79 Commits

Author SHA1 Message Date
doprz
c852b726de fix(linux-rust): format and fix syntax error 2025-12-14 10:23:32 +05:30
doprz
902b12a227 fix(clippy): fix cargo clippy warnings 2025-12-14 10:23:32 +05:30
doprz
6ded8ff3ff feat(nix): add comprehensive Nix flake for linux-rust 2025-12-14 10:23:32 +05:30
doprz
376c54247b feat(nix): add comprehensive Nix flake for linux-rust 2025-12-14 10:23:32 +05:30
Sophia
e2d17b8bae linux-rust: add nix flake (#371)
* build: 👷 add nix flake

* chore: 🙈 add nix's result to gitignore
2025-12-12 00:37:58 +05:30
Kavish Devar
6f0323ee6b linux-rust: parse single battery of AirPods Max 2025-11-23 00:35:09 +05:30
Kavish Devar
4737cbfc2c linux-rust: add battery to window and add option for text in tray 2025-11-20 18:56:17 +05:30
Kavish Devar
093554da07 linux-rust: add v0.1.0 to flatpak manifest 2025-11-10 14:26:26 +05:30
Kavish Devar
a01e16792a linux-rust: take version in justfile 2025-11-10 13:32:47 +05:30
Kavish Devar
253ed65afc linux-rust: add gitignore 2025-11-10 13:32:47 +05:30
Kavish Devar
b1f3856d0f linux-rust: update justfile for last two files 2025-11-10 13:32:47 +05:30
Kavish Devar
99a689a0f8 linux-rust: update desktop entry to use rDNS icon name 2025-11-10 13:32:47 +05:30
Kavish Devar
0c9a2bd743 linux-rust: rename binary 2025-11-10 13:32:47 +05:30
Kavish Devar
6585cf648c linux-rust: add session-bus to flatpak manifest for tray 2025-11-10 13:32:47 +05:30
Kavish Devar
99e5b71676 linux-rust: fix source name flatpak manifest 2025-11-10 13:32:47 +05:30
Kavish Devar
47f02136cd linux-rust: add metainfo for flatpak 2025-11-10 13:32:47 +05:30
Kavish Devar
29c422528a linux-rust: add just file to build appimage 2025-11-10 13:32:47 +05:30
Kavish Devar
9d10eed85d linux-rust: add desktop file 2025-11-10 13:32:47 +05:30
Kavish Devar
9f7f4347ec linux-rust: decrease icon size to 256x256 2025-11-10 13:32:47 +05:30
Kavish Devar
b7cd80edac linux-rust: cargo update 2025-11-10 13:32:47 +05:30
Kavish Devar
2049431817 linux-rust: parse encrypted ble info 2025-11-10 13:32:47 +05:30
Kavish Devar
23cf5728e9 linux-rust: add anc control for nothing device 2025-11-10 13:32:47 +05:30
Kavish Devar
381b09725b linux-rust: try enabling adaptive volume 2025-11-10 13:32:47 +05:30
Kavish Devar
3853e8ec9a linux-rust: add listening mode picker for airpods 2025-11-10 13:32:47 +05:30
Kavish Devar
bf6630dbd1 linux-rust: implement bluetooth logic with ui
finally! only copying all ui from android to first release
2025-11-10 13:32:47 +05:30
Kavish Devar
a2cda688d4 linux-rust: add skeleton for other devices 2025-11-10 13:32:47 +05:30
Kavish Devar
934df2419a linux-rust: add bt communication to ui 2025-11-10 13:32:47 +05:30
Kavish Devar
64470c4d34 linux-rust: show device info in UI 2025-11-10 13:32:47 +05:30
Kavish Devar
fa8bc11060 linux-rust: parse and store device info 2025-11-10 13:32:47 +05:30
Kavish Devar
c5a824c384 linux-rust: remove unused features from iced 2025-11-10 13:32:47 +05:30
Kavish Devar
99beeb5907 linux-rust: add --start-minimized 2025-11-10 13:32:47 +05:30
Kavish Devar
17b545481e linux-rust: fix conv detect 2025-11-10 13:32:47 +05:30
Kavish Devar
320964e9d7 linux-rust: add dummy UI 2025-11-10 13:32:47 +05:30
Kavish Devar
51b3d4692a linux-rust: fix conv-detect and add le auto-connect 2025-11-10 13:32:47 +05:30
Kavish Devar
3a0cc2e7f4 linux-rust: remove sampling period from LE scanner
tbh, I did not want to get results right away; batching would have been fine. But, BlueR seems to give the first scan result only after 10s if I set sampling period.
2025-11-10 13:32:47 +05:30
Kavish Devar
26cee5c8a5 linux-rust: show battery info from LE in tray
should probably have an indication when not connected
2025-11-10 13:32:47 +05:30
Kavish Devar
a007d9cd80 linux-rust: parse encrypted ble info 2025-11-10 13:32:47 +05:30
Kavish Devar
b47469803b linux-rust: parse encrypted ble info 2025-11-10 13:32:47 +05:30
Kavish Devar
925c930073 linux-rust: add ble skeleton 2025-11-10 13:32:47 +05:30
Kavish Devar
ccee82026d linux-rust: store irk and enc keys 2025-11-10 13:32:47 +05:30
Kavish Devar
99940b98ae linux-rust: add font 2025-11-10 13:32:47 +05:30
Kavish Devar
fec226336c linux-rust: catch errors 2025-11-10 13:32:47 +05:30
Kavish Devar
e5c2419ef6 linux-rust: optimize for size and add cli options 2025-11-10 13:32:47 +05:30
Kavish Devar
221680ff32 linux-rust: remove ATT temporarily 2025-11-10 13:32:47 +05:30
Kavish Devar
9da4c938ed linux-rust: add tipi 2025-11-10 13:32:47 +05:30
Kavish Devar
7dd029faa6 linux-rust: add att 2025-11-10 13:32:47 +05:30
Kavish Devar
ae5a701257 linux-rust: fix battery parsing 2025-11-10 13:32:47 +05:30
Kavish Devar
0f04290fba linux-rust: fix conv-detect toggle in tray menu 2025-11-10 13:32:47 +05:30
Kavish Devar
b0561e96df linux-rust: add conversational awareness 2025-11-10 13:32:47 +05:30
Kavish Devar
c0ae061cc7 linux-rust: remove percent character from tray 2025-11-10 13:32:47 +05:30
Kavish Devar
cf2a242d7c linux-rust: add tray icon 2025-11-10 13:32:47 +05:30
Kavish Devar
43bfbda21e linux-rust: implement basic connections 2025-11-10 13:32:47 +05:30
Kavish Devar
34f60699b8 ci: use just to build tarball 2025-11-10 13:32:40 +05:30
Kavish Devar
a38ebd213b ci: fix indendation in linux ci 2025-11-10 12:02:24 +05:30
Kavish Devar
142e0c5e37 ci: upload appimage and binary separately 2025-11-10 12:00:24 +05:30
Kavish Devar
a8963ecef5 ci: add libfuse in ci for appimage 2025-11-10 11:50:49 +05:30
Kavish Devar
53f5626914 ci: build appimage and vendored tarball for flatpak 2025-11-10 11:37:37 +05:30
imgbot[bot]
89782d9b7c [ImgBot] Optimize images (#237)
*Total -- 2,162.84kb -> 1,856.20kb (14.18%)

/android/imgs/hearing-test.png -- 72.02kb -> 32.69kb (54.62%)
/android/imgs/customizations-1.png -- 187.73kb -> 147.89kb (21.22%)
/android/imgs/accessibility.png -- 175.15kb -> 139.03kb (20.62%)
/android/imgs/transparency.png -- 142.45kb -> 114.67kb (19.5%)
/android/imgs/hearing-aid-adjustments.png -- 118.17kb -> 95.98kb (18.78%)
/android/imgs/long-press.png -- 89.79kb -> 74.73kb (16.77%)
/linux/imgs/main-app.png -- 32.58kb -> 27.15kb (16.67%)
/android/imgs/settings-2.png -- 265.03kb -> 221.76kb (16.33%)
/android/imgs/hearing-aid.png -- 73.45kb -> 61.93kb (15.68%)
/android/imgs/customizations-2.png -- 300.41kb -> 264.08kb (12.09%)
/android/imgs/settings-1.png -- 199.67kb -> 186.42kb (6.64%)
/android/imgs/head-tracking-and-gestures.png -- 142.86kb -> 134.84kb (5.61%)
/android/app/src/main/res-apple/drawable/airpods_pro_3_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_4_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_2_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_pro_2_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_3_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_pro_1_case.png -- 51.93kb -> 50.72kb (2.34%)
/android/app/src/main/res-apple/drawable/airpods_1_case.png -- 51.93kb -> 50.72kb (2.34%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-11-10 10:08:44 +05:30
汞齐
283f841855 android(i18n): Add complete Chinese translation (#240)
This pull request is purely a localization effort and **does not add any new features or UI elements** to the Android application. Its sole purpose is to enable a complete Chinese (zh-CN) display in a Chinese Android environment.

Changes:
- All translatable strings from  now have corresponding translated entries in .
- This significantly improves the user experience for Chinese-speaking users.
2025-11-05 19:19:09 +05:30
Marek Veselý
4c8ebe27bc android: Add missing HEAD_GESTURES capability to the Airpods Pro 3 (#239)
fix: Add missing HEAD_GESTURES capability to the Airpods Pro 3
2025-10-27 20:00:40 +05:30
Kavish Devar
bc5bcd81b3 ci: run android CI only when android src is changed 2025-10-27 17:51:28 +05:30
Kavish Devar
b72680a1c4 ci: build rust rewrite 2025-10-27 17:47:45 +05:30
Kavish Devar
816048f1f8 linux: parse enroled/enabled false as hearing aid disabled 2025-10-27 15:29:31 +05:30
Kavish Devar
d6b98b70e8 linux: fix hearing aid control cmd value
closes #236
2025-10-27 15:26:41 +05:30
Kavish Devar
f62a57c888 android: fix find replace mess-up 2025-10-27 15:19:10 +05:30
Kavish Devar
f7bb1ce6fc docs: update readme 2025-10-27 15:19:10 +05:30
Kavish Devar
9bf3e64b20 docs: update readme 2025-10-26 21:34:41 +05:30
Kavish Devar
217455fecb android: bump version 2025-10-26 21:10:44 +05:30
Kavish Devar
84d5fa466e stop linux ci
development for the old version is stopped. rewrite wip
2025-10-26 21:10:35 +05:30
Kavish Devar
8eb6eba049 android: multidevice capabilites and accessiblity features (and "liquid glass") (#202)
many thanks to @rithvikvibhu for help with the hearing aids feature

adds:
hearing aid
two-device connection
new UI
transparency mode customization

commits:
* android: add accessibility stuff

adds option for customizing transparency mode, amplification, tone, etc.

* docs: update transparency mode format

* android: don't 'start' service every time MainActivity is launched

* android: add basic multidevice capabilities

use at your own risk, may or may not work

* android: clean up a bit of AI gen'd code

* android: clean up main service and remove minimum API on head gestures

* android: clean up a lot of stuff

* android: implement the accessiblity settings page

* android: add EQ settings for phone and media

* android: add toggle for DID hook

* docs: add 'has ownership' control cmd

* android: fix balance NaN error when amplification L/R is both zero

* android: bring back some accessiblity settings and add listeners for all config

* android: add header to ATTManager

* android: use device name sent by the connected device in island

* android: fix track color in tone volume

* android: remove unused composable

* android: update eq sliders style

* android: fix text color in selectors

* android: add delay before starting head tracking again

* android: add a few options

ik not the right branch/pr but, eh, i am not merging this hook until i test further, and if i don't merge, conflicts, a lot of 'em

* android: a small ui fix

* docs: a few more control cmds

* android: add microphone setting

also, un-hardcoded strings, and updated text sizes

* android: improve dropdowns

ai generated

* android: move attmanager to service to avoid trying to connect multiple times

* android: add ui for hearing stuff

mostly copied from the transparency settings, which are now updated to match ios <26 ui

* android: add media assist options in hearing aid

ui only

* android: add hearing aid adjustments

* android: liquidglass sliders

* android: improve liquid glass sliders

* android: little more liquid glass

* android: fix hearing aid parsing

* android: remove customdeviceactivity from manifest

* android: remove unused strings

* android: small ui tweaks

* android: a very big commit

refactoring ui, mostly

* android: move padding to StyledScaffold's content

because haze needs it

* android: revert accidental capitalization on toggle label

* android: update usages for toggle

* android: liquidglass, maybe?

the switch and icon button took quite a while. i forgot the order of modifiers matters!

* remove bleonly mode, use CAPod instead

* remove bleonly mode, use CAPod instead

* android: fix switch styling

* android: remove fade from transition

* android: add A16's new bluetooth identifier for log collection

just why...

* android: fix crash in head gestures screen

* android: show head gestures status in the navigation button

* android: don't crash if att not available

* android: use lazycolumn in airpods settings for better performance and navigation transitions

* android: fix text color in troubshooting button and pressandhold settings

* android: bring back original confirmation dialog

too lazy to fix/implement properly the glassy one

* android: prevent hearing aid turning off itself

* android: hide media assist, not implemented

* docs: update README with new features

* docs: add demo video

* docs: add new screenshots for android

* docs: update demo video position

* docs: app3 compatibility

* docs: new control cmds '25 (again)

* docs: change section title in control cmd doc

Updated section title from 'Control Commands' to 'Identifiers and details'.

* android: ui tweaks

* android: update styled slider thumb

* android: add accessiblity service for camera control

* android: add camera control, finally

i got too lazy to find out how to listen to app openings earlier, wasn't too hard

* android: add option to change camera app id

* android: not use relative paths for executing commands

i hope it's the same across all skins

* android: fix transparency and noise cancellation flags

huh... was it always like this?

* android: revert to using relative paths for su

compatibility issues with magisk

* android: bump version

* android: don't crash if self MAC is not available

* android: remove unused LOCAL_ADDRESS permission

* android: add opensource licenses

should've done this a long time ago!

* android: move navigation button to activity level

* android: update animation time on switch tap

* android: implement setting hearing test results

* android: update title in hearing test screen

* docs: add screenshot for hearing test

* android: fix haze for dialog when enabling hearing aid

* android: parse device info

* android: add support for various models

still need to update images or find a way to fetch from apple's cdn

* android: fix a2dp connection

* android: remove stray eq config in accessibility settings

* android: improve connection handling

* android: add a (very important) support dialog

to not be invasive, this only shows up once, and never again.

* docs: add note for DID hook on android
2025-10-26 21:03:49 +05:30
Kavish Devar
e437572355 linux: remove specific qt version requirement 2025-10-20 14:50:23 +05:30
Kavish Devar
f062eb43b3 linux: hearing aid support (#230)
* linux: add hearing aid

it's just a simple python script, with a toggle in the main app. i dont want to mess with the main app because it uses ATT instead of the AACP protocol which is implemented in the app.

* linux: implement adding hearing aid test results

* docs: add linux screenshot

* docs: add linux hearing aid script

* linux: add reset button for hearing aid adjustments

* linux: remove MAC address logging for security
2025-10-16 12:13:57 +05:30
Mathias S.
28ffd217d6 docs: add troubleshooting section for media controls not working (#224) 2025-10-02 20:37:52 +05:30
Kavish Devar
b5d1ad0dd5 linux: add support for multiple media players (#222)
also add wait for setting card profiles to complete
2025-10-02 12:06:44 +05:30
Kavish Devar
a6d6e0e13c linux: fix name for preferred a2dp profile in mediacontroller 2025-10-02 11:33:23 +05:30
Kavish Devar
fe774d565d linux: replace pactl calls with libpulse (#221) 2025-10-02 11:25:41 +05:30
Kavish Devar
120681541f docs: remove instructions for phone mac address
Removed instructions for setting PHONE_MAC_ADDRESS, handled in UI.
2025-10-02 00:47:40 +05:30
Mathias S.
784b7a2cfa [Linux] Auto-restart WirePlumber when A2DP profile is unavailable (#213)
Fix: Auto-restart WirePlumber when A2DP profile is unavailable
2025-10-01 20:23:19 +05:30
Mathias S.
d7a96c9fc5 Fix: Activate A2DP audio profile for AirPods after system reboot on Linx (#212)
Fix: Activate A2DP audio profile for AirPods after system reboot

- Add A2DP profile activation on system wake-up
- Add A2DP profile activation when BlueZ detects connection
- Add A2DP profile activation for already connected devices on startup

Fixes issue where AirPods microphone works but audio output is unavailable after reboot
2025-09-30 14:17:16 +05:30
73 changed files with 14395 additions and 258 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -4,6 +4,8 @@ on:
push:
branches:
- '*'
paths:
- 'android/**'
workflow_dispatch:
inputs:
release:

87
.github/workflows/ci-linux-rust.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Linux Build & Release
on:
push:
branches:
- linux/rust
tags:
- 'linux-v*'
paths:
- 'linux-rust/**'
- '.github/workflows/linux-build.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libdbus-1-dev libpulse-dev appstream just libfuse2
- name: Install AppImage tools
run: |
wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O /usr/local/bin/appimagetool
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage -O /usr/local/bin/linuxdeploy
chmod +x /usr/local/bin/{appimagetool,linuxdeploy}
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
linux-rust/target
key: ${{ runner.os }}-cargo-${{ hashFiles('linux-rust/Cargo.lock') }}
- name: Build AppImage and Binary
working-directory: linux-rust
run: |
cargo build --release --verbose
just
mkdir -p dist
cp target/release/librepods dist/librepods
mv dist/LibrePods-x86_64.AppImage dist/librepods-x86_64.AppImage
- name: Upload AppImage artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods-x86_64.AppImage
path: linux-rust/dist/librepods-x86_64.AppImage
- name: Upload binary artifact
if: "!startsWith(github.ref, 'refs/tags/linux-v')"
uses: actions/upload-artifact@v4
with:
name: librepods
path: linux-rust/dist/librepods
- name: Create tarball for Flatpak
if: startsWith(github.ref, 'refs/tags/linux-v')
working-directory: linux-rust
run: |
VERSION="${GITHUB_REF_NAME#linux-v}"
just tarball "${VERSION}"
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "TAR_PATH=linux-rust/dist/librepods-v${VERSION}-source.tar.gz" >> $GITHUB_ENV
- name: Create GitHub Release (AppImage + binary + source)
if: startsWith(github.ref, 'refs/tags/linux-v')
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
files: |
linux-rust/dist/librepods-v${{ env.VERSION }}-source.tar.gz
linux-rust/dist/librepods-x86_64.AppImage
linux-rust/dist/librepods
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,9 +1,10 @@
name: Build LibrePods Linux
on:
push:
branches:
- '*'
workflow_dispatch:
# push:
# branches:
# - '*'
jobs:
build-linux:
@@ -33,4 +34,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: librepods-linux
path: linux/build/librepods
path: linux/build/librepods

7
.gitignore vendored
View File

@@ -659,3 +659,10 @@ obj/
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
linux/.qmlls.ini
# Nix
result
result-*
# direnv
.direnv

View File

@@ -78,10 +78,12 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
#### Root Requirement
> [!CAUTION]
> **You must have a rooted device to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
> **You must have a rooted device with Xposed to use LibrePods on Android.** This is due to a [bug in the Android Bluetooth stack](https://issuetracker.google.com/issues/371713238). Please upvote the issue by clicking the '+1' icon on the IssueTracker page.
>
> There are **no exceptions** to the root requirement until Google merges the fix.
Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features.
## Bluetooth DID (Device Identification) Hook
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
@@ -94,53 +96,20 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr
Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured.
The hearing aid feature can now also be used. Currently it can only be used to adjust the settings, not actually take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
All hearing aid customizations can be done from Android, including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result.
>[!NOTE]
> To enable these features, enable App Settings -> `act as Apple Device`.
> This only works if you use the Xposed method or patch the library yourself. The root module method does not support this feature currently.
#### Installation Methods
##### Method 1: Xposed Module (Recommended)
This method is less intrusive and should be tried first:
1. Install LSPosed, or another Xposed provider on your rooted device
2. Download the LibrePods app from the releases section, and install it.
3. Enable the Xposed module for the bluetooth app in your Xposed manager.
4. Disable unmount modules for the Bluetooth app if enabled.
5. Follow the instructions in the app to set up the module.
6. Open the app and connect your AirPods
##### Method 2: Root Module (Backup Option)
If the Xposed method doesn't work for you:
1. Download the `btl2capfix.zip` module from the releases section
2. Install it using your preferred root manager (KernelSU, Apatch, or Magisk).
3. Disable Unmount modules for the Bluetooth aop if enabled.
4. Reboot your device
5. Connect your AirPods
##### Method 3: Patching it yourself
If you prefer to patch the Bluetooth stack yourself, follow these steps:
1. Look for the library in use by running `lsof | grep libbluetooth`
2. Find the library path (e.g., `/system/lib64/libbluetooth_jni.so`)
3. Find the `l2c_fcr_chk_chan_modes` function in the library
4. Patch the function to always return `1` (true)
5. Repack the library and push it back to the device. You can do this by creating a root module yourself.
6. Reboot your device
If you're unfamiliar with these steps, search for tutorials online or ask in Android rooting communities.
To enable these features, enable App Settings -> `act as Apple Device`.
#### A few notes
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced!
- Due to recent AirPods' firmware upgrades, you must enable `Off listening mode` to switch to `Off`. This is because in this mode, louds sounds are not reduced.
- If you have take both AirPods out, the app will automatically switch to the phone speaker. But, Android might keep on trying to connect to the AirPods because the phone is still connected to them, just the A2DP profile is not connected. The app tries to disconnect the A2DP profile as soon as it detects that Android has connected again if they're not in the ear.
- When renaming your AirPods through the app, you'll need to re-pair them with your phone for the name change to take effect. This is a limitation of how Bluetooth device naming works on Android.
- If you want the AirPods icon and battery status to show in Android Settings app, install the app as a system app by using the root module.
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=kavishdevar/librepods&type=Date)](https://star-history.com/#kavishdevar/librepods&Date)

View File

@@ -15,7 +15,7 @@ android {
minSdk = 28
targetSdk = 36
versionCode = 8
versionName = "0.2.0-beta.1"
versionName = "0.2.0"
}
buildTypes {

View File

@@ -193,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) {
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."

View File

@@ -187,6 +187,7 @@ class AirPodsPro3: AirPodsBase(
capabilities = setOf(
Capability.LISTENING_MODE,
Capability.CONVERSATION_AWARENESS,
Capability.HEAD_GESTURES,
Capability.STEM_CONFIG,
Capability.LOUD_SOUND_REDUCTION,
Capability.PPE,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -1,77 +1,207 @@
<resources>
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
<string name="accessibility">辅助功能</string>
<string name="tone_volume">提示音音量</string>
<string name="audio">音频</string>
<string name="adaptive_audio">自适应音频</string>
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
<string name="buds">耳机</string>
<string name="case_alt">充电盒</string>
<string name="test">测试</string>
<string name="name">名称</string>
<string name="noise_control">噪音控制</string>
<string name="off">关闭</string>
<string name="transparency">通透模式</string>
<string name="adaptive">自适应</string>
<string name="noise_cancellation">主动降噪</string>
<string name="press_and_hold_airpods">按住 AirPods</string>
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string>
<string name="volume_control_description">通过在 AirPods Pro 柄部传感器上下滑动调节音量。</string>
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。如果卡在此处,请先关闭应用再重新打开。\n不要强制结束应用</string>
<string name="back">返回</string>
<string name="app_settings">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停。</string>
<string name="appwidget_text">示例</string>
<string name="add_widget">添加小组件</string>
<string name="noise_control_widget_description">在主屏幕直接控制噪音模式</string>
<string name="island_connected_text">已连接</string>
<string name="island_connected_remote_text">已连接到 Linux</string>
<string name="island_taking_over_text">已切换到手机</string>
<string name="island_moved_to_remote_text">切换到 Linux</string>
<string name="head_tracking">头部追踪</string>
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
<string name="general_settings_header">通用</string>
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
<string name="qs_click_behavior_dialog_desc">点击时显示噪音控制对话框。</string>
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
<string name="developer_options_header">开发者</string>
<string name="more_settings_title">打开 AirPods 设置</string>
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
<string name="ear_detection">自动入耳检测</string>
<string name="auto_play">自动播放</string>
<string name="auto_pause">自动暂停</string>
<string name="troubleshooting">故障排查</string>
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
<string name="collect_logs">收集日志</string>
<string name="saved_logs">已保存的日志</string>
<string name="no_logs_found">未找到保存的日志</string>
<string name="takeover_header">自动连接偏好</string>
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
<string name="takeover_disconnected">未连接</string>
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
<string name="takeover_idle">空闲</string>
<string name="takeover_idle_desc">某设备已连接 AirPods但未播放媒体或通话</string>
<string name="takeover_music">正在播放媒体</string>
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
<string name="takeover_call">正在通话</string>
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods</string>
<string name="takeover_ringing_call">来电中</string>
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
<string name="takeover_media_start">开始播放媒体</string>
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
</resources>
<resources>
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
<string name="accessibility">辅助功能</string>
<string name="tone_volume">提示音音量</string>
<string name="tone_volume_description">调整 AirPods 播放的提示音音量。</string>
<string name="audio">音频</string>
<string name="adaptive_audio">自适应音频</string>
<string name="customize_adaptive_audio">自定义自适应音频</string>
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
<string name="buds">耳机</string>
<string name="case_alt">充电盒</string>
<string name="test">测试</string>
<string name="name">名称</string>
<string name="noise_control">听音模式</string>
<string name="off">关闭</string>
<string name="transparency">通透模式</string>
<string name="adaptive">自适应</string>
<string name="noise_cancellation">主动降噪</string>
<string name="press_and_hold_airpods">按住 AirPods</string>
<string name="press_and_hold_noise_control_description">按住耳机柄以在选定的听音模式之间循环切换。</string>
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string>
<string name="volume_control_description">通过在 AirPods Pro柄部传感器上下滑动调节音量。</string>
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</string>
<string name="back">返回</string>
<string name="app_settings">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停</string>
<string name="appwidget_text">示例</string>
<string name="add_widget">添加小组件</string>
<string name="noise_control_widget_description">直接在主屏幕上控制听音模式。</string>
<string name="island_connected_text">连接</string>
<string name="island_connected_remote_text">已连接到 Linux</string>
<string name="island_taking_over_text">已连接</string>
<string name="island_moved_to_remote_text">已切换到 Linux</string>
<string name="island_moved_to_other_device_text">已切换到 %1$s</string>
<string name="island_moved_to_other_device_reversed_text">从通知中重新连接</string>
<string name="head_tracking">头部追踪</string>
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
<string name="general_settings_header">通用</string>
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
<string name="qs_click_behavior_dialog_desc">点击时显示听音模式控制对话框。</string>
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
<string name="developer_options_header">开发者</string>
<string name="more_settings_title">打开 AirPods 设置</string>
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
<string name="ear_detection">自动入耳检测</string>
<string name="auto_play">自动播放</string>
<string name="auto_pause">自动暂停</string>
<string name="troubleshooting">故障排查</string>
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
<string name="collect_logs">收集日志</string>
<string name="saved_logs">已保存的日志</string>
<string name="no_logs_found">未找到保存的日志</string>
<string name="takeover_header">自动连接偏好</string>
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
<string name="takeover_disconnected">未连接</string>
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
<string name="takeover_idle">空闲</string>
<string name="takeover_idle_desc">某设备已连接 AirPods但未播放媒体或通话</string>
<string name="takeover_music">正在播放媒体</string>
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
<string name="takeover_call">正在通话</string>
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods</string>
<string name="takeover_ringing_call">来电中</string>
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
<string name="takeover_media_start">开始播放媒体</string>
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
<string name="undo">撤销</string>
<string name="customize_transparency_mode_description">你可以为你的 AirPods Pro 自定义通透模式,以帮助你听清周围的声音。</string>
<string name="loud_sound_reduction_description">在通透模式和自适应模式下,响度声音降低功能可以主动减少你暴露在响亮环境噪音中的时间。此功能在关闭模式下不生效。</string>
<string name="loud_sound_reduction">大声减弱</string>
<string name="call_controls">通话控制</string>
<string name="automatically_connect">自动连接到此设备</string>
<string name="automatically_connect_description">启用后AirPods 将尝试自动连接到此设备。否则,它们将仅在上次连接时尝试自动连接。</string>
<string name="sleep_detection">入睡时暂停媒体</string>
<string name="off_listening_mode">关闭听音模式</string>
<string name="off_listening_mode_description">开启后AirPods 的听音模式将包含一个“关闭”选项。当听音模式设置为“关闭”时,高音量将不会被降低。</string>
<string name="microphone">麦克风</string>
<string name="microphone_mode">麦克风模式</string>
<string name="microphone_automatic">自动</string>
<string name="microphone_always_right">始终右耳</string>
<string name="microphone_always_left">始终左耳</string>
<string name="answer_call">接听电话</string>
<string name="mute_unmute">静音/取消静音</string>
<string name="hang_up">挂断</string>
<string name="press_once">按一次</string>
<string name="press_twice">按两次</string>
<string name="hearing_aid">助听</string>
<string name="adjustments">调整</string>
<string name="swipe_to_control_amplification">滑动以控制放大</string>
<string name="swipe_amplification_description">在通透模式下且无媒体播放时,在 AirPods Pro 的触摸控件上向上或向下滑动,以增强或减弱环境音的放大效果。</string>
<string name="transparency_mode">通透模式</string>
<string name="customize_transparency_mode">自定义通透模式</string>
<string name="press_speed">按压速度</string>
<string name="press_speed_description">调整在 AirPods 上需要按两次或三次的速度。</string>
<string name="press_and_hold_duration">按住持续时间</string>
<string name="press_and_hold_duration_description">调整在 AirPods 上需要按住的持续时间。</string>
<string name="volume_swipe_speed">音量滑动速度</string>
<string name="volume_swipe_speed_description">为防止意外的音量调整,请选择滑动之间的首选等待时间。</string>
<string name="equalizer">均衡器</string>
<string name="apply_eq_to">将均衡器应用于</string>
<string name="phone">电话</string>
<string name="media">媒体</string>
<string name="band_label">频段 %d</string>
<string name="default_option">默认</string>
<string name="slower">较慢</string>
<string name="slowest">最慢</string>
<string name="longer">较长</string>
<string name="longest">最长</string>
<string name="darker">更暗</string>
<string name="brighter">更亮</string>
<string name="less">更少</string>
<string name="more">更多</string>
<string name="amplification">放大</string>
<string name="balance">平衡</string>
<string name="tone">音调</string>
<string name="ambient_noise_reduction">环境降噪</string>
<string name="conversation_boost">对话增强</string>
<string name="conversation_boost_description">对话增强功能可将你的 AirPods Pro 聚焦于正前方的讲话者,让你在面对面交谈时听得更清楚。</string>
<string name="hearing_aid_description">AirPods 可以使用听力测试的结果进行调整,以提高你周围语音和声音的清晰度。
助听功能仅适用于有轻度至中度听力损失感知的人群。</string>
<string name="media_assist">媒体辅助</string>
<string name="media_assist_description">AirPods Pro 可以使用听力测试的结果进行调整,以提高音乐、视频和通话的清晰度。</string>
<string name="adjust_media">调整音乐和视频</string>
<string name="adjust_calls">调整通话</string>
<string name="widget">小组件</string>
<string name="show_phone_battery_in_widget">在小组件中显示手机电量</string>
<string name="show_phone_battery_in_widget_description">在小组件中与 AirPods 电量一同显示手机电量。</string>
<string name="conversational_awareness_volume">对话感知音量</string>
<string name="quick_settings_tile">快捷设置磁贴</string>
<string name="open_dialog_for_controlling">打开控制对话框</string>
<string name="open_dialog_for_controlling_description">如果禁用,点击快捷设置将循环切换模式。如果启用,它将显示一个用于控制听音模式和对话感知的对话框。</string>
<string name="disconnect_when_not_wearing">未佩戴时断开 AirPods</string>
<string name="disconnect_when_not_wearing_description">你仍然可以通过应用控制它们 - 这只是断开音频连接。</string>
<string name="advanced_options">高级选项</string>
<string name="set_identity_resolving_key">设置身份解析密钥 (IRK)</string>
<string name="set_identity_resolving_key_description">手动设置用于解析蓝牙低功耗随机地址的 IRK 值。</string>
<string name="set_encryption_key">设置加密密钥</string>
<string name="set_encryption_key_description">手动设置用于解密蓝牙低功耗广播的ENC_KEY 值。</string>
<string name="use_alternate_head_tracking_packets">使用备用头部追踪数据包</string>
<string name="use_alternate_head_tracking_packets_description">如果头部追踪对你无效,请启用此选项。这将向 AirPods 发送不同的数据以请求/停止头部追踪数据。</string>
<string name="act_as_an_apple_device">模拟为 Apple 设备</string>
<string name="act_as_an_apple_device_description">启用多设备连接和辅助功能,例如自定义通透模式(放大、音调、环境降噪、对话增强和均衡器)</string>
<string name="act_as_an_apple_device_warning">可能不稳定!!你的 AirPods 最多可以连接两台设备。如果你正在与 iPad 或 Mac 等 Apple 设备一起使用,请先连接该设备,然后再连接你的 Android 设备。</string>
<string name="reset_hook_offset">重置钩子偏移</string>
<string name="reset_hook_offset_description">这将清除当前的钩子偏移,并要求你重新进行设置过程。你确定要继续吗?</string>
<string name="reset">重置</string>
<string name="hook_offset_reset_success">钩子偏移已重置。正在重定向到设置...</string>
<string name="hook_offset_reset_failure">重置钩子偏移失败</string>
<string name="irk_set_success">IRK 已成功设置</string>
<string name="encryption_key_set_success">加密密钥已成功设置</string>
<string name="irk_hex_value">IRK 十六进制值</string>
<string name="enc_key_hex_value">ENC_KEY 十六进制值</string>
<string name="enter_irk_hex">输入 16 字节 IRK 的十六进制字符串32 个字符):</string>
<string name="enter_enc_key_hex">输入 16 字节ENC_KEY 的十六进制字符串32 个字符):</string>
<string name="must_be_32_hex_chars">必须是 32 个十六进制字符</string>
<string name="error_converting_hex">十六进制转换错误:</string>
<string name="found_offset_restart_bluetooth">找到偏移量,请重启蓝牙进程</string>
<string name="digital_assistant">数字助理</string>
<string name="on">开启</string>
<string name="camera_remote">相机遥控</string>
<string name="camera_control">相机控制</string>
<string name="camera_control_description">使用按一次或按住来拍照、开始或停止录制等。当使用 AirPods 进行相机操作时,如果选择按一次,媒体控制手势将不可用;如果选择按住,听音模式和数字助理手势将不可用。</string>
<string name="camera_control_app_description">为相机检测设置自定义应用包</string>
<string name="set_custom_camera_package">设置自定义相机应用 ID</string>
<string name="enter_custom_camera_package">输入相机应用的应用程序 ID</string>
<string name="custom_camera_package">自定义相机应用 ID</string>
<string name="custom_camera_package_set_success">自定义相机应用 ID 设置成功</string>
<string name="app_listener_service_label">相机监听器</string>
<string name="app_listener_service_description">LibrePods 的监听服务,用于检测相机何时处于活动状态,以激活 AirPods 上的相机控制。</string>
<string name="open_source_licenses">开源许可证</string>
<string name="hearing_test">更新听力测试</string>
<string name="update_hearing_test">更新听力测试结果</string>
<string name="att_manager_is_null_try_reconnecting">ATT 管理器为空,请尝试重新连接。</string>
<string name="permissions_required">使用此应用需要以下权限。请授予它们以继续。</string>
<string name="shake_your_head_or_nod">摇摇头或点点头!</string>
<string name="root_access_required">需要 Root 权限</string>
<string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">此应用需要 Root 权限才能挂钩到蓝牙库</string>
<string name="root_access_denied">Root 权限被拒绝。请授予 Root 权限。</string>
<string name="troubleshooting_steps">故障排除步骤</string>
<string name="hearing_test_value_instruction">请输入 dbHL 中的损失值</string>
<string name="hearing_health">听力健康</string>
<string name="hearing_protection">听力保护</string>
<string name="workspace_use">工作区使用</string>
<string name="ppe">EN 352 保护</string>
<string name="workspace_use_description">EN 352 保护将媒体的最大音量限制为 82 dBA并符合适用的 EN 352 个人听力保护标准要求。</string>
<string name="environmental_noise">环境噪音</string>
<string name="reconnect_to_last_device">重新连接到上次连接的设备</string>
<string name="disconnect">断开连接</string>
<string name="support_dialog_description">我最近丢了我的左耳 AirPod。如果你觉得 LibrePods 有用,请考虑在 GitHub Sponsors 上支持我,这样我就可以购买一个替换品并继续从事这个项目——即使是少量捐助也能发挥很大作用。感谢你的支持!</string>
<string name="support_librepods">支持 LibrePods</string>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 115 KiB

12
default.nix Normal file
View File

@@ -0,0 +1,12 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
nodeName = lock.nodes.root.inputs.flake-compat;
in
fetchTarball {
url =
lock.nodes.${nodeName}.locked.url
or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
sha256 = lock.nodes.${nodeName}.locked.narHash;
}
) { src = ./.; }).defaultNix

143
flake.lock generated Normal file
View File

@@ -0,0 +1,143 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1765145449,
"narHash": "sha256-aBVHGWWRzSpfL++LubA0CwOOQ64WNLegrYHwsVuVN7A=",
"owner": "ipetkov",
"repo": "crane",
"rev": "69f538cdce5955fcd47abfed4395dc6d5194c1c5",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-compat": {
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"revCount": 69,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.1.0/01948eb7-9cba-704f-bbf3-3fa956735b52/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1765495779,
"narHash": "sha256-MhA7wmo/7uogLxiewwRRmIax70g6q1U/YemqTGoFHlM=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "5635c32d666a59ec9a55cab87e898889869f7b71",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1765425892,
"narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1761765539,
"narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "719359f4562934ae99f5443f20aa06c2ffff91fc",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1761236834,
"narHash": "sha256-+pthv6hrL5VLW2UqPdISGuLiUZ6SnAXdd2DdUE+fV2Q=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "d5faa84122bc0a1fd5d378492efce4e289f8eac1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"crane": "crane",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"systems": "systems",
"treefmt-nix": "treefmt-nix"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1762938485,
"narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

141
flake.nix Normal file
View File

@@ -0,0 +1,141 @@
{
description = "AirPods liberated from Apple's ecosystem";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane.url = "github:ipetkov/crane";
flake-parts.url = "github:hercules-ci/flake-parts";
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz";
systems.url = "github:nix-systems/default";
treefmt-nix.url = "github:numtide/treefmt-nix";
};
outputs =
inputs@{
self,
crane,
flake-parts,
systems,
...
}:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = import systems;
imports = [
inputs.treefmt-nix.flakeModule
];
perSystem =
{
self',
pkgs,
lib,
...
}:
let
buildInputs =
with pkgs;
[
dbus
libpulseaudio
alsa-lib
bluez
# https://github.com/max-privatevoid/iced/blob/master/DEPENDENCIES.md
expat
fontconfig
freetype
freetype.dev
libGL
pkg-config
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
wayland
libxkbcommon
vulkan-loader
]
++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
pkgs.libiconv
];
nativeBuildInputs = with pkgs; [
pkg-config
makeWrapper
];
craneLib = crane.mkLib pkgs;
unfilteredRoot = ./linux-rust/.;
src = lib.fileset.toSource {
root = unfilteredRoot;
fileset = lib.fileset.unions [
# Default files from crane (Rust and cargo files)
(craneLib.fileset.commonCargoSources unfilteredRoot)
(lib.fileset.maybeMissing ./linux-rust/assets/font)
];
};
commonArgs = {
inherit buildInputs nativeBuildInputs src;
strictDeps = true;
# RUST_BACKTRACE = "1";
};
librepods = craneLib.buildPackage (
commonArgs
// {
cargoArtifacts = craneLib.buildDepsOnly commonArgs;
doCheck = false;
# Wrap the binary after build to set runtime library path
postInstall = ''
wrapProgram $out/bin/librepods \
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath buildInputs}
'';
meta = {
description = "AirPods liberated from Apple's ecosystem";
homepage = "https://github.com/kavishdevar/librepods";
license = pkgs.lib.licenses.gpl3Only;
maintainers = [ "kavishdevar" ];
platforms = pkgs.lib.platforms.unix;
mainProgram = "librepods";
};
}
);
in
{
checks = {
inherit librepods;
};
packages.default = librepods;
apps.default = {
type = "app";
program = lib.getExe librepods;
};
devShells.default = craneLib.devShell {
name = "librepods-dev";
checks = self'.checks;
# NOTE: cargo and rustc are provided by default.
buildInputs =
with pkgs;
[
rust-analyzer
]
++ buildInputs;
LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs;
};
treefmt = {
programs.nixfmt.enable = pkgs.lib.meta.availableOn pkgs.stdenv.buildPlatform pkgs.nixfmt-rfc-style.compiler;
programs.nixfmt.package = pkgs.nixfmt-rfc-style;
};
};
};
}

7
linux-rust/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
**/*.flatpak
repo
dist
build-dir
vendor
.cargo
.flatpak-builder

5667
linux-rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

31
linux-rust/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "librepods"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = {version = "1.47.1", features = ["full"]}
bluer = { version = "0.17.4", features = ["full"] }
env_logger = {version = "0.11.8", features = ["auto-color"]}
uuid = "1.18.1"
log = "0.4.28"
dbus = "0.9.9"
hex = "0.4.3"
iced = { version = "0.13.1", features = ["tokio", "image"] }
libpulse-binding = "2.30.1"
ksni = "0.3.1"
image = "0.25.8"
imageproc = "0.25.0"
ab_glyph = "0.2.32"
clap = { version = "4.5.50", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
aes = "0.8.4"
futures = "0.3.31"
[profile.release]
opt-level = "s"
lto = true
codegen-units = 8
panic = "abort"
strip = true

70
linux-rust/Justfile Normal file
View File

@@ -0,0 +1,70 @@
APP_NAME := "librepods"
DESKTOP_FILE := "assets/me.kavishdevar.librepods.desktop"
ICON_FILE := "assets/icon.png"
default: build-appimage
build:
cargo build --release
prepare:
#!/usr/bin/env bash
set -euo pipefail
tmpdir="$(mktemp -d)"
echo "Building AppDir in: $tmpdir"
mkdir -p "$tmpdir/usr/bin"
mkdir -p "$tmpdir/usr/share/applications"
mkdir -p "$tmpdir/usr/share/icons/hicolor/256x256/apps"
cp target/release/{{APP_NAME}} "$tmpdir/usr/bin/"
cp assets/icon.png "$tmpdir/usr/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png"
cp {{DESKTOP_FILE}} "$tmpdir/{{APP_NAME}}.desktop"
printf '%s\n' \
'#!/bin/bash' \
'HERE="$(dirname "$(readlink -f "$0")")"' \
'exec "$HERE/usr/bin/librepods" "$@"' \
> "$tmpdir/AppRun"
chmod +x "$tmpdir/AppRun"
echo "$tmpdir" > .appdir_path
bundle:
#!/usr/bin/env bash
set -euo pipefail
tmpdir="$(cat .appdir_path)"
linuxdeploy \
--appdir "$tmpdir" \
--executable "$tmpdir/usr/bin/{{APP_NAME}}" \
--desktop-file "$tmpdir/{{APP_NAME}}.desktop" \
--icon-file "$tmpdir/usr/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png"
build-appimage: build prepare bundle
#!/usr/bin/env bash
set -euo pipefail
tmpdir="$(cat .appdir_path)"
mkdir -p dist
appimagetool "$tmpdir" "dist/LibrePods-x86_64.AppImage"
rm -rf "$tmpdir" .appdir_path
echo "Done!"
tarball version:
#!/usr/bin/env bash
set -euo pipefail
cargo vendor vendor
mkdir -p dist .cargo
cat > .cargo/config.toml <<'EOF'
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
EOF
TAR="librepods-v{{version}}-source.tar.gz"
tar -czf "dist/${TAR}" \
--transform "s,^,librepods-v{{version}}/," \
Cargo.toml Cargo.lock src vendor .cargo assets flatpak
echo "Created: dist/${TAR}"

Binary file not shown.

Binary file not shown.

BIN
linux-rust/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -0,0 +1,6 @@
[Desktop Entry]
Name=LibrePods
Exec=librepods
Icon=me.kavishdevar.librepods
Type=Application
Categories=Utility;

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>me.kavishdevar.librepods</id>
<name>LibrePods</name>
<summary>Liberate AirPods from Apple&apos;s ecosystem</summary>
<metadata_license>CC-BY-SA-4.0</metadata_license>
<project_license>AGPL-3.0-only</project_license>
<description>
<p>
Key - Noise Control Modes: Easily switch between noise control modes without having to reach out to your AirPods to long - Ear Detection: Controls your music automatically when you put your AirPods in or take them out, and switch to phone speaker when you take them out - Battery Status: Accurate battery levels - Conversational Awareness: Volume automatically lowers when you speak - Hearing Aid: Setup Hearing Aid, even in an unsupported region
</p>
</description>
<launchable type="desktop-id">me.kavishdevar.librepods.desktop</launchable>
<screenshots>
<screenshot type="default">
<image>https://raw.githubusercontent.com/kavishdevar/librepods/refs/heads/main/linux/imgs/main-app.png</image>
</screenshot>
</screenshots>
</component>

View File

@@ -0,0 +1,43 @@
app-id: me.kavishdevar.librepods
runtime: org.freedesktop.Platform
runtime-version: '25.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: librepods
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --socket=pulseaudio
- --system-talk-name=org.bluez
- --allow=bluetooth
- --share=network
- --socket=session-bus
build-options:
append-path: /usr/lib/sdk/rust-stable/bin
env:
CARGO_HOME: /run/build/librepods/cargo
CARGO_NET_OFFLINE: 'true'
RUSTUP_HOME: /usr/lib/sdk/rust-stable
modules:
- name: librepods
buildsystem: simple
build-options:
env:
CARGO_NET_OFFLINE: 'true'
build-commands:
- cargo build --release --frozen --offline --verbose
- install -Dm755 target/release/librepods ${FLATPAK_DEST}/bin/librepods
- install -Dm644 assets/icon.png ${FLATPAK_DEST}/share/icons/hicolor/256x256/apps/me.kavishdevar.librepods.png
- install -Dm644 assets/me.kavishdevar.librepods.desktop ${FLATPAK_DEST}/share/applications/${FLATPAK_ID}.desktop
- install -Dm644 flatpak/me.kavishdevar.librepods.metainfo.xml ${FLATPAK_DEST}/share/metainfo/${FLATPAK_ID}.metainfo.xml
sources:
- type: archive
# path: ../dist/librepods-vlocal-source.tar.gz
url: https://github.com/kavishdevar/librepods/releases/download/linux-v0.1.0/librepods-v0.1.0-source.tar.gz
sha256: 78828d6113dcdc37be9aa006d7a437ec1705978669cddb9342824ec9546a7b4e

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,275 @@
use bluer::l2cap::{SeqPacket, Socket, SocketAddr};
use bluer::{Address, AddressType, Error, Result};
use hex;
use log::{debug, error, info};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::{Duration, Instant, sleep};
const PSM_ATT: u16 = 0x001F;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const POLL_INTERVAL: Duration = Duration::from_millis(200);
const OPCODE_READ_REQUEST: u8 = 0x0A;
const OPCODE_WRITE_REQUEST: u8 = 0x12;
const OPCODE_HANDLE_VALUE_NTF: u8 = 0x1B;
const OPCODE_WRITE_RESPONSE: u8 = 0x13;
const RESPONSE_TIMEOUT: u64 = 5000;
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ATTHandles {
AirPodsTransparency = 0x18,
AirPodsLoudSoundReduction = 0x1B,
AirPodsHearingAid = 0x2A,
NothingEverything = 0x8002,
NothingEverythingRead = 0x8005, // for some reason, and not the same as the write handle
}
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ATTCCCDHandles {
Transparency = ATTHandles::AirPodsTransparency as u16 + 1,
LoudSoundReduction = ATTHandles::AirPodsLoudSoundReduction as u16 + 1,
HearingAid = ATTHandles::AirPodsHearingAid as u16 + 1,
}
impl From<ATTHandles> for ATTCCCDHandles {
fn from(handle: ATTHandles) -> Self {
match handle {
ATTHandles::AirPodsTransparency => ATTCCCDHandles::Transparency,
ATTHandles::AirPodsLoudSoundReduction => ATTCCCDHandles::LoudSoundReduction,
ATTHandles::AirPodsHearingAid => ATTCCCDHandles::HearingAid,
ATTHandles::NothingEverything => panic!("No CCCD for NothingEverything handle"), // we don't request it
ATTHandles::NothingEverythingRead => panic!("No CCD for NothingEverythingRead handle"), // it sends notifications without CCCD
}
}
}
struct ATTManagerState {
sender: Option<mpsc::Sender<Vec<u8>>>,
listeners: HashMap<u16, Vec<mpsc::UnboundedSender<Vec<u8>>>>,
}
impl ATTManagerState {
fn new() -> Self {
ATTManagerState {
sender: None,
listeners: HashMap::new(),
}
}
}
#[derive(Clone)]
pub struct ATTManager {
state: Arc<Mutex<ATTManagerState>>,
response_rx: Arc<Mutex<mpsc::UnboundedReceiver<Vec<u8>>>>,
response_tx: mpsc::UnboundedSender<Vec<u8>>,
tasks: Arc<Mutex<JoinSet<()>>>,
}
impl ATTManager {
pub fn new() -> Self {
let (tx, rx) = mpsc::unbounded_channel();
ATTManager {
state: Arc::new(Mutex::new(ATTManagerState::new())),
response_rx: Arc::new(Mutex::new(rx)),
response_tx: tx,
tasks: Arc::new(Mutex::new(JoinSet::new())),
}
}
pub async fn connect(&mut self, addr: Address) -> Result<()> {
info!(
"ATTManager connecting to {} on PSM {:#06X}...",
addr, PSM_ATT
);
let target_sa = SocketAddr::new(addr, AddressType::BrEdr, PSM_ATT);
let socket = Socket::new_seq_packet()?;
let seq_packet_result =
tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await;
let seq_packet = match seq_packet_result {
Ok(Ok(s)) => Arc::new(s),
Ok(Err(e)) => {
error!("L2CAP connect failed: {}", e);
return Err(e.into());
}
Err(_) => {
error!("L2CAP connect timed out");
return Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Connection timeout",
)));
}
};
// Wait for connection to be fully established
let start = Instant::now();
loop {
match seq_packet.peer_addr() {
Ok(peer) if peer.cid != 0 => break,
Ok(_) => {}
Err(e) => {
if e.raw_os_error() == Some(107) {
// ENOTCONN
error!("Peer has disconnected during connection setup.");
return Err(e.into());
}
error!("Error getting peer address: {}", e);
}
}
if start.elapsed() >= CONNECT_TIMEOUT {
error!("Timed out waiting for L2CAP connection to be fully established.");
return Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Connection timeout",
)));
}
sleep(POLL_INTERVAL).await;
}
info!("L2CAP connection established with {}", addr);
let (tx, rx) = mpsc::channel(128);
let state = ATTManagerState::new();
{
let mut s = self.state.lock().await;
*s = state;
s.sender = Some(tx);
}
let manager_clone = self.clone();
let mut tasks = self.tasks.lock().await;
tasks.spawn(recv_thread(manager_clone, seq_packet.clone()));
tasks.spawn(send_thread(rx, seq_packet));
Ok(())
}
pub async fn register_listener(&self, handle: ATTHandles, tx: mpsc::UnboundedSender<Vec<u8>>) {
let mut state = self.state.lock().await;
state.listeners.entry(handle as u16).or_default().push(tx);
}
pub async fn enable_notifications(&self, handle: ATTHandles) -> Result<()> {
self.write_cccd(handle.into(), &[0x01, 0x00]).await
}
pub async fn read(&self, handle: ATTHandles) -> Result<Vec<u8>> {
let lsb = (handle as u16 & 0xFF) as u8;
let msb = ((handle as u16 >> 8) & 0xFF) as u8;
let pdu = vec![OPCODE_READ_REQUEST, lsb, msb];
self.send_packet(&pdu).await?;
self.read_response().await
}
pub async fn write(&self, handle: ATTHandles, value: &[u8]) -> Result<()> {
let lsb = (handle as u16 & 0xFF) as u8;
let msb = ((handle as u16 >> 8) & 0xFF) as u8;
let mut pdu = vec![OPCODE_WRITE_REQUEST, lsb, msb];
pdu.extend_from_slice(value);
self.send_packet(&pdu).await?;
self.read_response().await?;
Ok(())
}
async fn write_cccd(&self, handle: ATTCCCDHandles, value: &[u8]) -> Result<()> {
let lsb = (handle as u16 & 0xFF) as u8;
let msb = ((handle as u16 >> 8) & 0xFF) as u8;
let mut pdu = vec![OPCODE_WRITE_REQUEST, lsb, msb];
pdu.extend_from_slice(value);
self.send_packet(&pdu).await?;
self.read_response().await?;
Ok(())
}
async fn send_packet(&self, data: &[u8]) -> Result<()> {
let state = self.state.lock().await;
if let Some(sender) = &state.sender {
sender.send(data.to_vec()).await.map_err(|e| {
error!("Failed to send packet to channel: {}", e);
Error::from(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"L2CAP send channel closed",
))
})
} else {
error!("Cannot send packet, sender is not available.");
Err(Error::from(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"L2CAP stream not connected",
)))
}
}
async fn read_response(&self) -> Result<Vec<u8>> {
debug!("Waiting for response...");
let mut rx = self.response_rx.lock().await;
match tokio::time::timeout(Duration::from_millis(RESPONSE_TIMEOUT), rx.recv()).await {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(Error::from(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Response channel closed",
))),
Err(_) => Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Response timeout",
))),
}
}
}
async fn recv_thread(manager: ATTManager, sp: Arc<SeqPacket>) {
let mut buf = vec![0u8; 1024];
loop {
match sp.recv(&mut buf).await {
Ok(0) => {
info!("Remote closed the connection.");
break;
}
Ok(n) => {
let data = &buf[..n];
debug!("Received {} bytes: {}", n, hex::encode(data));
if data.is_empty() {
continue;
}
if data[0] == OPCODE_HANDLE_VALUE_NTF {
// Notification
let handle = (data[1] as u16) | ((data[2] as u16) << 8);
let value = data[3..].to_vec();
let state = manager.state.lock().await;
if let Some(listeners) = state.listeners.get(&handle) {
for listener in listeners {
let _ = listener.send(value.clone());
}
}
} else if data[0] == OPCODE_WRITE_RESPONSE {
let _ = manager.response_tx.send(vec![]);
} else {
// Response
let _ = manager.response_tx.send(data[1..].to_vec());
}
}
Err(e) => {
error!("read error: {}", e);
break;
}
}
}
let mut state = manager.state.lock().await;
state.sender = None;
}
async fn send_thread(mut rx: mpsc::Receiver<Vec<u8>>, sp: Arc<SeqPacket>) {
while let Some(data) = rx.recv().await {
if let Err(e) = sp.send(&data).await {
error!("Failed to send data: {}", e);
break;
}
debug!("Sent {} bytes: {}", data.len(), hex::encode(&data));
}
info!("send thread finished.");
}

View File

@@ -0,0 +1,49 @@
use bluer::Adapter;
use log::debug;
use std::io::Error;
pub(crate) async fn find_connected_airpods(adapter: &Adapter) -> bluer::Result<bluer::Device> {
let target_uuid = uuid::Uuid::parse_str("74ec2172-0bad-4d01-8f77-997b2be0722a").unwrap();
let addrs = adapter.device_addresses().await?;
for addr in addrs {
let device = adapter.device(addr)?;
if device.is_connected().await.unwrap_or(false)
&& let Ok(uuids) = device.uuids().await
&& let Some(uuids) = uuids
&& uuids.iter().any(|u| *u == target_uuid)
{
return Ok(device);
}
}
Err(bluer::Error::from(Error::new(
std::io::ErrorKind::NotFound,
"No connected AirPods found",
)))
}
pub async fn find_other_managed_devices(
adapter: &Adapter,
managed_macs: Vec<String>,
) -> bluer::Result<Vec<bluer::Device>> {
let addrs = adapter.device_addresses().await?;
let mut devices = Vec::new();
for addr in addrs {
let device = adapter.device(addr)?;
let device_mac = device.address().to_string();
let connected = device.is_connected().await.unwrap_or(false);
debug!("Checking device: {}, connected: {}", device_mac, connected);
if connected && managed_macs.contains(&device_mac) {
debug!("Found managed device: {}", device_mac);
devices.push(device);
}
}
if !devices.is_empty() {
return Ok(devices);
}
debug!("No other managed devices found");
Err(bluer::Error::from(Error::new(
std::io::ErrorKind::NotFound,
"No other managed devices found",
)))
}

View File

@@ -0,0 +1,379 @@
use crate::bluetooth::aacp::BatteryStatus;
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType};
use crate::ui::tray::MyTray;
use crate::utils::{ah, get_devices_path, get_preferences_path};
use aes::Aes128;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, KeyInit};
use bluer::monitor::{Monitor, MonitorEvent, Pattern};
use bluer::{Address, Session};
use futures::StreamExt;
use hex;
use log::{debug, info};
use serde_json;
use std::collections::{HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use tokio::sync::Mutex;
fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(&GenericArray::from(*key));
let mut block = GenericArray::from(*data);
cipher.decrypt_block(&mut block);
block.into()
}
fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool {
let rpa: Vec<u8> = addr
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if rpa.len() != 6 {
return false;
}
let prand_slice = &rpa[3..6];
let prand: [u8; 3] = prand_slice.try_into().unwrap();
let hash_slice = &rpa[0..3];
let hash: [u8; 3] = hash_slice.try_into().unwrap();
let computed_hash = ah(irk, &prand);
debug!(
"Verifying RPA: addr={}, hash={:?}, computed_hash={:?}",
addr, hash, computed_hash
);
hash == computed_hash
}
pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> bluer::Result<()> {
let session = Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
let all_devices: HashMap<String, DeviceData> = std::fs::read_to_string(get_devices_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut verified_macs: HashMap<Address, String> = HashMap::new();
let mut failed_macs: HashSet<Address> = HashSet::new();
let connecting_macs = Arc::new(Mutex::new(HashSet::<Address>::new()));
let pattern = Pattern {
data_type: 0xFF, // Manufacturer specific data
start_position: 0,
content: vec![0x4C, 0x00], // Apple manufacturer ID (76) in LE
};
let mm = adapter.monitor().await?;
let mut monitor_handle = mm
.register(Monitor {
monitor_type: bluer::monitor::Type::OrPatterns,
rssi_low_threshold: None,
rssi_high_threshold: None,
rssi_low_timeout: None,
rssi_high_timeout: None,
rssi_sampling_period: None,
patterns: Some(vec![pattern]),
..Default::default()
})
.await?;
debug!("Started LE monitor");
while let Some(mevt) = monitor_handle.next().await {
if let MonitorEvent::DeviceFound(devid) = mevt {
let adapter_monitor_clone = adapter.clone();
let dev = adapter_monitor_clone.device(devid.device)?;
let addr = dev.address();
let addr_str = addr.to_string();
let matched_airpods_mac: Option<String>;
let mut matched_enc_key: Option<[u8; 16]> = None;
if let Some(airpods_mac) = verified_macs.get(&addr) {
matched_airpods_mac = Some(airpods_mac.clone());
} else if failed_macs.contains(&addr) {
continue;
} else {
debug!("Checking RPA for device: {}", addr_str);
let mut found_mac = None;
for (airpods_mac, device_data) in &all_devices {
if device_data.type_ == DeviceType::AirPods
&& let Some(DeviceInformation::AirPods(info)) = &device_data.information
&& let Ok(irk_bytes) = hex::decode(&info.le_keys.irk)
&& irk_bytes.len() == 16
{
let irk: [u8; 16] = irk_bytes.as_slice().try_into().unwrap();
debug!(
"Verifying RPA {} for airpods MAC {} with IRK {}",
addr_str, airpods_mac, info.le_keys.irk
);
if verify_rpa(&addr_str, &irk) {
info!(
"Matched our device ({}) with the irk for {}",
addr, airpods_mac
);
verified_macs.insert(addr, airpods_mac.clone());
found_mac = Some(airpods_mac.clone());
break;
}
}
}
if let Some(mac) = found_mac {
matched_airpods_mac = Some(mac);
} else {
failed_macs.insert(addr);
debug!("Device {} did not match any of our irks", addr);
continue;
}
}
if let Some(ref mac) = matched_airpods_mac
&& let Some(device_data) = all_devices.get(mac)
&& let Some(DeviceInformation::AirPods(info)) = &device_data.information
&& let Ok(enc_key_bytes) = hex::decode(&info.le_keys.enc_key)
&& enc_key_bytes.len() == 16
{
matched_enc_key = Some(enc_key_bytes.as_slice().try_into().unwrap());
}
if matched_airpods_mac.is_some() {
let mut events = dev.events().await?;
let tray_handle_clone = tray_handle.clone();
let connecting_macs_clone = Arc::clone(&connecting_macs);
tokio::spawn(async move {
while let Some(ev) = events.next().await {
match ev {
bluer::DeviceEvent::PropertyChanged(prop) => {
if let bluer::DeviceProperty::ManufacturerData(data) = prop {
if let Some(enc_key) = &matched_enc_key
&& let Some(apple_data) = data.get(&76)
&& apple_data.len() > 20
{
let last_16: [u8; 16] =
apple_data[apple_data.len() - 16..].try_into().unwrap();
let decrypted = decrypt(enc_key, &last_16);
debug!(
"Decrypted data from airpods_mac {}: {}",
matched_airpods_mac
.as_ref()
.unwrap_or(&"unknown".to_string()),
hex::encode(decrypted)
);
let connection_state = apple_data[10] as usize;
debug!("Connection state: {}", connection_state);
if connection_state == 0x00 {
let pref_path = get_preferences_path();
let preferences: HashMap<
String,
HashMap<String, bool>,
> = std::fs::read_to_string(&pref_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let auto_connect = preferences
.get(matched_airpods_mac.as_ref().unwrap())
.and_then(|prefs| prefs.get("autoConnect"))
.copied()
.unwrap_or(true);
debug!(
"Auto-connect preference for {}: {}",
matched_airpods_mac.as_ref().unwrap(),
auto_connect
);
if auto_connect {
let real_address =
Address::from_str(&addr_str).unwrap();
let mut cm = connecting_macs_clone.lock().await;
if cm.contains(&real_address) {
info!(
"Already connecting to {}, skipping duplicate attempt.",
matched_airpods_mac.as_ref().unwrap()
);
return;
}
cm.insert(real_address);
// let adapter_clone = adapter_monitor_clone.clone();
// let real_device = adapter_clone.device(real_address).unwrap();
info!(
"AirPods are disconnected, attempting to connect to {}",
matched_airpods_mac.as_ref().unwrap()
);
// if let Err(e) = real_device.connect().await {
// info!("Failed to connect to AirPods {}: {}", matched_airpods_mac.as_ref().unwrap(), e);
// } else {
// info!("Successfully connected to AirPods {}", matched_airpods_mac.as_ref().unwrap());
// }
// call bluetoothctl connect <mac> for now, I don't know why bluer connect isn't working
let output =
tokio::process::Command::new("bluetoothctl")
.arg("connect")
.arg(matched_airpods_mac.as_ref().unwrap())
.output()
.await;
match output {
Ok(output) => {
if output.status.success() {
info!(
"Successfully connected to AirPods {}",
matched_airpods_mac
.as_ref()
.unwrap()
);
cm.remove(&real_address);
} else {
let stderr = String::from_utf8_lossy(
&output.stderr,
);
info!(
"Failed to connect to AirPods {}: {}",
matched_airpods_mac
.as_ref()
.unwrap(),
stderr
);
}
}
Err(e) => {
info!(
"Failed to execute bluetoothctl to connect to AirPods {}: {}",
matched_airpods_mac.as_ref().unwrap(),
e
);
}
}
info!(
"Auto-connect is disabled for {}, not attempting to connect.",
matched_airpods_mac.as_ref().unwrap()
);
}
}
let status = apple_data[5] as usize;
let primary_left = (status >> 5) & 0x01 == 1;
let this_in_case = (status >> 6) & 0x01 == 1;
let xor_factor = primary_left ^ this_in_case;
let is_left_in_ear = if xor_factor {
(status & 0x02) != 0
} else {
(status & 0x08) != 0
};
let is_right_in_ear = if xor_factor {
(status & 0x08) != 0
} else {
(status & 0x02) != 0
};
let is_flipped = !primary_left;
let left_byte_index = if is_flipped { 2 } else { 1 };
let right_byte_index = if is_flipped { 1 } else { 2 };
let left_byte = decrypted[left_byte_index] as i32;
let right_byte = decrypted[right_byte_index] as i32;
let case_byte = decrypted[3] as i32;
let (left_battery, left_charging) = if left_byte == 0xff {
(0, false)
} else {
(left_byte & 0x7F, (left_byte & 0x80) != 0)
};
let (right_battery, right_charging) = if right_byte == 0xff
{
(0, false)
} else {
(right_byte & 0x7F, (right_byte & 0x80) != 0)
};
let (case_battery, case_charging) = if case_byte == 0xff {
(0, false)
} else {
(case_byte & 0x7F, (case_byte & 0x80) != 0)
};
if let Some(handle) = &tray_handle_clone {
handle
.update(|tray: &mut MyTray| {
tray.battery_l = if left_byte == 0xff {
None
} else {
Some(left_battery as u8)
};
tray.battery_l_status = if left_byte == 0xff {
Some(BatteryStatus::Disconnected)
} else if left_charging {
Some(BatteryStatus::Charging)
} else {
Some(BatteryStatus::NotCharging)
};
tray.battery_r = if right_byte == 0xff {
None
} else {
Some(right_battery as u8)
};
tray.battery_r_status = if right_byte == 0xff {
Some(BatteryStatus::Disconnected)
} else if right_charging {
Some(BatteryStatus::Charging)
} else {
Some(BatteryStatus::NotCharging)
};
tray.battery_c = if case_byte == 0xff {
None
} else {
Some(case_battery as u8)
};
tray.battery_c_status = if case_byte == 0xff {
Some(BatteryStatus::Disconnected)
} else if case_charging {
Some(BatteryStatus::Charging)
} else {
Some(BatteryStatus::NotCharging)
};
})
.await;
}
debug!(
"Battery status: Left: {}, Right: {}, Case: {}, InEar: L:{} R:{}",
if left_byte == 0xff {
"disconnected".to_string()
} else {
format!(
"{}% (charging: {})",
left_battery, left_charging
)
},
if right_byte == 0xff {
"disconnected".to_string()
} else {
format!(
"{}% (charging: {})",
right_battery, right_charging
)
},
if case_byte == 0xff {
"disconnected".to_string()
} else {
format!(
"{}% (charging: {})",
case_battery, case_charging
)
},
is_left_in_ear,
is_right_in_ear
);
}
}
}
}
}
});
}
}
}
Ok(())
}

View File

@@ -0,0 +1,48 @@
use crate::bluetooth::aacp::AACPManager;
use crate::bluetooth::att::ATTManager;
use std::sync::Arc;
pub struct DeviceManagers {
att: Option<Arc<ATTManager>>,
aacp: Option<Arc<AACPManager>>,
}
impl DeviceManagers {
pub fn with_aacp(aacp: AACPManager) -> Self {
Self {
att: None,
aacp: Some(Arc::new(aacp)),
}
}
pub fn with_att(att: ATTManager) -> Self {
Self {
att: Some(Arc::new(att)),
aacp: None,
}
}
// keeping the att for airpods optional as it requires changes in system bluez config
pub fn with_both(aacp: AACPManager, att: ATTManager) -> Self {
Self {
att: Some(Arc::new(att)),
aacp: Some(Arc::new(aacp)),
}
}
pub fn set_aacp(&mut self, manager: AACPManager) {
self.aacp = Some(Arc::new(manager));
}
pub fn set_att(&mut self, manager: ATTManager) {
self.att = Some(Arc::new(manager));
}
pub fn get_aacp(&self) -> Option<Arc<AACPManager>> {
self.aacp.clone()
}
pub fn get_att(&self) -> Option<Arc<ATTManager>> {
self.att.clone()
}
}

View File

@@ -0,0 +1,5 @@
pub mod aacp;
pub mod att;
pub(crate) mod discovery;
pub mod le;
pub mod managers;

View File

@@ -0,0 +1,354 @@
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::bluetooth::aacp::{AACPEvent, AACPManager, AirPodsLEKeys, ProximityKeyType};
use crate::media_controller::MediaController;
use crate::ui::messages::BluetoothUIMessage;
use crate::ui::tray::MyTray;
use bluer::Address;
use ksni::Handle;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{Duration, sleep};
pub struct AirPodsDevice {
pub mac_address: Address,
pub aacp_manager: AACPManager,
// pub att_manager: ATTManager,
pub media_controller: Arc<Mutex<MediaController>>,
// pub command_tx: Option<tokio::sync::mpsc::UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
}
impl AirPodsDevice {
pub async fn new(
mac_address: Address,
tray_handle: Option<Handle<MyTray>>,
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
) -> Self {
info!("Creating new AirPodsDevice for {}", mac_address);
let mut aacp_manager = AACPManager::new();
aacp_manager.connect(mac_address).await;
// let mut att_manager = ATTManager::new();
// att_manager.connect(mac_address).await.expect("Failed to connect ATT");
if let Some(handle) = &tray_handle {
handle
.update(|tray: &mut MyTray| tray.connected = true)
.await;
}
info!("Sending handshake");
if let Err(e) = aacp_manager.send_handshake().await {
error!("Failed to send handshake to AirPods device: {}", e);
}
sleep(Duration::from_millis(100)).await;
info!("Setting feature flags");
if let Err(e) = aacp_manager.send_set_feature_flags_packet().await {
error!("Failed to set feature flags: {}", e);
}
sleep(Duration::from_millis(100)).await;
info!("Requesting notifications");
if let Err(e) = aacp_manager.send_notification_request().await {
error!("Failed to request notifications: {}", e);
}
info!("sending some packet");
if let Err(e) = aacp_manager.send_some_packet().await {
error!("Failed to send some packet: {}", e);
}
info!("Requesting Proximity Keys: IRK and ENC_KEY");
if let Err(e) = aacp_manager
.send_proximity_keys_request(vec![ProximityKeyType::Irk, ProximityKeyType::EncKey])
.await
{
error!("Failed to request proximity keys: {}", e);
}
let session = bluer::Session::new()
.await
.expect("Failed to get bluer session");
let adapter = session
.default_adapter()
.await
.expect("Failed to get default adapter");
let local_mac = adapter
.address()
.await
.expect("Failed to get adapter address")
.to_string();
let media_controller = Arc::new(Mutex::new(MediaController::new(
mac_address.to_string(),
local_mac.clone(),
)));
let mc_clone = media_controller.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager.set_event_channel(tx).await;
if let Some(handle) = &tray_handle {
handle
.update(|tray: &mut MyTray| tray.command_tx = Some(command_tx.clone()))
.await;
}
let aacp_manager_clone = aacp_manager.clone();
tokio::spawn(async move {
while let Some((id, value)) = command_rx.recv().await {
if let Err(e) = aacp_manager_clone.send_control_command(id, &value).await {
log::error!("Failed to send control command: {}", e);
}
}
});
let mc_listener = media_controller.lock().await;
let aacp_manager_clone_listener = aacp_manager.clone();
mc_listener
.start_playback_listener(aacp_manager_clone_listener, command_tx.clone())
.await;
drop(mc_listener);
let (listening_mode_tx, mut listening_mode_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager
.subscribe_to_control_command(
ControlCommandIdentifiers::ListeningMode,
listening_mode_tx,
)
.await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = listening_mode_rx.recv().await {
if let Some(handle) = &tray_handle_clone {
handle
.update(|tray: &mut MyTray| {
tray.listening_mode = Some(value[0]);
})
.await;
}
}
});
let (allow_off_tx, mut allow_off_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager
.subscribe_to_control_command(ControlCommandIdentifiers::AllowOffOption, allow_off_tx)
.await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = allow_off_rx.recv().await {
if let Some(handle) = &tray_handle_clone {
handle
.update(|tray: &mut MyTray| {
tray.allow_off_option = Some(value[0]);
})
.await;
}
}
});
let (conversation_detect_tx, mut conversation_detect_rx) =
tokio::sync::mpsc::unbounded_channel();
aacp_manager
.subscribe_to_control_command(
ControlCommandIdentifiers::ConversationDetectConfig,
conversation_detect_tx,
)
.await;
let tray_handle_clone = tray_handle.clone();
tokio::spawn(async move {
while let Some(value) = conversation_detect_rx.recv().await {
if let Some(handle) = &tray_handle_clone {
handle
.update(|tray: &mut MyTray| {
tray.conversation_detect_enabled = Some(value[0] == 0x01);
})
.await;
}
}
});
let (owns_connection_tx, mut owns_connection_rx) = tokio::sync::mpsc::unbounded_channel();
aacp_manager
.subscribe_to_control_command(
ControlCommandIdentifiers::OwnsConnection,
owns_connection_tx,
)
.await;
let mc_clone_owns = media_controller.clone();
tokio::spawn(async move {
while let Some(value) = owns_connection_rx.recv().await {
let owns = value.first().copied().unwrap_or(0) != 0;
if !owns {
info!("Lost ownership, pausing media and disconnecting audio");
let controller = mc_clone_owns.lock().await;
controller.pause_all_media().await;
controller.deactivate_a2dp_profile().await;
}
}
});
let aacp_manager_clone_events = aacp_manager.clone();
let local_mac_events = local_mac.clone();
let ui_tx_clone = ui_tx.clone();
let command_tx_clone = command_tx.clone();
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let event_clone = event.clone();
match event {
AACPEvent::EarDetection(old_status, new_status) => {
debug!(
"Received EarDetection event: old_status={:?}, new_status={:?}",
old_status, new_status
);
let controller = mc_clone.lock().await;
debug!(
"Calling handle_ear_detection with old_status: {:?}, new_status: {:?}",
old_status, new_status
);
controller
.handle_ear_detection(old_status, new_status)
.await;
}
AACPEvent::BatteryInfo(battery_info) => {
debug!("Received BatteryInfo event: {:?}", battery_info);
if let Some(handle) = &tray_handle {
handle
.update(|tray: &mut MyTray| {
for b in &battery_info {
match b.component as u8 {
0x01 => {
tray.battery_headphone = Some(b.level);
tray.battery_headphone_status = Some(b.status);
}
0x02 => {
tray.battery_r = Some(b.level);
tray.battery_r_status = Some(b.status);
}
0x04 => {
tray.battery_l = Some(b.level);
tray.battery_l_status = Some(b.status);
}
0x08 => {
tray.battery_c = Some(b.level);
tray.battery_c_status = Some(b.status);
}
_ => {}
}
}
})
.await;
}
debug!("Updated tray with new battery info");
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac_address.to_string(),
event_clone,
));
debug!("Sent BatteryInfo event to UI");
}
AACPEvent::ControlCommand(status) => {
debug!("Received ControlCommand event: {:?}", status);
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac_address.to_string(),
event_clone,
));
debug!("Sent ControlCommand event to UI");
}
AACPEvent::ConversationalAwareness(status) => {
debug!("Received ConversationalAwareness event: {}", status);
let controller = mc_clone.lock().await;
controller.handle_conversational_awareness(status).await;
}
AACPEvent::ConnectedDevices(old_devices, new_devices) => {
let local_mac = local_mac_events.clone();
let new_devices_filtered = new_devices.iter().filter(|new_device| {
let not_in_old = old_devices
.iter()
.all(|old_device| old_device.mac != new_device.mac);
let not_local = new_device.mac != local_mac;
not_in_old && not_local
});
for device in new_devices_filtered {
info!(
"New connected device: {}, info1: {}, info2: {}",
device.mac, device.info1, device.info2
);
info!(
"Sending new Tipi packet for device {}, and sending media info to the device",
device.mac
);
let aacp_manager_clone = aacp_manager_clone_events.clone();
let local_mac_clone = local_mac.clone();
let device_mac_clone = device.mac.clone();
tokio::spawn(async move {
if let Err(e) = aacp_manager_clone
.send_media_information_new_device(
&local_mac_clone,
&device_mac_clone,
)
.await
{
error!("Failed to send media info new device: {}", e);
}
if let Err(e) = aacp_manager_clone
.send_add_tipi_device(&local_mac_clone, &device_mac_clone)
.await
{
error!("Failed to send add tipi device: {}", e);
}
});
}
}
AACPEvent::OwnershipToFalseRequest => {
info!(
"Received ownership to false request. Setting ownership to false and pausing media."
);
let _ = command_tx_clone
.send((ControlCommandIdentifiers::OwnsConnection, vec![0x00]));
let controller = mc_clone.lock().await;
controller.pause_all_media().await;
controller.deactivate_a2dp_profile().await;
}
_ => {
debug!("Received unhandled AACP event: {:?}", event);
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac_address.to_string(),
event_clone,
));
debug!("Sent unhandled AACP event to UI");
}
}
}
});
AirPodsDevice {
mac_address,
aacp_manager,
// att_manager,
media_controller,
// command_tx: Some(command_tx.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AirPodsInformation {
pub name: String,
pub model_number: String,
pub manufacturer: String,
pub serial_number: String,
pub version1: String,
pub version2: String,
pub hardware_revision: String,
pub updater_identifier: String,
pub left_serial_number: String,
pub right_serial_number: String,
pub version3: String,
pub le_keys: AirPodsLEKeys,
}

View File

@@ -0,0 +1,152 @@
use crate::bluetooth::aacp::BatteryInfo;
use crate::devices::airpods::AirPodsInformation;
use crate::devices::nothing::NothingInformation;
use iced::widget::combo_box;
use serde::{Deserialize, Serialize};
use std::fmt::Display;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DeviceType {
AirPods,
Nothing,
}
impl Display for DeviceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceType::AirPods => write!(f, "AirPods"),
DeviceType::Nothing => write!(f, "Nothing"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum DeviceInformation {
AirPods(AirPodsInformation),
Nothing(NothingInformation),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceData {
pub name: String,
pub type_: DeviceType,
pub information: Option<DeviceInformation>,
}
#[derive(Clone, Debug)]
pub enum DeviceState {
AirPods(AirPodsState),
Nothing(NothingState),
}
impl Display for DeviceState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DeviceState::AirPods(_) => write!(f, "AirPods State"),
DeviceState::Nothing(_) => write!(f, "Nothing State"),
}
}
}
#[derive(Clone, Debug)]
pub struct AirPodsState {
pub device_name: String,
pub noise_control_mode: AirPodsNoiseControlMode,
pub noise_control_state: combo_box::State<AirPodsNoiseControlMode>,
pub conversation_awareness_enabled: bool,
pub personalized_volume_enabled: bool,
pub allow_off_mode: bool,
pub battery: Vec<BatteryInfo>,
}
#[derive(Clone, Debug)]
pub enum AirPodsNoiseControlMode {
Off,
NoiseCancellation,
Transparency,
Adaptive,
}
impl Display for AirPodsNoiseControlMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AirPodsNoiseControlMode::Off => write!(f, "Off"),
AirPodsNoiseControlMode::NoiseCancellation => write!(f, "Noise Cancellation"),
AirPodsNoiseControlMode::Transparency => write!(f, "Transparency"),
AirPodsNoiseControlMode::Adaptive => write!(f, "Adaptive"),
}
}
}
impl AirPodsNoiseControlMode {
pub fn from_byte(value: &u8) -> Self {
match value {
0x01 => AirPodsNoiseControlMode::Off,
0x02 => AirPodsNoiseControlMode::NoiseCancellation,
0x03 => AirPodsNoiseControlMode::Transparency,
0x04 => AirPodsNoiseControlMode::Adaptive,
_ => AirPodsNoiseControlMode::Off,
}
}
pub fn to_byte(&self) -> u8 {
match self {
AirPodsNoiseControlMode::Off => 0x01,
AirPodsNoiseControlMode::NoiseCancellation => 0x02,
AirPodsNoiseControlMode::Transparency => 0x03,
AirPodsNoiseControlMode::Adaptive => 0x04,
}
}
}
#[derive(Clone, Debug)]
pub struct NothingState {
pub anc_mode: NothingAncMode,
pub anc_mode_state: combo_box::State<NothingAncMode>,
}
#[derive(Clone, Debug)]
pub enum NothingAncMode {
Off,
LowNoiseCancellation,
MidNoiseCancellation,
HighNoiseCancellation,
AdaptiveNoiseCancellation,
Transparency,
}
impl Display for NothingAncMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NothingAncMode::Off => write!(f, "Off"),
NothingAncMode::LowNoiseCancellation => write!(f, "Low Noise Cancellation"),
NothingAncMode::MidNoiseCancellation => write!(f, "Mid Noise Cancellation"),
NothingAncMode::HighNoiseCancellation => write!(f, "High Noise Cancellation"),
NothingAncMode::AdaptiveNoiseCancellation => write!(f, "Adaptive Noise Cancellation"),
NothingAncMode::Transparency => write!(f, "Transparency"),
}
}
}
impl NothingAncMode {
pub fn from_byte(value: u8) -> Self {
match value {
0x03 => NothingAncMode::LowNoiseCancellation,
0x02 => NothingAncMode::MidNoiseCancellation,
0x01 => NothingAncMode::HighNoiseCancellation,
0x04 => NothingAncMode::AdaptiveNoiseCancellation,
0x07 => NothingAncMode::Transparency,
0x05 => NothingAncMode::Off,
_ => NothingAncMode::Off,
}
}
pub fn to_byte(&self) -> u8 {
match self {
NothingAncMode::LowNoiseCancellation => 0x03,
NothingAncMode::MidNoiseCancellation => 0x02,
NothingAncMode::HighNoiseCancellation => 0x01,
NothingAncMode::AdaptiveNoiseCancellation => 0x04,
NothingAncMode::Transparency => 0x07,
NothingAncMode::Off => 0x05,
}
}
}

View File

@@ -0,0 +1,3 @@
pub mod airpods;
pub mod enums;
pub(crate) mod nothing;

View File

@@ -0,0 +1,179 @@
use crate::bluetooth::att::{ATTHandles, ATTManager};
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType};
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_devices_path;
use bluer::Address;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NothingInformation {
pub serial_number: String,
pub firmware_version: String,
}
pub struct NothingDevice {
pub att_manager: ATTManager,
pub information: NothingInformation,
}
impl NothingDevice {
pub async fn new(
mac_address: Address,
ui_tx: mpsc::UnboundedSender<BluetoothUIMessage>,
) -> Self {
let mut att_manager = ATTManager::new();
att_manager
.connect(mac_address)
.await
.expect("Failed to connect");
let (tx, mut rx) = mpsc::unbounded_channel::<Vec<u8>>();
att_manager
.register_listener(ATTHandles::NothingEverythingRead, tx)
.await;
let devices: HashMap<String, DeviceData> = std::fs::read_to_string(get_devices_path())
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let device_key = mac_address.to_string();
let information = if let Some(device_data) = devices.get(&device_key) {
let info = device_data.information.clone();
if let Some(DeviceInformation::Nothing(ref nothing_info)) = info {
nothing_info.clone()
} else {
NothingInformation {
serial_number: String::new(),
firmware_version: String::new(),
}
}
} else {
NothingInformation {
serial_number: String::new(),
firmware_version: String::new(),
}
};
// Request version information
att_manager
.write(
ATTHandles::NothingEverything,
&[
0x55, 0x20, 0x01, 0x42, 0xC0, 0x00, 0x00, 0x00, 0x00,
0x00, // something, idk
],
)
.await
.expect("Failed to write");
sleep(Duration::from_millis(100)).await;
// Request serial number
att_manager
.write(
ATTHandles::NothingEverything,
&[0x55, 0x20, 0x01, 0x06, 0xC0, 0x00, 0x00, 0x13, 0x00, 0x00],
)
.await
.expect("Failed to write");
// let ui_tx_clone = ui_tx.clone();
let information_l = information.clone();
tokio::spawn(async move {
while let Some(data) = rx.recv().await {
if data.starts_with(&[0x55, 0x20, 0x01, 0x42, 0x40]) {
let firmware_version = String::from_utf8_lossy(&data[8..]).to_string();
info!(
"Received firmware version from Nothing device {}: {}",
mac_address, firmware_version
);
let new_information = NothingInformation {
serial_number: information_l.serial_number.clone(),
firmware_version: firmware_version.clone(),
};
let mut new_devices = devices.clone();
new_devices.insert(
device_key.clone(),
DeviceData {
name: devices
.get(&device_key)
.map(|d| d.name.clone())
.unwrap_or("Nothing Device".to_string()),
type_: devices
.get(&device_key)
.map(|d| d.type_.clone())
.unwrap_or(DeviceType::Nothing),
information: Some(DeviceInformation::Nothing(new_information)),
},
);
let json = serde_json::to_string(&new_devices).unwrap();
std::fs::write(get_devices_path(), json).expect("Failed to write devices file");
} else if data.starts_with(&[0x55, 0x20, 0x01, 0x06, 0x40]) {
let serial_number_start_position = data
.iter()
.position(|&b| b == "S".as_bytes()[0])
.unwrap_or(8);
let serial_number_end = data
.iter()
.skip(serial_number_start_position)
.position(|&b| b == 0x0A)
.map(|pos| pos + serial_number_start_position)
.unwrap_or(data.len());
if data.get(serial_number_start_position + 1) == Some(&"H".as_bytes()[0]) {
let serial_number = String::from_utf8_lossy(
&data[serial_number_start_position..serial_number_end],
)
.to_string();
info!(
"Received serial number from Nothing device {}: {}",
mac_address, serial_number
);
let new_information = NothingInformation {
serial_number: serial_number.clone(),
firmware_version: information_l.firmware_version.clone(),
};
let mut new_devices = devices.clone();
new_devices.insert(
device_key.clone(),
DeviceData {
name: devices
.get(&device_key)
.map(|d| d.name.clone())
.unwrap_or("Nothing Device".to_string()),
type_: devices
.get(&device_key)
.map(|d| d.type_.clone())
.unwrap_or(DeviceType::Nothing),
information: Some(DeviceInformation::Nothing(new_information)),
},
);
let json = serde_json::to_string(&new_devices).unwrap();
std::fs::write(get_devices_path(), json)
.expect("Failed to write devices file");
} else {
debug!(
"Serial number format unexpected from Nothing device {}: {:?}",
mac_address, data
);
}
}
debug!(
"Received data from (Nothing) device {}, data: {:?}",
mac_address, data
);
}
});
NothingDevice {
att_manager,
information,
}
}
}

322
linux-rust/src/main.rs Normal file
View File

@@ -0,0 +1,322 @@
mod bluetooth;
mod devices;
mod media_controller;
mod ui;
mod utils;
use crate::bluetooth::discovery::{find_connected_airpods, find_other_managed_devices};
use crate::bluetooth::le::start_le_monitor;
use crate::bluetooth::managers::DeviceManagers;
use crate::devices::enums::DeviceData;
use crate::ui::messages::BluetoothUIMessage;
use crate::ui::tray::MyTray;
use crate::utils::get_devices_path;
use bluer::{Address, InternalErrorKind};
use clap::Parser;
use dbus::arg::{RefArg, Variant};
use dbus::blocking::Connection;
use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
use dbus::message::MatchRule;
use devices::airpods::AirPodsDevice;
use ksni::TrayMethods;
use log::info;
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::mpsc::unbounded_channel;
#[derive(Parser)]
struct Args {
#[arg(long, short = 'd', help = "Enable debug logging")]
debug: bool,
#[arg(
long,
help = "Disable system tray, useful if your environment doesn't support AppIndicator or StatusNotifier"
)]
no_tray: bool,
#[arg(long, help = "Start the application minimized to tray")]
start_minimized: bool,
#[arg(
long,
help = "Enable Bluetooth LE debug logging. Only use when absolutely necessary; this produces a lot of logs."
)]
le_debug: bool,
#[arg(long, short = 'v', help = "Show application version and exit")]
version: bool,
}
fn main() -> iced::Result {
let args = Args::parse();
if args.version {
println!(
"You are running LibrePods version {}",
env!("CARGO_PKG_VERSION")
);
return Ok(());
}
let log_level = if args.debug { "debug" } else { "info" };
let wayland_display = env::var("WAYLAND_DISPLAY").is_ok();
if env::var("RUST_LOG").is_err() {
if wayland_display {
unsafe { env::set_var("WGPU_BACKEND", "gl") };
}
unsafe {
env::set_var(
"RUST_LOG",
log_level.to_owned()
+ &format!(
",winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods_rust::bluetooth::le={}",
if args.le_debug { "debug" } else { "warn" }
),
)
};
}
env_logger::init();
let (ui_tx, ui_rx) = unbounded_channel::<BluetoothUIMessage>();
let device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>> =
Arc::new(RwLock::new(HashMap::new()));
let device_managers_clone = device_managers.clone();
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers_clone))
.unwrap();
});
ui::window::start_ui(ui_rx, args.start_minimized, device_managers)
}
async fn async_main(
ui_tx: tokio::sync::mpsc::UnboundedSender<BluetoothUIMessage>,
device_managers: Arc<RwLock<HashMap<String, DeviceManagers>>>,
) -> bluer::Result<()> {
let args = Args::parse();
let mut managed_devices_mac: Vec<String> = Vec::new(); // includes ony non-AirPods. AirPods handled separately.
let devices_path = get_devices_path();
let devices_json = std::fs::read_to_string(&devices_path).unwrap_or_else(|e| {
log::error!("Failed to read devices file: {}", e);
"{}".to_string()
});
let devices_list: HashMap<String, DeviceData> = serde_json::from_str(&devices_json)
.unwrap_or_else(|e| {
log::error!("Deserialization failed: {}", e);
HashMap::new()
});
for (mac, device_data) in devices_list.iter() {
if device_data.type_ == devices::enums::DeviceType::Nothing {
managed_devices_mac.push(mac.clone());
}
}
let tray_handle = if args.no_tray {
None
} else {
let tray = MyTray {
conversation_detect_enabled: None,
battery_headphone: None,
battery_headphone_status: None,
battery_l: None,
battery_l_status: None,
battery_r: None,
battery_r_status: None,
battery_c: None,
battery_c_status: None,
connected: false,
listening_mode: None,
allow_off_option: None,
command_tx: None,
ui_tx: Some(ui_tx.clone()),
};
let handle = tray.spawn().await.unwrap();
Some(handle)
};
let session = bluer::Session::new().await?;
let adapter = session.default_adapter().await?;
adapter.set_powered(true).await?;
let le_tray_clone = tray_handle.clone();
tokio::spawn(async move {
info!("Starting LE monitor...");
if let Err(e) = start_le_monitor(le_tray_clone).await {
log::error!("LE monitor error: {}", e);
}
});
info!("Listening for new connections.");
info!("Checking for connected devices...");
match find_connected_airpods(&adapter).await {
Ok(device) => {
let name = device
.name()
.await?
.unwrap_or_else(|| "Unknown".to_string());
info!("Found connected AirPods: {}, initializing.", name);
let airpods_device =
AirPodsDevice::new(device.address(), tray_handle.clone(), ui_tx.clone()).await;
let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
managers
.entry(device.address().to_string())
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx
.send(BluetoothUIMessage::DeviceConnected(
device.address().to_string(),
))
.unwrap();
}
Err(_) => {
info!("No connected AirPods found.");
}
}
match find_other_managed_devices(&adapter, managed_devices_mac.clone()).await {
Ok(devices) => {
for device in devices {
let addr_str = device.address().to_string();
info!(
"Found connected managed device: {}, initializing.",
addr_str
);
let type_ = devices_list.get(&addr_str).unwrap().type_.clone();
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
let mut managers = device_managers.write().await;
if type_ == devices::enums::DeviceType::Nothing {
let dev = devices::nothing::NothingDevice::new(
device.address(),
ui_tx_clone.clone(),
)
.await;
let dev_managers = DeviceManagers::with_att(dev.att_manager.clone());
managers
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_att(dev.att_manager);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str))
.unwrap();
}
drop(managers)
});
}
}
Err(e) => {
log::debug!("type of error: {:?}", e.kind);
if e.kind
!= bluer::ErrorKind::Internal(InternalErrorKind::Io(std::io::ErrorKind::NotFound))
{
log::error!("Error finding other managed devices: {}", e);
} else {
info!("No other managed devices found.");
}
}
}
let conn = Connection::new_system()?;
let rule = MatchRule::new_signal("org.freedesktop.DBus.Properties", "PropertiesChanged");
conn.add_match(rule, move |_: (), conn, msg| {
let Some(path) = msg.path() else {
return true;
};
if !path.contains("/org/bluez/hci") || !path.contains("/dev_") {
return true;
}
// debug!("PropertiesChanged signal for path: {}", path);
let Ok((iface, changed, _)) =
msg.read3::<String, HashMap<String, Variant<Box<dyn RefArg>>>, Vec<String>>()
else {
return true;
};
if iface != "org.bluez.Device1" {
return true;
}
let Some(connected_var) = changed.get("Connected") else {
return true;
};
let Some(is_connected) = connected_var.0.as_ref().as_u64() else {
return true;
};
if is_connected == 0 {
return true;
}
let proxy = conn.with_proxy("org.bluez", path, std::time::Duration::from_millis(5000));
let Ok(uuids) = proxy.get::<Vec<String>>("org.bluez.Device1", "UUIDs") else {
return true;
};
let target_uuid = "74ec2172-0bad-4d01-8f77-997b2be0722a";
let Ok(addr_str) = proxy.get::<String>("org.bluez.Device1", "Address") else {
return true;
};
let Ok(addr) = addr_str.parse::<Address>() else {
return true;
};
if managed_devices_mac.contains(&addr_str) {
info!("Managed device connected: {}, initializing", addr_str);
let type_ = devices_list.get(&addr_str).unwrap().type_.clone();
if type_ == devices::enums::DeviceType::Nothing {
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
let mut managers = device_managers.write().await;
let dev = devices::nothing::NothingDevice::new(addr, ui_tx_clone.clone()).await;
let dev_managers = DeviceManagers::with_att(dev.att_manager.clone());
managers
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_att(dev.att_manager);
drop(managers);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
.unwrap();
});
}
return true;
}
if !uuids.iter().any(|u| u.to_lowercase() == target_uuid) {
return true;
}
let name = proxy
.get::<String>("org.bluez.Device1", "Name")
.unwrap_or_else(|_| "Unknown".to_string());
info!("AirPods connected: {}, initializing", name);
let handle_clone = tray_handle.clone();
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
let airpods_device = AirPodsDevice::new(addr, handle_clone, ui_tx_clone.clone()).await;
let mut managers = device_managers.write().await;
// let dev_managers = DeviceManagers::with_both(airpods_device.aacp_manager.clone(), airpods_device.att_manager.clone());
let dev_managers = DeviceManagers::with_aacp(airpods_device.aacp_manager.clone());
managers
.entry(addr_str.clone())
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx_clone
.send(BluetoothUIMessage::DeviceConnected(addr_str.clone()))
.unwrap();
});
true
})?;
info!("Listening for Bluetooth connections via D-Bus...");
loop {
conn.process(std::time::Duration::from_millis(1000))?;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,533 @@
use crate::bluetooth::aacp::{AACPManager, ControlCommandIdentifiers};
use iced::Alignment::End;
use iced::border::Radius;
use iced::overlay::menu;
use iced::widget::button::Style;
use iced::widget::rule::FillMode;
use iced::widget::{
Rule, Space, button, column, combo_box, container, row, rule, text, text_input, toggler,
};
use iced::{Background, Border, Center, Color, Length, Padding, Theme};
use log::error;
use std::collections::HashMap;
use std::sync::Arc;
use std::thread;
use tokio::runtime::Runtime;
// use crate::bluetooth::att::ATTManager;
use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation, DeviceState};
use crate::ui::window::Message;
pub fn airpods_view<'a>(
mac: &'a str,
devices_list: &HashMap<String, DeviceData>,
state: &'a AirPodsState,
aacp_manager: Arc<AACPManager>,
// att_manager: Arc<ATTManager>
) -> iced::widget::Container<'a, Message> {
let mac = mac.to_string();
// order: name, noise control, press and hold config, call controls (not sure if why it might be needed, adding it just in case), audio (personalized volume, conversational awareness, adaptive audio slider), connection settings, microphone, head gestures (not adding this), off listening mode, device information
let aacp_manager_for_rename = aacp_manager.clone();
let rename_input = container(
row![
Space::with_width(10),
text("Name").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text_input("", &state.device_name)
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.style(|theme: &Theme, _status| {
text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Default::default(),
icon: Default::default(),
placeholder: theme.palette().text.scale_alpha(0.7),
value: theme.palette().text,
selection: Default::default(),
}
})
.align_x(End)
.on_input({
let mac = mac.clone();
let state = state.clone();
move |new_name| {
let aacp_manager = aacp_manager_for_rename.clone();
run_async_in_thread({
let new_name = new_name.clone();
async move {
aacp_manager
.send_rename_packet(&new_name)
.await
.expect("Failed to send rename packet");
}
});
let mut state = state.clone();
state.device_name = new_name.clone();
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
}
})
]
.align_y(Center),
)
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
});
let listening_mode = container(
row![
text("Listening Mode").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
{
let state_clone = state.clone();
let mac = mac.clone();
// this combo_box doesn't go really well with the design, but I am not writing my own dropdown menu for this
combo_box(
&state.noise_control_state,
"Select Listening Mode",
Some(&state.noise_control_mode.clone()),
{
let aacp_manager = aacp_manager.clone();
move |selected_mode| {
let aacp_manager = aacp_manager.clone();
let selected_mode_c = selected_mode.clone();
run_async_in_thread(async move {
aacp_manager
.send_control_command(
ControlCommandIdentifiers::ListeningMode,
&[selected_mode_c.to_byte()],
)
.await
.expect("Failed to send Noise Control Mode command");
});
let mut state = state_clone.clone();
state.noise_control_mode = selected_mode.clone();
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
}
},
)
.width(Length::from(200))
.input_style(|theme: &Theme, _status| text_input::Style {
background: Background::Color(theme.palette().primary.scale_alpha(0.2)),
border: Border {
width: 1.0,
color: theme.palette().text.scale_alpha(0.3),
radius: Radius::from(4.0),
},
icon: Default::default(),
placeholder: theme.palette().text,
value: theme.palette().text,
selection: Default::default(),
})
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.menu_style(|theme: &Theme| menu::Style {
background: Background::Color(theme.palette().background),
border: Border {
width: 1.0,
color: theme.palette().text,
radius: Radius::from(4.0),
},
text_color: theme.palette().text,
selected_text_color: theme.palette().text,
selected_background: Background::Color(
theme.palette().primary.scale_alpha(0.3),
),
})
}
]
.align_y(Center),
)
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
});
let mac_audio = mac.clone();
let mac_information = mac.clone();
let audio_settings_col = column![
container(
text("Audio Settings").size(18).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().primary);
style
}
)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
}),
container(
column![
{
let aacp_manager_pv = aacp_manager.clone();
row![
column![
text("Personalized Volume").size(16),
text("Adjusts the volume in response to your environment.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill),
].width(Length::Fill),
toggler(state.personalized_volume_enabled)
.on_toggle(
{
let mac = mac_audio.clone();
let state = state.clone();
move |is_enabled| {
let aacp_manager = aacp_manager_pv.clone();
let mac = mac.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::AdaptiveVolumeConfig,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Personalized Volume command");
}
);
let mut state = state.clone();
state.personalized_volume_enabled = is_enabled;
Message::StateChanged(mac, DeviceState::AirPods(state))
}
}
)
.spacing(0)
.size(20)
]
.align_y(Center)
.spacing(8)
},
Rule::horizontal(8).style(
|theme: &Theme| {
rule::Style {
color: theme.palette().text,
width: 1,
radius: Radius::from(12),
fill_mode: FillMode::Full
}
}
),
{
let aacp_manager_conv_detect = aacp_manager.clone();
row![
column![
text("Conversation Awareness").size(16),
text("Lowers the volume of your audio when it detects that you are speaking.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill),
].width(Length::Fill),
toggler(state.conversation_awareness_enabled)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_conv_detect.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::ConversationDetectConfig,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Conversation Awareness command");
}
);
let mut state = state.clone();
state.conversation_awareness_enabled = is_enabled;
Message::StateChanged(mac_audio.to_string(), DeviceState::AirPods(state))
})
.spacing(0)
.size(20)
]
.align_y(Center)
.spacing(8)
}
]
.spacing(4)
.padding(8)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
)
];
let off_listening_mode_toggle = {
let aacp_manager_olm = aacp_manager.clone();
let mac = mac.clone();
container(row![
column![
text("Off Listening Mode").size(16),
text("When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when listening mode is set to Off.").size(12).style(
|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
).width(Length::Fill)
].width(Length::Fill),
toggler(state.allow_off_mode)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_olm.clone();
run_async_in_thread(
async move {
aacp_manager.send_control_command(
ControlCommandIdentifiers::AllowOffOption,
if is_enabled { &[0x01] } else { &[0x02] }
).await.expect("Failed to send Off Listening Mode command");
}
);
let mut state = state.clone();
state.allow_off_mode = is_enabled;
Message::StateChanged(mac.to_string(), DeviceState::AirPods(state))
})
.spacing(0)
.size(20)
]
.align_y(Center)
.spacing(8)
)
.padding(Padding{
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
}
)
};
let mut information_col = column![];
if let Some(device) = devices_list.get(mac_information.as_str()) {
if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information {
let info_rows = column![
row![
text("Model Number").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(airpods_info.model_number.clone()).size(16)
],
row![
text("Manufacturer").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(airpods_info.manufacturer.clone()).size(16)
],
row![
text("Serial Number").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
button(text(airpods_info.serial_number.clone()).size(16))
.style(|theme: &Theme, _status| {
let mut style = Style::default();
style.text_color = theme.palette().text;
style.background = Some(Background::Color(Color::TRANSPARENT));
style
})
.padding(0)
.on_press(Message::CopyToClipboard(airpods_info.serial_number.clone()))
],
row![
text("Left Serial Number").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
button(text(airpods_info.left_serial_number.clone()).size(16))
.style(|theme: &Theme, _status| {
let mut style = Style::default();
style.text_color = theme.palette().text;
style.background = Some(Background::Color(Color::TRANSPARENT));
style
})
.padding(0)
.on_press(Message::CopyToClipboard(
airpods_info.left_serial_number.clone()
))
],
row![
text("Right Serial Number").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
button(text(airpods_info.right_serial_number.clone()).size(16))
.style(|theme: &Theme, _status| {
let mut style = Style::default();
style.text_color = theme.palette().text;
style.background = Some(Background::Color(Color::TRANSPARENT));
style
})
.padding(0)
.on_press(Message::CopyToClipboard(
airpods_info.right_serial_number.clone()
))
],
row![
text("Version 1").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(airpods_info.version1.clone()).size(16)
],
row![
text("Version 2").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(airpods_info.version2.clone()).size(16)
],
row![
text("Version 3").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(airpods_info.version3.clone()).size(16)
]
]
.spacing(4)
.padding(8);
information_col = column![
container(text("Device Information").size(18).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().primary);
style
}))
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
}),
container(info_rows)
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background =
Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
})
];
} else {
error!(
"Expected AirPodsInformation for device {}, got something else",
mac.clone()
);
}
}
container(column![
rename_input,
Space::with_height(Length::from(20)),
listening_mode,
Space::with_height(Length::from(20)),
audio_settings_col,
Space::with_height(Length::from(20)),
off_listening_mode_toggle,
Space::with_height(Length::from(20)),
information_col
])
.padding(20)
.center_x(Length::Fill)
.height(Length::Fill)
}
fn run_async_in_thread<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(fut);
});
}

View File

@@ -0,0 +1,11 @@
use crate::bluetooth::aacp::AACPEvent;
#[derive(Debug, Clone)]
pub enum BluetoothUIMessage {
OpenWindow,
DeviceConnected(String), // mac
DeviceDisconnected(String), // mac
AACPUIEvent(String, AACPEvent), // mac, event
ATTNotification(String, u16, Vec<u8>), // mac, handle, data
NoOp,
}

5
linux-rust/src/ui/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod airpods;
pub mod messages;
mod nothing;
pub mod tray;
pub mod window;

View File

@@ -0,0 +1,188 @@
use crate::bluetooth::att::{ATTHandles, ATTManager};
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceState, NothingState};
use crate::ui::window::Message;
use iced::border::Radius;
use iced::overlay::menu;
use iced::widget::combo_box;
use iced::widget::text_input;
use iced::widget::{Space, column, container, row, text};
use iced::{Background, Border, Length, Theme};
use std::collections::HashMap;
use std::sync::Arc;
use std::thread;
use tokio::runtime::Runtime;
pub fn nothing_view<'a>(
mac: &'a str,
devices_list: &HashMap<String, DeviceData>,
state: &'a NothingState,
att_manager: Arc<ATTManager>,
) -> iced::widget::Container<'a, Message> {
let mut information_col = iced::widget::column![];
let mac = mac.to_string();
if let Some(device) = devices_list.get(mac.as_str())
&& let Some(DeviceInformation::Nothing(ref nothing_info)) = device.information
{
information_col = information_col
.push(text("Device Information").size(18).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().primary);
style
}))
.push(Space::with_height(iced::Length::from(10)))
.push(iced::widget::row![
text("Serial Number").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(nothing_info.serial_number.clone()).size(16)
])
.push(iced::widget::row![
text("Firmware Version").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
text(nothing_info.firmware_version.clone()).size(16)
]);
}
let noise_control_mode = container(
row![
text("Noise Control Mode").size(16).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().text);
style
}),
Space::with_width(Length::Fill),
{
let state_clone = state.clone();
let mac = mac.clone();
let att_manager_clone = att_manager.clone();
combo_box(
&state.anc_mode_state,
"Select Noise Control Mode",
Some(&state.anc_mode.clone()),
{
move |selected_mode| {
let att_manager = att_manager_clone.clone();
let selected_mode_c = selected_mode.clone();
let mac_s = mac.clone();
run_async_in_thread(async move {
if let Err(e) = att_manager
.write(
ATTHandles::NothingEverything,
&[
0x55,
0x60,
0x01,
0x0F,
0xF0,
0x03,
0x00,
0x00,
0x01,
selected_mode_c.to_byte(),
0x00,
0x00,
0x00,
],
)
.await
{
log::error!(
"Failed to set noise cancellation mode for device {}: {}",
mac_s,
e
);
}
});
let mut state = state_clone.clone();
state.anc_mode = selected_mode.clone();
Message::StateChanged(mac.to_string(), DeviceState::Nothing(state))
}
},
)
.width(Length::from(200))
.input_style(|theme: &Theme, _status| text_input::Style {
background: Background::Color(theme.palette().primary.scale_alpha(0.2)),
border: Border {
width: 1.0,
color: theme.palette().text.scale_alpha(0.3),
radius: Radius::from(4.0),
},
icon: Default::default(),
placeholder: theme.palette().text,
value: theme.palette().text,
selection: Default::default(),
})
.padding(iced::Padding {
top: 5.0,
bottom: 5.0,
left: 10.0,
right: 10.0,
})
.menu_style(|theme: &Theme| menu::Style {
background: Background::Color(theme.palette().background),
border: Border {
width: 1.0,
color: theme.palette().text,
radius: Radius::from(4.0),
},
text_color: theme.palette().text,
selected_text_color: theme.palette().text,
selected_background: Background::Color(
theme.palette().primary.scale_alpha(0.3),
),
})
}
]
.align_y(iced::Alignment::Center),
)
.padding(iced::Padding {
top: 5.0,
bottom: 5.0,
left: 18.0,
right: 18.0,
})
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().primary.scale_alpha(0.5);
style.border = border.rounded(16);
style
});
container(column![
noise_control_mode,
Space::with_height(Length::from(20)),
container(information_col)
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background =
Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
let mut border = Border::default();
border.color = theme.palette().text;
style.border = border.rounded(20);
style
})
.padding(20)
])
.padding(20)
.center_x(Length::Fill)
.height(Length::Fill)
}
fn run_async_in_thread<F>(fut: F)
where
F: Future<Output = ()> + Send + 'static,
{
thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(fut);
});
}

301
linux-rust/src/ui/tray.rs Normal file
View File

@@ -0,0 +1,301 @@
// use ksni::TrayMethods; // provides the spawn method
use ab_glyph::{Font, ScaleFont};
use ksni::{Icon, ToolTip};
use tokio::sync::mpsc::UnboundedSender;
use crate::bluetooth::aacp::{BatteryStatus, ControlCommandIdentifiers};
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_app_settings_path;
#[derive(Debug)]
pub struct MyTray {
pub conversation_detect_enabled: Option<bool>,
pub battery_headphone: Option<u8>,
pub battery_headphone_status: Option<BatteryStatus>,
pub battery_l: Option<u8>,
pub battery_l_status: Option<BatteryStatus>,
pub battery_r: Option<u8>,
pub battery_r_status: Option<BatteryStatus>,
pub battery_c: Option<u8>,
pub battery_c_status: Option<BatteryStatus>,
pub connected: bool,
pub listening_mode: Option<u8>,
pub allow_off_option: Option<u8>,
pub command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
pub ui_tx: Option<UnboundedSender<BluetoothUIMessage>>,
}
impl ksni::Tray for MyTray {
fn id(&self) -> String {
env!("CARGO_PKG_NAME").into()
}
fn title(&self) -> String {
"AirPods".into()
}
fn icon_pixmap(&self) -> Vec<Icon> {
let text = {
let mut levels: Vec<u8> = Vec::new();
if let Some(h) = self.battery_headphone {
if self.battery_headphone_status != Some(BatteryStatus::Disconnected) {
levels.push(h);
}
} else {
if let Some(l) = self.battery_l
&& self.battery_l_status != Some(BatteryStatus::Disconnected)
{
levels.push(l);
}
if let Some(r) = self.battery_r
&& self.battery_r_status != Some(BatteryStatus::Disconnected)
{
levels.push(r);
}
// if let Some(c) = self.battery_c {
// if self.battery_c_status != Some(BatteryStatus::Disconnected) {
// levels.push(c);
// }
// }
}
let min_battery = levels.iter().min().copied();
if let Some(b) = min_battery {
format!("{}", b)
} else {
"?".to_string()
}
};
let any_bud_charging = matches!(self.battery_l_status, Some(BatteryStatus::Charging))
|| matches!(self.battery_r_status, Some(BatteryStatus::Charging));
let app_settings_path = get_app_settings_path();
let settings = std::fs::read_to_string(&app_settings_path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let text_mode = settings
.clone()
.and_then(|v| v.get("tray_text_mode").cloned())
.and_then(|ttm| serde_json::from_value(ttm).ok())
.unwrap_or(false);
let icon = generate_icon(&text, text_mode, any_bud_charging);
vec![icon]
}
fn tool_tip(&self) -> ToolTip {
let format_component =
|label: &str, level: Option<u8>, status: Option<BatteryStatus>| -> String {
match status {
Some(BatteryStatus::Disconnected) => format!("{}: -", label),
_ => {
let pct = level.map(|b| format!("{}%", b)).unwrap_or("?".to_string());
let suffix = if status == Some(BatteryStatus::Charging) {
""
} else {
""
};
format!("{}: {}{}", label, pct, suffix)
}
}
};
let l = format_component("L", self.battery_l, self.battery_l_status);
let r = format_component("R", self.battery_r, self.battery_r_status);
let c = format_component("C", self.battery_c, self.battery_c_status);
ToolTip {
icon_name: "".to_string(),
icon_pixmap: vec![],
title: "Battery Status".to_string(),
description: format!("{} {} {}", l, r, c),
}
}
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
use ksni::menu::*;
let allow_off = self.allow_off_option == Some(0x01);
let options = if allow_off {
vec![
("Off", 0x01),
("Noise Cancellation", 0x02),
("Transparency", 0x03),
("Adaptive", 0x04),
]
} else {
vec![
("Noise Cancellation", 0x02),
("Transparency", 0x03),
("Adaptive", 0x04),
]
};
let selected = self
.listening_mode
.and_then(|mode| options.iter().position(|&(_, val)| val == mode))
.unwrap_or(0);
let options_clone = options.clone();
vec![
StandardItem {
label: "Open Window".into(),
icon_name: "window-new".into(),
activate: Box::new(|this: &mut Self| {
if let Some(tx) = &this.ui_tx {
let _ = tx.send(BluetoothUIMessage::OpenWindow);
}
}),
..Default::default()
}
.into(),
RadioGroup {
selected,
select: Box::new(move |this: &mut Self, current| {
if let Some(tx) = &this.command_tx {
let value = options_clone
.get(current)
.map(|&(_, val)| val)
.unwrap_or(0x02);
let _ = tx.send((ControlCommandIdentifiers::ListeningMode, vec![value]));
}
}),
options: options
.into_iter()
.map(|(label, _)| RadioItem {
label: label.into(),
..Default::default()
})
.collect(),
..Default::default()
}
.into(),
MenuItem::Separator,
CheckmarkItem {
label: "Conversation Detection".into(),
checked: self.conversation_detect_enabled.unwrap_or(false),
enabled: self.conversation_detect_enabled.is_some(),
activate: Box::new(|this: &mut Self| {
if let Some(tx) = &this.command_tx
&& let Some(is_enabled) = this.conversation_detect_enabled
{
let new_state = !is_enabled;
let value = if !new_state { 0x02 } else { 0x01 };
let _ = tx.send((
ControlCommandIdentifiers::ConversationDetectConfig,
vec![value],
));
this.conversation_detect_enabled = Some(new_state);
}
}),
..Default::default()
}
.into(),
StandardItem {
label: "Exit".into(),
icon_name: "application-exit".into(),
activate: Box::new(|_| std::process::exit(0)),
..Default::default()
}
.into(),
]
}
}
fn generate_icon(text: &str, text_mode: bool, charging: bool) -> Icon {
use ab_glyph::{FontRef, PxScale};
use image::{ImageBuffer, Rgba};
use imageproc::drawing::draw_text_mut;
let width = 64;
let height = 64;
let mut img = ImageBuffer::from_fn(width, height, |_, _| Rgba([0u8, 0u8, 0u8, 0u8]));
let font_data = include_bytes!("../../assets/font/DejaVuSans.ttf");
let font = match FontRef::try_from_slice(font_data) {
Ok(f) => f,
Err(_) => {
return Icon {
width: width as i32,
height: height as i32,
data: vec![0u8; (width * height * 4) as usize],
};
}
};
if !text_mode {
let percentage = text.parse::<f32>().unwrap_or(0.0) / 100.0;
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let inner_radius = 22.0;
let outer_radius = 28.0;
// ring background
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > inner_radius && dist <= outer_radius {
img.put_pixel(x, y, Rgba([128u8, 128u8, 128u8, 255u8]));
}
}
}
// ring
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > inner_radius && dist <= outer_radius {
let angle = dy.atan2(dx);
let angle_from_top =
(angle + std::f32::consts::PI / 2.0).rem_euclid(2.0 * std::f32::consts::PI);
if angle_from_top <= percentage * 2.0 * std::f32::consts::PI {
img.put_pixel(x, y, Rgba([0u8, 255u8, 0u8, 255u8]));
}
}
}
}
if charging {
let emoji = "";
let scale = PxScale::from(48.0);
let color = Rgba([0u8, 255u8, 0u8, 255u8]);
let scaled_font = font.as_scaled(scale);
let mut emoji_width = 0.0;
for c in emoji.chars() {
let glyph_id = font.glyph_id(c);
emoji_width += scaled_font.h_advance(glyph_id);
}
let x = ((width as f32 - emoji_width) / 2.0).max(0.0) as i32;
let y = ((height as f32 - scale.y) / 2.0).max(0.0) as i32;
draw_text_mut(&mut img, color, x, y, scale, &font, emoji);
}
} else {
// battery text
let scale = PxScale::from(48.0);
let color = if charging {
Rgba([0u8, 255u8, 0u8, 255u8])
} else {
Rgba([255u8, 255u8, 255u8, 255u8])
};
let scaled_font = font.as_scaled(scale);
let mut text_width = 0.0;
for c in text.chars() {
let glyph_id = font.glyph_id(c);
text_width += scaled_font.h_advance(glyph_id);
}
let x = ((width as f32 - text_width) / 2.0).max(0.0) as i32;
let y = ((height as f32 - scale.y) / 2.0).max(0.0) as i32;
draw_text_mut(&mut img, color, x, y, scale, &font, text);
}
let mut data = Vec::with_capacity((width * height * 4) as usize);
for pixel in img.pixels() {
data.push(pixel[3]);
data.push(pixel[0]);
data.push(pixel[1]);
data.push(pixel[2]);
}
Icon {
width: width as i32,
height: height as i32,
data,
}
}

1214
linux-rust/src/ui/window.rs Normal file

File diff suppressed because it is too large Load Diff

136
linux-rust/src/utils.rs Normal file
View File

@@ -0,0 +1,136 @@
use aes::Aes128;
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockEncrypt, KeyInit};
use iced::Theme;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub fn get_devices_path() -> PathBuf {
let data_dir = std::env::var("XDG_DATA_HOME")
.unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default()));
PathBuf::from(data_dir)
.join("librepods")
.join("devices.json")
}
pub fn get_preferences_path() -> PathBuf {
let config_dir = std::env::var("XDG_CONFIG_HOME")
.unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default()));
PathBuf::from(config_dir)
.join("librepods")
.join("preferences.json")
}
pub fn get_app_settings_path() -> PathBuf {
let config_dir = std::env::var("XDG_CONFIG_HOME")
.unwrap_or_else(|_| format!("{}/.local/share", std::env::var("HOME").unwrap_or_default()));
PathBuf::from(config_dir)
.join("librepods")
.join("app_settings.json")
}
fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let mut swapped_key = *key;
swapped_key.reverse();
let mut swapped_data = *data;
swapped_data.reverse();
let cipher = Aes128::new(&GenericArray::from(swapped_key));
let mut block = GenericArray::from(swapped_data);
cipher.encrypt_block(&mut block);
let mut result: [u8; 16] = block.into();
result.reverse();
result
}
pub fn ah(k: &[u8; 16], r: &[u8; 3]) -> [u8; 3] {
let mut r_padded = [0u8; 16];
r_padded[..3].copy_from_slice(r);
let encrypted = e(k, &r_padded);
let mut hash = [0u8; 3];
hash.copy_from_slice(&encrypted[..3]);
hash
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum MyTheme {
Light,
Dark,
Dracula,
Nord,
SolarizedLight,
SolarizedDark,
GruvboxLight,
GruvboxDark,
CatppuccinLatte,
CatppuccinFrappe,
CatppuccinMacchiato,
CatppuccinMocha,
TokyoNight,
TokyoNightStorm,
TokyoNightLight,
KanagawaWave,
KanagawaDragon,
KanagawaLotus,
Moonfly,
Nightfly,
Oxocarbon,
Ferra,
}
impl std::fmt::Display for MyTheme {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Light => "Light",
Self::Dark => "Dark",
Self::Dracula => "Dracula",
Self::Nord => "Nord",
Self::SolarizedLight => "Solarized Light",
Self::SolarizedDark => "Solarized Dark",
Self::GruvboxLight => "Gruvbox Light",
Self::GruvboxDark => "Gruvbox Dark",
Self::CatppuccinLatte => "Catppuccin Latte",
Self::CatppuccinFrappe => "Catppuccin Frappé",
Self::CatppuccinMacchiato => "Catppuccin Macchiato",
Self::CatppuccinMocha => "Catppuccin Mocha",
Self::TokyoNight => "Tokyo Night",
Self::TokyoNightStorm => "Tokyo Night Storm",
Self::TokyoNightLight => "Tokyo Night Light",
Self::KanagawaWave => "Kanagawa Wave",
Self::KanagawaDragon => "Kanagawa Dragon",
Self::KanagawaLotus => "Kanagawa Lotus",
Self::Moonfly => "Moonfly",
Self::Nightfly => "Nightfly",
Self::Oxocarbon => "Oxocarbon",
Self::Ferra => "Ferra",
})
}
}
impl From<MyTheme> for Theme {
fn from(my_theme: MyTheme) -> Self {
match my_theme {
MyTheme::Light => Theme::Light,
MyTheme::Dark => Theme::Dark,
MyTheme::Dracula => Theme::Dracula,
MyTheme::Nord => Theme::Nord,
MyTheme::SolarizedLight => Theme::SolarizedLight,
MyTheme::SolarizedDark => Theme::SolarizedDark,
MyTheme::GruvboxLight => Theme::GruvboxLight,
MyTheme::GruvboxDark => Theme::GruvboxDark,
MyTheme::CatppuccinLatte => Theme::CatppuccinLatte,
MyTheme::CatppuccinFrappe => Theme::CatppuccinFrappe,
MyTheme::CatppuccinMacchiato => Theme::CatppuccinMacchiato,
MyTheme::CatppuccinMocha => Theme::CatppuccinMocha,
MyTheme::TokyoNight => Theme::TokyoNight,
MyTheme::TokyoNightStorm => Theme::TokyoNightStorm,
MyTheme::TokyoNightLight => Theme::TokyoNightLight,
MyTheme::KanagawaWave => Theme::KanagawaWave,
MyTheme::KanagawaDragon => Theme::KanagawaDragon,
MyTheme::KanagawaLotus => Theme::KanagawaLotus,
MyTheme::Moonfly => Theme::Moonfly,
MyTheme::Nightfly => Theme::Nightfly,
MyTheme::Oxocarbon => Theme::Oxocarbon,
MyTheme::Ferra => Theme::Ferra,
}
}
}

View File

@@ -4,16 +4,20 @@ project(linux VERSION 0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
find_package(Qt6 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(PULSEAUDIO REQUIRED libpulse)
qt_standard_project_setup(REQUIRES 6.4)
qt_standard_project_setup()
qt_add_executable(librepods
main.cpp
logger.h
media/mediacontroller.cpp
media/mediacontroller.h
media/pulseaudiocontroller.cpp
media/pulseaudiocontroller.h
airpods_packets.h
trayiconmanager.cpp
trayiconmanager.h
@@ -66,9 +70,11 @@ qt_add_resources(librepods "resources"
)
target_link_libraries(librepods
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto ${PULSEAUDIO_LIBRARIES}
)
target_include_directories(librepods PRIVATE ${PULSEAUDIO_INCLUDE_DIRS})
include(GNUInstallDirs)
install(TARGETS librepods
BUNDLE DESTINATION .

View File

@@ -156,6 +156,13 @@ ApplicationWindow {
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
}
Switch {
visible: airPodsTrayApp.airpodsConnected
text: "Hearing Aid"
checked: airPodsTrayApp.deviceInfo.hearingAidEnabled
onCheckedChanged: airPodsTrayApp.setHearingAidEnabled(checked)
}
}
RoundButton {

View File

@@ -6,7 +6,10 @@ A native Linux application to control your AirPods, with support for:
- Conversational Awareness
- Battery monitoring
- Auto play/pause on ear detection
- Seamless handoff between phone and PC
- Hearing Aid features
- Supports adjusting hearing aid- amplification, balance, tone, ambient noise reduction, own voice amplification, and conversation boost
- Supports setting the values for left and right hearing aids (this is not a hearing test! you need to have an audiogram to set the values)
- Seamless handoff between Android and Linux
## Prerequisites
@@ -40,13 +43,7 @@ A native Linux application to control your AirPods, with support for:
```
## Setup
1. Set the `PHONE_MAC_ADDRESS` environment variable to your phone's Bluetooth MAC address by running the following:
```bash
export PHONE_MAC_ADDRESS="XX:XX:XX:XX:XX:XX" # Replace with your phone's MAC
```
2. Build the application:
1. Build the application:
```bash
mkdir build
@@ -55,12 +52,46 @@ A native Linux application to control your AirPods, with support for:
make -j $(nproc)
```
3. Run the application:
2. Run the application:
```bash
./librepods
```
## Troubleshooting
### Media Controls (Play/Pause/Skip) Not Working
If tap gestures on your AirPods aren't working for media control, you need to enable AVRCP support. The solution depends on your audio stack:
#### PipeWire/WirePlumber (Recommended)
Create `~/.config/wireplumber/wireplumber.conf.d/51-bluez-avrcp.conf`:
```conf
monitor.bluez.properties = {
# Enable dummy AVRCP player for proper media control support
# This is required for AirPods and other devices to send play/pause/skip commands
bluez5.dummy-avrcp-player = true
}
```
Then restart WirePlumber:
```bash
systemctl --user restart wireplumber
```
**Note:** Do NOT run `mpris-proxy` with WirePlumber - it will conflict and break media controls.
#### PulseAudio
If you're using PulseAudio instead of PipeWire, enable and start `mpris-proxy`:
```bash
systemctl --user enable --now mpris-proxy
```
## Usage
- Left-click the tray icon to view battery status
@@ -69,3 +100,35 @@ A native Linux application to control your AirPods, with support for:
- Switch between noise control modes
- View battery levels
- Control playback
## Hearing Aid
To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. But, to adjust the settings and set the audiogram, you need to use a different script which is located in this folder as `hearing_aid.py`. You can run it with:
```bash
python3 hearing_aid.py
```
The script will load the current settings from the AirPods and allow you to adjust them. You can set the audiogram by providing the values for 8 frequencies (250Hz, 500Hz, 1kHz, 2kHz, 3kHz, 4kHz, 6kHz, 8kHz) for both left and right ears. There are also options to adjust amplification, balance, tone, ambient noise reduction, own voice amplification, and conversation boost.
AirPods check for the DeviceID characteristic to see if the connected device is an Apple device and only then allow hearing aid features. To set the DeviceID characteristic, you need to add this line to your bluetooth configuration file (usually located at `/etc/bluetooth/main.conf`):
```
DeviceID = bluetooth:004C:0000:0000
```
Then, restart the bluetooth service:
```bash
sudo systemctl restart bluetooth
```
Here, you might need to re-pair your AirPods because they seem to cache this info.
### Troubleshooting
It is possible that the AirPods disconnect after a short period of time and play the disconnect sound. This is likely due to the AirPods expecting some information from an Apple device. Since I have not implemented everything that an Apple device does, the AirPods may disconnect. You don't need to reconnect them manually; the script will handle reconnection automatically for hearing aid features. So, once you are done setting the hearing aid features, change back the `DeviceID` to whatever it was before.
### Why a separate script?
Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application.

View File

@@ -107,6 +107,34 @@ namespace AirPodsPackets
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
}
// Hearing Aid
namespace HearingAid
{
static const QByteArray HEADER = ControlCommand::HEADER + static_cast<char>(0x2C);
static const QByteArray ENABLED = ControlCommand::createCommand(0x2C, 0x01, 0x01);
static const QByteArray DISABLED = ControlCommand::createCommand(0x2C, 0x02, 0x02);
inline std::optional<bool> parseState(const QByteArray &data)
{
if (!data.startsWith(HEADER) || data.size() < HEADER.size() + 2)
return std::nullopt;
QByteArray value = data.mid(HEADER.size(), 2);
if (value.size() != 2)
return std::nullopt;
char b1 = value.at(0);
char b2 = value.at(1);
if (b1 == 0x01 && b2 == 0x01)
return true;
if (b1 == 0x02 || b2 == 0x02)
return false;
return std::nullopt;
}
}
// Allow Off Option
namespace AllowOffOption
{

View File

@@ -15,6 +15,7 @@ class DeviceInfo : public QObject
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
Q_PROPERTY(bool hearingAidEnabled READ hearingAidEnabled WRITE setHearingAidEnabled NOTIFY hearingAidEnabledChanged)
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
@@ -67,6 +68,16 @@ public:
}
}
bool hearingAidEnabled() const { return m_hearingAidEnabled; }
void setHearingAidEnabled(bool enabled)
{
if (m_hearingAidEnabled != enabled)
{
m_hearingAidEnabled = enabled;
emit hearingAidEnabledChanged(enabled);
}
}
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
void setAdaptiveNoiseLevel(int level)
{
@@ -159,6 +170,7 @@ public:
setNoiseControlMode(NoiseControlMode::Off);
setBluetoothAddress("");
getEarDetection()->reset();
setHearingAidEnabled(false);
}
void saveToSettings(QSettings &settings)
@@ -168,6 +180,7 @@ public:
settings.setValue("model", static_cast<int>(model()));
settings.setValue("magicAccIRK", magicAccIRK());
settings.setValue("magicAccEncKey", magicAccEncKey());
settings.setValue("hearingAidEnabled", hearingAidEnabled());
settings.endGroup();
}
void loadFromSettings(const QSettings &settings)
@@ -176,6 +189,7 @@ public:
setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
setHearingAidEnabled(settings.value("DeviceInfo/hearingAidEnabled", false).toBool());
}
void updateBatteryStatus()
@@ -191,6 +205,7 @@ signals:
void noiseControlModeChanged(NoiseControlMode mode);
void noiseControlModeChangedInt(int mode);
void conversationalAwarenessChanged(bool enabled);
void hearingAidEnabledChanged(bool enabled);
void adaptiveNoiseLevelChanged(int level);
void deviceNameChanged(const QString &name);
void primaryChanged();
@@ -202,6 +217,7 @@ private:
QString m_batteryStatus;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
bool m_conversationalAwareness = false;
bool m_hearingAidEnabled = false;
int m_adaptiveNoiseLevel = 50;
QString m_deviceName;
Battery *m_battery;

View File

@@ -0,0 +1,480 @@
import sys
import socket
import struct
import threading
from queue import Queue
import logging
import signal
# Configure logging
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
from PyQt5.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QCheckBox, QPushButton, QLineEdit, QFormLayout, QGridLayout
from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QObject
OPCODE_READ_REQUEST = 0x0A
OPCODE_WRITE_REQUEST = 0x12
OPCODE_HANDLE_VALUE_NTF = 0x1B
ATT_HANDLES = {
'TRANSPARENCY': 0x18,
'LOUD_SOUND_REDUCTION': 0x1B,
'HEARING_AID': 0x2A,
}
ATT_CCCD_HANDLES = {
'TRANSPARENCY': ATT_HANDLES['TRANSPARENCY'] + 1,
'LOUD_SOUND_REDUCTION': ATT_HANDLES['LOUD_SOUND_REDUCTION'] + 1,
'HEARING_AID': ATT_HANDLES['HEARING_AID'] + 1,
}
PSM_ATT = 31
class ATTManager:
def __init__(self, mac_address):
self.mac_address = mac_address
self.sock = None
self.responses = Queue()
self.listeners = {}
self.notification_thread = None
self.running = False
# Avoid logging full MAC address to prevent sensitive data exposure
mac_tail = ':'.join(mac_address.split(':')[-2:]) if isinstance(mac_address, str) and ':' in mac_address else '[redacted]'
logging.info(f"ATTManager initialized")
def connect(self):
logging.info("Attempting to connect to ATT socket")
self.sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP)
self.sock.connect((self.mac_address, PSM_ATT))
self.sock.settimeout(0.1)
self.running = True
self.notification_thread = threading.Thread(target=self._listen_notifications)
self.notification_thread.start()
logging.info("Connected to ATT socket")
def disconnect(self):
logging.info("Disconnecting from ATT socket")
self.running = False
if self.sock:
logging.info("Closing socket")
self.sock.close()
if self.notification_thread:
logging.info("Stopping notification thread")
self.notification_thread.join(timeout=1.0)
logging.info("Disconnected from ATT socket")
def register_listener(self, handle, listener):
if handle not in self.listeners:
self.listeners[handle] = []
self.listeners[handle].append(listener)
logging.debug(f"Registered listener for handle {handle}")
def unregister_listener(self, handle, listener):
if handle in self.listeners:
self.listeners[handle].remove(listener)
logging.debug(f"Unregistered listener for handle {handle}")
def enable_notifications(self, handle):
self.write_cccd(handle, b'\x01\x00')
logging.info(f"Enabled notifications for handle {handle.name}")
def read(self, handle):
handle_value = ATT_HANDLES[handle.name]
lsb = handle_value & 0xFF
msb = (handle_value >> 8) & 0xFF
pdu = bytes([OPCODE_READ_REQUEST, lsb, msb])
logging.debug(f"Sending read request for handle {handle.name}: {pdu.hex()}")
self._write_raw(pdu)
response = self._read_response()
logging.debug(f"Read response for handle {handle.name}: {response.hex()}")
return response
def write(self, handle, value):
handle_value = ATT_HANDLES[handle.name]
lsb = handle_value & 0xFF
msb = (handle_value >> 8) & 0xFF
pdu = bytes([OPCODE_WRITE_REQUEST, lsb, msb]) + value
logging.debug(f"Sending write request for handle {handle.name}: {pdu.hex()}")
self._write_raw(pdu)
try:
self._read_response()
logging.debug(f"Write response received for handle {handle.name}")
except:
logging.warning(f"No write response received for handle {handle.name}")
def write_cccd(self, handle, value):
handle_value = ATT_CCCD_HANDLES[handle.name]
lsb = handle_value & 0xFF
msb = (handle_value >> 8) & 0xFF
pdu = bytes([OPCODE_WRITE_REQUEST, lsb, msb]) + value
logging.debug(f"Sending CCCD write request for handle {handle.name}: {pdu.hex()}")
self._write_raw(pdu)
try:
self._read_response()
logging.debug(f"CCCD write response received for handle {handle.name}")
except:
logging.warning(f"No CCCD write response received for handle {handle.name}")
def _write_raw(self, pdu):
self.sock.send(pdu)
logging.debug(f"Sent PDU: {pdu.hex()}")
def _read_pdu(self):
try:
data = self.sock.recv(512)
logging.debug(f"Received PDU: {data.hex()}")
return data
except socket.timeout:
return None
except:
raise
def _read_response(self, timeout=2.0):
try:
response = self.responses.get(timeout=timeout)[1:] # Skip opcode
logging.debug(f"Response received: {response.hex()}")
return response
except:
logging.error("No response received within timeout")
raise Exception("No response received")
def _listen_notifications(self):
logging.info("Starting notification listener thread")
while self.running:
try:
pdu = self._read_pdu()
except:
break
if pdu is None:
continue
if len(pdu) > 0 and pdu[0] == OPCODE_HANDLE_VALUE_NTF:
logging.debug(f"Notification PDU received: {pdu.hex()}")
handle = pdu[1] | (pdu[2] << 8)
value = pdu[3:]
logging.debug(f"Notification for handle {handle}: {value.hex()}")
if handle in self.listeners:
for listener in self.listeners[handle]:
listener(value)
else:
self.responses.put(pdu)
logging.info("Notification listener thread stopped, trying to reconnect")
if self.running:
try:
self.connect()
except Exception as e:
logging.error(f"Reconnection failed: {e}")
class HearingAidSettings:
def __init__(self, left_eq, right_eq, left_amp, right_amp, left_tone, right_tone,
left_conv, right_conv, left_anr, right_anr, net_amp, balance, own_voice):
self.left_eq = left_eq
self.right_eq = right_eq
self.left_amplification = left_amp
self.right_amplification = right_amp
self.left_tone = left_tone
self.right_tone = right_tone
self.left_conversation_boost = left_conv
self.right_conversation_boost = right_conv
self.left_ambient_noise_reduction = left_anr
self.right_ambient_noise_reduction = right_anr
self.net_amplification = net_amp
self.balance = balance
self.own_voice_amplification = own_voice
logging.debug(f"HearingAidSettings created: amp={net_amp}, balance={balance}, tone={left_tone}, anr={left_anr}, conv={left_conv}")
def parse_hearing_aid_settings(data):
logging.debug(f"Parsing hearing aid settings from data: {data.hex()}")
if len(data) < 104:
logging.warning("Data too short for parsing")
return None
buffer = data
offset = 0
offset += 4
logging.info(f"Parsing hearing aid settings, starting read at offset 4, value: {buffer[offset]:02x}")
left_eq = []
for i in range(8):
val, = struct.unpack('<f', buffer[offset:offset+4])
left_eq.append(val)
offset += 4
left_amp, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
left_tone, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
left_conv_float, = struct.unpack('<f', buffer[offset:offset+4])
left_conv = left_conv_float > 0.5
offset += 4
left_anr, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
right_eq = []
for _ in range(8):
val, = struct.unpack('<f', buffer[offset:offset+4])
right_eq.append(val)
offset += 4
right_amp, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
right_tone, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
right_conv_float, = struct.unpack('<f', buffer[offset:offset+4])
right_conv = right_conv_float > 0.5
offset += 4
right_anr, = struct.unpack('<f', buffer[offset:offset+4])
offset += 4
own_voice, = struct.unpack('<f', buffer[offset:offset+4])
avg = (left_amp + right_amp) / 2
amplification = max(-1, min(1, avg))
diff = right_amp - left_amp
balance = max(-1, min(1, diff))
settings = HearingAidSettings(left_eq, right_eq, left_amp, right_amp, left_tone, right_tone,
left_conv, right_conv, left_anr, right_anr, amplification, balance, own_voice)
logging.info(f"Parsed settings: amp={amplification}, balance={balance}")
return settings
def send_hearing_aid_settings(att_manager, settings):
logging.info("Sending hearing aid settings")
data = att_manager.read(type('Handle', (), {'name': 'HEARING_AID'})())
if len(data) < 104:
logging.error("Read data too short for sending settings")
return
buffer = bytearray(data)
# Modify byte at index 2 to 0x64
buffer[2] = 0x64
# Left ear
for i in range(8):
struct.pack_into('<f', buffer, 4 + i * 4, settings.left_eq[i])
struct.pack_into('<f', buffer, 36, settings.left_amplification)
struct.pack_into('<f', buffer, 40, settings.left_tone)
struct.pack_into('<f', buffer, 44, 1.0 if settings.left_conversation_boost else 0.0)
struct.pack_into('<f', buffer, 48, settings.left_ambient_noise_reduction)
# Right ear
for i in range(8):
struct.pack_into('<f', buffer, 52 + i * 4, settings.right_eq[i])
struct.pack_into('<f', buffer, 84, settings.right_amplification)
struct.pack_into('<f', buffer, 88, settings.right_tone)
struct.pack_into('<f', buffer, 92, 1.0 if settings.right_conversation_boost else 0.0)
struct.pack_into('<f', buffer, 96, settings.right_ambient_noise_reduction)
# Own voice
struct.pack_into('<f', buffer, 100, settings.own_voice_amplification)
att_manager.write(type('Handle', (), {'name': 'HEARING_AID'})(), buffer)
logging.info("Hearing aid settings sent")
class SignalEmitter(QObject):
update_ui = pyqtSignal(HearingAidSettings)
class HearingAidApp(QWidget):
def __init__(self, mac_address):
super().__init__()
self.mac_address = mac_address
self.att_manager = ATTManager(mac_address)
self.emitter = SignalEmitter()
self.emitter.update_ui.connect(self.on_update_ui)
self.debounce_timer = QTimer()
self.debounce_timer.setSingleShot(True)
self.debounce_timer.timeout.connect(self.send_settings)
logging.info("HearingAidConfig initialized")
self.init_ui()
self.connect_att()
def init_ui(self):
logging.debug("Initializing UI")
self.setWindowTitle("Hearing Aid Adjustments")
layout = QVBoxLayout()
# EQ Inputs
eq_layout = QGridLayout()
self.left_eq_inputs = []
self.right_eq_inputs = []
eq_labels = ["250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz"]
eq_layout.addWidget(QLabel("Frequency"), 0, 0)
eq_layout.addWidget(QLabel("Left"), 0, 1)
eq_layout.addWidget(QLabel("Right"), 0, 2)
for i, label in enumerate(eq_labels):
eq_layout.addWidget(QLabel(label), i + 1, 0)
left_input = QLineEdit()
right_input = QLineEdit()
left_input.setPlaceholderText("Left")
right_input.setPlaceholderText("Right")
self.left_eq_inputs.append(left_input)
self.right_eq_inputs.append(right_input)
eq_layout.addWidget(left_input, i + 1, 1)
eq_layout.addWidget(right_input, i + 1, 2)
eq_group = QWidget()
eq_group.setLayout(eq_layout)
layout.addWidget(QLabel("Loss, in dBHL"))
layout.addWidget(eq_group)
# Amplification
self.amp_slider = QSlider(Qt.Horizontal)
self.amp_slider.setRange(-100, 100)
self.amp_slider.setValue(50)
layout.addWidget(QLabel("Amplification"))
layout.addWidget(self.amp_slider)
# Balance
self.balance_slider = QSlider(Qt.Horizontal)
self.balance_slider.setRange(-100, 100)
self.balance_slider.setValue(50)
layout.addWidget(QLabel("Balance"))
layout.addWidget(self.balance_slider)
# Tone
self.tone_slider = QSlider(Qt.Horizontal)
self.tone_slider.setRange(-100, 100)
self.tone_slider.setValue(50)
layout.addWidget(QLabel("Tone"))
layout.addWidget(self.tone_slider)
# Ambient Noise Reduction
self.anr_slider = QSlider(Qt.Horizontal)
self.anr_slider.setRange(0, 100)
self.anr_slider.setValue(0)
layout.addWidget(QLabel("Ambient Noise Reduction"))
layout.addWidget(self.anr_slider)
# Conversation Boost
self.conv_checkbox = QCheckBox("Conversation Boost")
layout.addWidget(self.conv_checkbox)
# Own Voice Amplification
self.own_voice_slider = QSlider(Qt.Horizontal)
self.own_voice_slider.setRange(0, 100)
self.own_voice_slider.setValue(50)
# layout.addWidget(QLabel("Own Voice Amplification"))
# layout.addWidget(self.own_voice_slider) # seems to have no effect
# Reset button
self.reset_button = QPushButton("Reset")
layout.addWidget(self.reset_button)
# Connect signals
for input_box in self.left_eq_inputs + self.right_eq_inputs:
input_box.textChanged.connect(self.on_value_changed)
self.amp_slider.valueChanged.connect(self.on_value_changed)
self.balance_slider.valueChanged.connect(self.on_value_changed)
self.tone_slider.valueChanged.connect(self.on_value_changed)
self.anr_slider.valueChanged.connect(self.on_value_changed)
self.conv_checkbox.stateChanged.connect(self.on_value_changed)
self.own_voice_slider.valueChanged.connect(self.on_value_changed)
self.reset_button.clicked.connect(self.reset_settings)
self.setLayout(layout)
logging.debug("UI initialized")
def connect_att(self):
logging.info("Connecting to ATT in UI")
try:
self.att_manager.connect()
self.att_manager.enable_notifications(type('Handle', (), {'name': 'HEARING_AID'})())
self.att_manager.register_listener(ATT_HANDLES['HEARING_AID'], self.on_notification)
# Initial read
data = self.att_manager.read(type('Handle', (), {'name': 'HEARING_AID'})())
settings = parse_hearing_aid_settings(data)
if settings:
self.emitter.update_ui.emit(settings)
logging.info("Initial settings loaded")
except Exception as e:
if e.errno == 111:
logging.error("Connection refused. Try reconnecting your AirPods.")
sys.exit(1)
else:
logging.error(f"Connection failed: {e}")
def on_notification(self, value):
logging.debug("Notification received")
settings = parse_hearing_aid_settings(value)
if settings:
self.emitter.update_ui.emit(settings)
def on_update_ui(self, settings):
logging.debug("Updating UI with settings")
self.amp_slider.setValue(int(settings.net_amplification * 100))
self.balance_slider.setValue(int(settings.balance * 100))
self.tone_slider.setValue(int(settings.left_tone * 100))
self.anr_slider.setValue(int(settings.left_ambient_noise_reduction * 100))
self.conv_checkbox.setChecked(settings.left_conversation_boost)
self.own_voice_slider.setValue(int(settings.own_voice_amplification * 100))
for i, value in enumerate(settings.left_eq):
self.left_eq_inputs[i].setText(f"{value:.2f}")
for i, value in enumerate(settings.right_eq):
self.right_eq_inputs[i].setText(f"{value:.2f}")
def on_value_changed(self):
logging.debug("UI value changed, starting debounce")
self.debounce_timer.start(100)
def send_settings(self):
logging.info("Sending settings from UI")
amp = self.amp_slider.value() / 100.0
balance = self.balance_slider.value() / 100.0
tone = self.tone_slider.value() / 100.0
anr = self.anr_slider.value() / 100.0
conv = self.conv_checkbox.isChecked()
own_voice = self.own_voice_slider.value() / 100.0
left_amp = amp + (0.5 - balance) * amp * 2 if balance < 0 else amp
right_amp = amp + (balance - 0.5) * amp * 2 if balance > 0 else amp
left_eq = [float(input_box.text() or 0) for input_box in self.left_eq_inputs]
right_eq = [float(input_box.text() or 0) for input_box in self.right_eq_inputs]
settings = HearingAidSettings(
left_eq, right_eq, left_amp, right_amp, tone, tone,
conv, conv, anr, anr, amp, balance, own_voice
)
threading.Thread(target=send_hearing_aid_settings, args=(self.att_manager, settings)).start()
def reset_settings(self):
logging.debug("Resetting settings to defaults")
self.amp_slider.setValue(0)
self.balance_slider.setValue(0)
self.tone_slider.setValue(0)
self.anr_slider.setValue(50)
self.conv_checkbox.setChecked(False)
self.own_voice_slider.setValue(50)
self.on_value_changed()
def closeEvent(self, event):
logging.info("Closing app")
self.att_manager.disconnect()
event.accept()
if __name__ == "__main__":
mac = None
if len(sys.argv) != 2:
logging.error("Usage: python hearing-aid-adjustments.py <MAC_ADDRESS>")
sys.exit(1)
mac = sys.argv[1]
mac_regex = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$'
import re
if not re.match(mac_regex, mac):
logging.error("Invalid MAC address format")
sys.exit(1)
logging.info(f"Starting app")
app = QApplication(sys.argv)
def quit_app(signum, frame):
app.quit()
signal.signal(signal.SIGINT, quit_app)
window = HearingAidApp(mac)
window.show()
sys.exit(app.exec_())

BIN
linux/imgs/main-app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -42,6 +42,7 @@ class AirPodsTrayApp : public QObject {
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
Q_PROPERTY(bool hearingAidEnabled READ hearingAidEnabled WRITE setHearingAidEnabled NOTIFY hearingAidEnabledChanged)
public:
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
@@ -96,6 +97,15 @@ public:
QBluetoothDeviceInfo device(address, "", 0);
if (isAirPodsDevice(device)) {
connectToDevice(device);
// On startup after reboot, activate A2DP profile for already connected AirPods
QTimer::singleShot(2000, this, [this, address]()
{
QString formattedAddress = address.toString().replace(":", "_");
mediaController->setConnectedDeviceMacAddress(formattedAddress);
mediaController->activateA2dpProfile();
LOG_INFO("A2DP profile activation attempted for AirPods found on startup");
});
return;
}
}
@@ -122,6 +132,7 @@ public:
bool hideOnStart() const { return m_hideOnStart; }
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
QString phoneMacStatus() const { return m_phoneMacStatus; }
bool hearingAidEnabled() const { return m_deviceInfo->hearingAidEnabled(); }
private:
bool debugMode;
@@ -358,6 +369,16 @@ public slots:
emit phoneMacStatusChanged();
}
void setHearingAidEnabled(bool enabled)
{
LOG_INFO("Setting hearing aid to: " << (enabled ? "enabled" : "disabled"));
QByteArray packet = enabled ? AirPodsPackets::HearingAid::ENABLED
: AirPodsPackets::HearingAid::DISABLED;
writePacketToSocket(packet, "Hearing aid packet written: ");
m_deviceInfo->setHearingAidEnabled(enabled);
}
bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
{
if (socket && socket->isOpen())
@@ -397,6 +418,23 @@ public slots:
{
LOG_INFO("System is waking up, starting ble scan");
m_bleManager->startScan();
// Check if AirPods are already connected and activate A2DP profile
if (areAirpodsConnected() && m_deviceInfo && !m_deviceInfo->bluetoothAddress().isEmpty())
{
LOG_INFO("AirPods already connected after wake-up, re-activating A2DP profile");
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
// Always activate A2DP profile after system wake since the profile might have been lost
QTimer::singleShot(1000, this, [this]()
{
mediaController->activateA2dpProfile();
LOG_INFO("A2DP profile activation attempted after system wake-up");
});
}
// Also check for already connected devices via BlueZ
monitor->checkAlreadyConnectedDevices();
}
private slots:
@@ -445,6 +483,20 @@ private slots:
{
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
connectToDevice(device);
// After system reboot, AirPods might be connected but A2DP profile not active
// Attempt to activate A2DP profile after a delay to ensure connection is established
QTimer::singleShot(2000, this, [this, address]()
{
if (!address.isEmpty())
{
QString formattedAddress = address;
formattedAddress = formattedAddress.replace(":", "_");
mediaController->setConnectedDeviceMacAddress(formattedAddress);
mediaController->activateA2dpProfile();
LOG_INFO("A2DP profile activation attempted for newly connected device");
}
});
}
void onDeviceDisconnected(const QBluetoothAddress &address)
@@ -642,6 +694,14 @@ private slots:
LOG_INFO("Conversational awareness state received: " << m_deviceInfo->conversationalAwareness());
}
}
// Hearing Aid state
else if (data.startsWith(AirPodsPackets::HearingAid::HEADER)) {
if (auto result = AirPodsPackets::HearingAid::parseState(data))
{
m_deviceInfo->setHearingAidEnabled(result.value());
LOG_INFO("Hearing aid state received: " << m_deviceInfo->hearingAidEnabled());
}
}
// Noise Control Mode
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
{
@@ -904,6 +964,7 @@ signals:
void retryAttemptsChanged(int attempts);
void oneBudANCModeChanged(bool enabled);
void phoneMacStatusChanged();
void hearingAidEnabledChanged(bool enabled);
private:
QBluetoothSocket *socket = nullptr;
@@ -927,7 +988,7 @@ int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QSharedMemory sharedMemory;
sharedMemory.setKey("TcpServer-Key");
sharedMemory.setKey("TcpServer-Key2");
// Check if app is already open
if(sharedMemory.create(1) == false)

View File

@@ -2,14 +2,21 @@
#include "logger.h"
#include "eardetection.hpp"
#include "playerstatuswatcher.h"
#include "pulseaudiocontroller.h"
#include <QDebug>
#include <QProcess>
#include <QThread>
#include <QRegularExpression>
#include <QDBusConnection>
#include <QDBusConnectionInterface>
MediaController::MediaController(QObject *parent) : QObject(parent) {
m_pulseAudio = new PulseAudioController(this);
if (!m_pulseAudio->initialize())
{
LOG_ERROR("Failed to initialize PulseAudio controller");
}
}
void MediaController::handleEarDetection(EarDetection *earDetection)
@@ -46,6 +53,7 @@ void MediaController::handleEarDetection(EarDetection *earDetection)
{
if (getCurrentMediaState() == Playing)
{
LOG_DEBUG("Pausing playback for ear detection");
pause();
}
}
@@ -57,7 +65,7 @@ void MediaController::handleEarDetection(EarDetection *earDetection)
activateA2dpProfile();
// Resume if conditions are met and we previously paused
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
if (shouldResume && !pausedByAppServices.isEmpty() && isActiveOutputDeviceAirPods())
{
play();
}
@@ -87,12 +95,9 @@ void MediaController::followMediaChanges() {
}
bool MediaController::isActiveOutputDeviceAirPods() {
QProcess process;
process.start("pactl", QStringList() << "get-default-sink");
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Default sink: " << output);
return output.contains(connectedDeviceMacAddress);
QString defaultSink = m_pulseAudio->getDefaultSink();
LOG_DEBUG("Default sink: " << defaultSink);
return defaultSink.contains(connectedDeviceMacAddress);
}
void MediaController::handleConversationalAwareness(const QByteArray &data) {
@@ -102,51 +107,112 @@ void MediaController::handleConversationalAwareness(const QByteArray &data) {
if (lowered) {
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
QProcess process;
process.start("pactl", QStringList()
<< "get-sink-volume" << "@DEFAULT_SINK@");
process.waitForFinished();
QString output = process.readAllStandardOutput();
QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%");
QRegularExpressionMatch match = re.match(output);
if (match.hasMatch()) {
LOG_DEBUG("Matched: " << match.captured(1));
initialVolume = match.captured(1).toInt();
} else {
LOG_ERROR("Failed to parse initial volume from output: " << output);
QString defaultSink = m_pulseAudio->getDefaultSink();
initialVolume = m_pulseAudio->getSinkVolume(defaultSink);
if (initialVolume == -1) {
LOG_ERROR("Failed to get initial volume");
return;
}
LOG_DEBUG("Initial volume: " << initialVolume << "%");
}
QString defaultSink = m_pulseAudio->getDefaultSink();
int targetVolume = initialVolume * 0.20;
if (m_pulseAudio->setSinkVolume(defaultSink, targetVolume)) {
LOG_INFO("Volume lowered to 0.20 of initial which is " << targetVolume << "%");
} else {
LOG_ERROR("Failed to lower volume");
}
QProcess::execute(
"pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@"
<< QString::number(initialVolume * 0.20) + "%");
LOG_INFO("Volume lowered to 0.20 of initial which is "
<< initialVolume * 0.20 << "%");
} else {
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
QProcess::execute("pactl", QStringList()
<< "set-sink-volume" << "@DEFAULT_SINK@"
<< QString::number(initialVolume) + "%");
LOG_INFO("Volume restored to " << initialVolume << "%");
QString defaultSink = m_pulseAudio->getDefaultSink();
if (m_pulseAudio->setSinkVolume(defaultSink, initialVolume)) {
LOG_INFO("Volume restored to " << initialVolume << "%");
} else {
LOG_ERROR("Failed to restore volume");
}
initialVolume = -1;
}
}
}
bool MediaController::isA2dpProfileAvailable() {
if (m_deviceOutputName.isEmpty()) {
return false;
}
return m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink-sbc_xq") ||
m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink-sbc") ||
m_pulseAudio->isProfileAvailable(m_deviceOutputName, "a2dp-sink");
}
QString MediaController::getPreferredA2dpProfile() {
if (m_deviceOutputName.isEmpty()) {
return QString();
}
if (!m_cachedA2dpProfile.isEmpty() &&
m_pulseAudio->isProfileAvailable(m_deviceOutputName, m_cachedA2dpProfile)) {
return m_cachedA2dpProfile;
}
QStringList profiles = {"a2dp-sink-sbc_xq", "a2dp-sink-sbc", "a2dp-sink"};
for (const QString &profile : profiles) {
if (m_pulseAudio->isProfileAvailable(m_deviceOutputName, profile)) {
LOG_INFO("Selected best available A2DP profile: " << profile);
m_cachedA2dpProfile = profile;
return profile;
}
}
m_cachedA2dpProfile.clear();
return QString();
}
bool MediaController::restartWirePlumber() {
LOG_INFO("Restarting WirePlumber to rediscover A2DP profiles");
int result = QProcess::execute("systemctl", QStringList() << "--user" << "restart" << "wireplumber");
if (result == 0) {
LOG_INFO("WirePlumber restarted successfully");
QThread::sleep(2);
return true;
} else {
LOG_ERROR("Failed to restart WirePlumber. Do you use wireplumber?");
return false;
}
}
void MediaController::activateA2dpProfile() {
if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) {
LOG_WARN("Connected device MAC address or output name is empty, cannot activate A2DP profile");
return;
}
LOG_INFO("Activating A2DP profile for AirPods");
int result = QProcess::execute(
"pactl", QStringList()
<< "set-card-profile"
<< m_deviceOutputName << "a2dp-sink");
if (result != 0) {
LOG_ERROR("Failed to activate A2DP profile");
if (!isA2dpProfileAvailable()) {
LOG_WARN("A2DP profile not available, attempting to restart WirePlumber");
if (restartWirePlumber()) {
m_deviceOutputName = getAudioDeviceName();
if (!isA2dpProfileAvailable()) {
LOG_ERROR("A2DP profile still not available after WirePlumber restart");
return;
}
} else {
LOG_ERROR("Could not restart WirePlumber, A2DP profile unavailable");
return;
}
}
QString preferredProfile = getPreferredA2dpProfile();
if (preferredProfile.isEmpty()) {
LOG_ERROR("No suitable A2DP profile found");
return;
}
LOG_INFO("Activating A2DP profile for AirPods: " << preferredProfile);
if (!m_pulseAudio->setCardProfile(m_deviceOutputName, preferredProfile)) {
LOG_ERROR("Failed to activate A2DP profile: " << preferredProfile);
}
LOG_INFO("A2DP profile activated successfully");
}
void MediaController::removeAudioOutputDevice() {
@@ -156,11 +222,7 @@ void MediaController::removeAudioOutputDevice() {
}
LOG_INFO("Removing AirPods as audio output device");
int result = QProcess::execute(
"pactl", QStringList()
<< "set-card-profile"
<< m_deviceOutputName << "off");
if (result != 0) {
if (!m_pulseAudio->setCardProfile(m_deviceOutputName, "off")) {
LOG_ERROR("Failed to remove AirPods as audio output device");
}
}
@@ -168,6 +230,7 @@ void MediaController::removeAudioOutputDevice() {
void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
connectedDeviceMacAddress = macAddress;
m_deviceOutputName = getAudioDeviceName();
m_cachedA2dpProfile.clear();
LOG_INFO("Device output name set to: " << m_deviceOutputName);
}
@@ -187,31 +250,53 @@ MediaController::MediaState MediaController::getCurrentMediaState() const
return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
}
bool MediaController::sendMediaPlayerCommand(const QString &method)
QStringList MediaController::getPlayingMediaPlayers()
{
// Connect to the session bus
QStringList playingServices;
QDBusConnection bus = QDBusConnection::sessionBus();
// Find available MPRIS-compatible media players
QStringList services = bus.interface()->registeredServiceNames().value();
QStringList mprisServices;
for (const QString &service : services)
{
if (service.startsWith("org.mpris.MediaPlayer2."))
if (!service.startsWith("org.mpris.MediaPlayer2."))
{
mprisServices << service;
continue;
}
QDBusInterface playerInterface(
service,
"/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
bus);
if (!playerInterface.isValid())
{
continue;
}
QVariant playbackStatus = playerInterface.property("PlaybackStatus");
if (playbackStatus.isValid() && playbackStatus.toString() == "Playing")
{
playingServices << service;
LOG_DEBUG("Found playing service: " << service);
}
}
if (mprisServices.isEmpty())
return playingServices;
}
void MediaController::play()
{
if (pausedByAppServices.isEmpty())
{
LOG_ERROR("No MPRIS-compatible media players found on DBus");
return false;
LOG_INFO("No services to resume");
return;
}
bool success = false;
// Try each MPRIS service until one succeeds
for (const QString &service : mprisServices)
QDBusConnection bus = QDBusConnection::sessionBus();
int resumedCount = 0;
for (const QString &service : pausedByAppServices)
{
QDBusInterface playerInterface(
service,
@@ -221,63 +306,87 @@ bool MediaController::sendMediaPlayerCommand(const QString &method)
if (!playerInterface.isValid())
{
LOG_ERROR("Invalid DBus interface for service: " << service);
LOG_WARN("Service no longer available: " << service);
continue;
}
// Send the Play or Pause command
if (method == "Play" || method == "Pause")
QDBusReply<void> reply = playerInterface.call("Play");
if (reply.isValid())
{
QDBusReply<void> reply = playerInterface.call(method);
if (reply.isValid())
{
LOG_INFO("Successfully sent " << method << " to " << service);
success = true;
break; // Exit after the first successful command
}
else
{
LOG_ERROR("Failed to send " << method << " to " << service
<< ": " << reply.error().message());
}
LOG_INFO("Resumed playback for: " << service);
resumedCount++;
}
else
{
LOG_ERROR("Unsupported method: " << method);
return false;
LOG_ERROR("Failed to resume " << service << ": " << reply.error().message());
}
}
if (!success)
if (resumedCount > 0)
{
LOG_ERROR("No media player responded successfully to " << method);
}
return success;
}
void MediaController::play()
{
if (sendMediaPlayerCommand("Play"))
{
LOG_INFO("Resumed playback via DBus");
wasPausedByApp = false;
LOG_INFO("Resumed " << resumedCount << " media player(s) via DBus");
pausedByAppServices.clear();
}
else
{
LOG_ERROR("Failed to resume playback via DBus");
LOG_ERROR("Failed to resume any media players via DBus");
}
}
void MediaController::pause()
{
if (sendMediaPlayerCommand("Pause"))
QDBusConnection bus = QDBusConnection::sessionBus();
QStringList services = bus.interface()->registeredServiceNames().value();
pausedByAppServices.clear();
int pausedCount = 0;
for (const QString &service : services)
{
LOG_INFO("Paused playback via DBus");
wasPausedByApp = true;
if (!service.startsWith("org.mpris.MediaPlayer2."))
{
continue;
}
QDBusInterface playerInterface(
service,
"/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
bus);
if (!playerInterface.isValid())
{
continue;
}
QVariant playbackStatus = playerInterface.property("PlaybackStatus");
LOG_DEBUG("PlaybackStatus for " << service << ": " << playbackStatus.toString());
if (!playbackStatus.isValid() || playbackStatus.toString() != "Playing")
{
continue;
}
QDBusReply<void> reply = playerInterface.call("Pause");
LOG_DEBUG("Pausing service: " << service);
if (reply.isValid())
{
LOG_INFO("Paused playback for: " << service);
pausedByAppServices << service;
pausedCount++;
}
else
{
LOG_ERROR("Failed to pause " << service << ": " << reply.error().message());
}
}
if (pausedCount > 0)
{
LOG_INFO("Paused " << pausedCount << " media player(s) via DBus");
}
else
{
LOG_ERROR("Failed to pause playback via DBus");
LOG_INFO("No playing media players found to pause");
}
}
@@ -288,40 +397,9 @@ QString MediaController::getAudioDeviceName()
{
if (connectedDeviceMacAddress.isEmpty()) { return QString(); }
// Set up QProcess to run pactl directly
QProcess process;
process.start("pactl", QStringList() << "list" << "cards" << "short");
if (!process.waitForFinished(3000)) // Timeout after 3 seconds
{
LOG_ERROR("pactl command failed or timed out: " << process.errorString());
return QString();
QString cardName = m_pulseAudio->getCardNameForDevice(connectedDeviceMacAddress);
if (cardName.isEmpty()) {
LOG_ERROR("No matching Bluetooth card found for MAC address: " << connectedDeviceMacAddress);
}
// Check for execution errors
if (process.exitCode() != 0)
{
LOG_ERROR("pactl exited with error code: " << process.exitCode());
return QString();
}
// Read and parse the command output
QString output = process.readAllStandardOutput();
QStringList lines = output.split("\n", Qt::SkipEmptyParts);
// Iterate through each line to find a matching Bluetooth sink
for (const QString &line : lines)
{
QStringList fields = line.split("\t", Qt::SkipEmptyParts);
if (fields.size() < 2) { continue; }
QString sinkName = fields[1].trimmed();
if (sinkName.startsWith("bluez") && sinkName.contains(connectedDeviceMacAddress))
{
return sinkName;
}
}
// No matching sink found
LOG_ERROR("No matching Bluetooth sink found for MAC address: " << connectedDeviceMacAddress);
return QString();
return cardName;
}

View File

@@ -2,6 +2,7 @@
#define MEDIACONTROLLER_H
#include <QObject>
#include "pulseaudiocontroller.h"
class QProcess;
class EarDetection;
@@ -37,6 +38,9 @@ public:
void activateA2dpProfile();
void removeAudioOutputDevice();
void setConnectedDeviceMacAddress(const QString &macAddress);
bool isA2dpProfileAvailable();
QString getPreferredA2dpProfile();
bool restartWirePlumber();
void setEarDetectionBehavior(EarDetectionBehavior behavior);
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
@@ -51,14 +55,16 @@ Q_SIGNALS:
private:
MediaState mediaStateFromPlayerctlOutput(const QString &output) const;
QString getAudioDeviceName();
bool sendMediaPlayerCommand(const QString &method);
QStringList getPlayingMediaPlayers();
bool wasPausedByApp = false;
QStringList pausedByAppServices;
int initialVolume = -1;
QString connectedDeviceMacAddress;
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
QString m_deviceOutputName;
PlayerStatusWatcher *playerStatusWatcher = nullptr;
PulseAudioController *m_pulseAudio = nullptr;
QString m_cachedA2dpProfile;
};
#endif // MEDIACONTROLLER_H

View File

@@ -0,0 +1,297 @@
#include "pulseaudiocontroller.h"
#include "logger.h"
#include <QThread>
PulseAudioController::PulseAudioController(QObject *parent)
: QObject(parent), m_mainloop(nullptr), m_context(nullptr), m_initialized(false)
{
}
PulseAudioController::~PulseAudioController()
{
if (m_context)
{
pa_context_disconnect(m_context);
pa_context_unref(m_context);
}
if (m_mainloop)
{
pa_threaded_mainloop_stop(m_mainloop);
pa_threaded_mainloop_free(m_mainloop);
}
}
bool PulseAudioController::initialize()
{
m_mainloop = pa_threaded_mainloop_new();
if (!m_mainloop)
{
LOG_ERROR("Failed to create PulseAudio mainloop");
return false;
}
pa_mainloop_api *api = pa_threaded_mainloop_get_api(m_mainloop);
m_context = pa_context_new(api, "LibrePods");
if (!m_context)
{
LOG_ERROR("Failed to create PulseAudio context");
return false;
}
pa_context_set_state_callback(m_context, contextStateCallback, this);
if (pa_threaded_mainloop_start(m_mainloop) < 0)
{
LOG_ERROR("Failed to start PulseAudio mainloop");
return false;
}
pa_threaded_mainloop_lock(m_mainloop);
if (pa_context_connect(m_context, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0)
{
LOG_ERROR("Failed to connect to PulseAudio");
pa_threaded_mainloop_unlock(m_mainloop);
return false;
}
// Wait for context to be ready
while (pa_context_get_state(m_context) != PA_CONTEXT_READY)
{
if (!PA_CONTEXT_IS_GOOD(pa_context_get_state(m_context)))
{
LOG_ERROR("PulseAudio context failed");
pa_threaded_mainloop_unlock(m_mainloop);
return false;
}
pa_threaded_mainloop_wait(m_mainloop);
}
pa_threaded_mainloop_unlock(m_mainloop);
m_initialized = true;
LOG_INFO("PulseAudio controller initialized");
return true;
}
void PulseAudioController::contextStateCallback(pa_context *c, void *userdata)
{
PulseAudioController *controller = static_cast<PulseAudioController*>(userdata);
pa_threaded_mainloop_signal(controller->m_mainloop, 0);
}
QString PulseAudioController::getDefaultSink()
{
if (!m_initialized) return QString();
struct CallbackData {
QString sinkName;
pa_threaded_mainloop *mainloop;
} data;
data.mainloop = m_mainloop;
auto callback = [](pa_context *c, const pa_server_info *info, void *userdata) {
CallbackData *d = static_cast<CallbackData*>(userdata);
if (info && info->default_sink_name)
{
d->sinkName = QString::fromUtf8(info->default_sink_name);
}
pa_threaded_mainloop_signal(d->mainloop, 0);
};
pa_threaded_mainloop_lock(m_mainloop);
pa_operation *op = pa_context_get_server_info(m_context, callback, &data);
if (op)
{
waitForOperation(op);
pa_operation_unref(op);
}
pa_threaded_mainloop_unlock(m_mainloop);
return data.sinkName;
}
int PulseAudioController::getSinkVolume(const QString &sinkName)
{
if (!m_initialized) return -1;
struct CallbackData {
int volume;
QString targetSink;
pa_threaded_mainloop *mainloop;
} data;
data.volume = -1;
data.targetSink = sinkName;
data.mainloop = m_mainloop;
auto callback = [](pa_context *c, const pa_sink_info *info, int eol, void *userdata) {
CallbackData *d = static_cast<CallbackData*>(userdata);
if (eol > 0)
{
pa_threaded_mainloop_signal(d->mainloop, 0);
return;
}
if (info && QString::fromUtf8(info->name) == d->targetSink)
{
d->volume = (pa_cvolume_avg(&info->volume) * 100) / PA_VOLUME_NORM;
pa_threaded_mainloop_signal(d->mainloop, 0);
}
};
pa_threaded_mainloop_lock(m_mainloop);
pa_operation *op = pa_context_get_sink_info_by_name(m_context, sinkName.toUtf8().constData(), callback, &data);
if (op)
{
waitForOperation(op);
pa_operation_unref(op);
}
pa_threaded_mainloop_unlock(m_mainloop);
return data.volume;
}
bool PulseAudioController::setSinkVolume(const QString &sinkName, int volumePercent)
{
if (!m_initialized) return false;
pa_cvolume volume;
pa_cvolume_set(&volume, 2, (volumePercent * PA_VOLUME_NORM) / 100);
pa_threaded_mainloop_lock(m_mainloop);
auto successCallback = [](pa_context *c, int success, void *userdata) {
pa_threaded_mainloop *mainloop = static_cast<pa_threaded_mainloop*>(userdata);
pa_threaded_mainloop_signal(mainloop, 0);
};
pa_operation *op = pa_context_set_sink_volume_by_name(m_context, sinkName.toUtf8().constData(), &volume, successCallback, m_mainloop);
bool success = waitForOperation(op);
if (op) pa_operation_unref(op);
pa_threaded_mainloop_unlock(m_mainloop);
return success;
}
bool PulseAudioController::setCardProfile(const QString &cardName, const QString &profileName)
{
if (!m_initialized) return false;
pa_threaded_mainloop_lock(m_mainloop);
auto successCallback = [](pa_context *c, int success, void *userdata) {
pa_threaded_mainloop *mainloop = static_cast<pa_threaded_mainloop*>(userdata);
pa_threaded_mainloop_signal(mainloop, 0);
};
pa_operation *op = pa_context_set_card_profile_by_name(m_context,
cardName.toUtf8().constData(),
profileName.toUtf8().constData(),
successCallback, m_mainloop);
bool success = waitForOperation(op);
if (op) pa_operation_unref(op);
pa_threaded_mainloop_unlock(m_mainloop);
return success;
}
QString PulseAudioController::getCardNameForDevice(const QString &macAddress)
{
if (!m_initialized) return QString();
struct CallbackData {
QString cardName;
QString targetMac;
pa_threaded_mainloop *mainloop;
} data;
data.targetMac = macAddress;
data.mainloop = m_mainloop;
auto callback = [](pa_context *c, const pa_card_info *info, int eol, void *userdata) {
CallbackData *d = static_cast<CallbackData*>(userdata);
if (eol > 0)
{
pa_threaded_mainloop_signal(d->mainloop, 0);
return;
}
if (info)
{
QString name = QString::fromUtf8(info->name);
if (name.startsWith("bluez") && name.contains(d->targetMac))
{
d->cardName = name;
pa_threaded_mainloop_signal(d->mainloop, 0);
}
}
};
pa_threaded_mainloop_lock(m_mainloop);
pa_operation *op = pa_context_get_card_info_list(m_context, callback, &data);
if (op)
{
waitForOperation(op);
pa_operation_unref(op);
}
pa_threaded_mainloop_unlock(m_mainloop);
return data.cardName;
}
bool PulseAudioController::isProfileAvailable(const QString &cardName, const QString &profileName)
{
if (!m_initialized) return false;
struct CallbackData {
bool available;
QString targetCard;
QString targetProfile;
pa_threaded_mainloop *mainloop;
} data;
data.available = false;
data.targetCard = cardName;
data.targetProfile = profileName;
data.mainloop = m_mainloop;
auto callback = [](pa_context *c, const pa_card_info *info, int eol, void *userdata) {
CallbackData *d = static_cast<CallbackData*>(userdata);
if (eol > 0)
{
pa_threaded_mainloop_signal(d->mainloop, 0);
return;
}
if (info && QString::fromUtf8(info->name) == d->targetCard)
{
for (uint32_t i = 0; i < info->n_profiles; i++)
{
if (QString::fromUtf8(info->profiles[i].name) == d->targetProfile)
{
d->available = true;
break;
}
}
pa_threaded_mainloop_signal(d->mainloop, 0);
}
};
pa_threaded_mainloop_lock(m_mainloop);
pa_operation *op = pa_context_get_card_info_by_name(m_context, cardName.toUtf8().constData(), callback, &data);
if (op)
{
waitForOperation(op);
pa_operation_unref(op);
}
pa_threaded_mainloop_unlock(m_mainloop);
return data.available;
}
bool PulseAudioController::waitForOperation(pa_operation *op)
{
if (!op) return false;
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING)
{
pa_threaded_mainloop_wait(m_mainloop);
}
return pa_operation_get_state(op) == PA_OPERATION_DONE;
}

View File

@@ -0,0 +1,37 @@
#ifndef PULSEAUDIOCONTROLLER_H
#define PULSEAUDIOCONTROLLER_H
#include <QString>
#include <QObject>
#include <pulse/pulseaudio.h>
class PulseAudioController : public QObject
{
Q_OBJECT
public:
explicit PulseAudioController(QObject *parent = nullptr);
~PulseAudioController();
bool initialize();
QString getDefaultSink();
int getSinkVolume(const QString &sinkName);
bool setSinkVolume(const QString &sinkName, int volumePercent);
bool setCardProfile(const QString &cardName, const QString &profileName);
QString getCardNameForDevice(const QString &macAddress);
bool isProfileAvailable(const QString &cardName, const QString &profileName);
private:
pa_threaded_mainloop *m_mainloop;
pa_context *m_context;
bool m_initialized;
static void contextStateCallback(pa_context *c, void *userdata);
static void sinkInfoCallback(pa_context *c, const pa_sink_info *info, int eol, void *userdata);
static void cardInfoCallback(pa_context *c, const pa_card_info *info, int eol, void *userdata);
static void serverInfoCallback(pa_context *c, const pa_server_info *info, void *userdata);
bool waitForOperation(pa_operation *op);
};
#endif // PULSEAUDIOCONTROLLER_H

12
shell.nix Normal file
View File

@@ -0,0 +1,12 @@
(import (
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
nodeName = lock.nodes.root.inputs.flake-compat;
in
fetchTarball {
url =
lock.nodes.${nodeName}.locked.url
or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.${nodeName}.locked.rev}.tar.gz";
sha256 = lock.nodes.${nodeName}.locked.narHash;
}
) { src = ./.; }).shellNix