Compare commits
79 Commits
multi-devi
...
linux/rust
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c852b726de | ||
|
|
902b12a227 | ||
|
|
6ded8ff3ff | ||
|
|
376c54247b | ||
|
|
e2d17b8bae | ||
|
|
6f0323ee6b | ||
|
|
4737cbfc2c | ||
|
|
093554da07 | ||
|
|
a01e16792a | ||
|
|
253ed65afc | ||
|
|
b1f3856d0f | ||
|
|
99a689a0f8 | ||
|
|
0c9a2bd743 | ||
|
|
6585cf648c | ||
|
|
99e5b71676 | ||
|
|
47f02136cd | ||
|
|
29c422528a | ||
|
|
9d10eed85d | ||
|
|
9f7f4347ec | ||
|
|
b7cd80edac | ||
|
|
2049431817 | ||
|
|
23cf5728e9 | ||
|
|
381b09725b | ||
|
|
3853e8ec9a | ||
|
|
bf6630dbd1 | ||
|
|
a2cda688d4 | ||
|
|
934df2419a | ||
|
|
64470c4d34 | ||
|
|
fa8bc11060 | ||
|
|
c5a824c384 | ||
|
|
99beeb5907 | ||
|
|
17b545481e | ||
|
|
320964e9d7 | ||
|
|
51b3d4692a | ||
|
|
3a0cc2e7f4 | ||
|
|
26cee5c8a5 | ||
|
|
a007d9cd80 | ||
|
|
b47469803b | ||
|
|
925c930073 | ||
|
|
ccee82026d | ||
|
|
99940b98ae | ||
|
|
fec226336c | ||
|
|
e5c2419ef6 | ||
|
|
221680ff32 | ||
|
|
9da4c938ed | ||
|
|
7dd029faa6 | ||
|
|
ae5a701257 | ||
|
|
0f04290fba | ||
|
|
b0561e96df | ||
|
|
c0ae061cc7 | ||
|
|
cf2a242d7c | ||
|
|
43bfbda21e | ||
|
|
34f60699b8 | ||
|
|
a38ebd213b | ||
|
|
142e0c5e37 | ||
|
|
a8963ecef5 | ||
|
|
53f5626914 | ||
|
|
89782d9b7c | ||
|
|
283f841855 | ||
|
|
4c8ebe27bc | ||
|
|
bc5bcd81b3 | ||
|
|
b72680a1c4 | ||
|
|
816048f1f8 | ||
|
|
d6b98b70e8 | ||
|
|
f62a57c888 | ||
|
|
f7bb1ce6fc | ||
|
|
9bf3e64b20 | ||
|
|
217455fecb | ||
|
|
84d5fa466e | ||
|
|
8eb6eba049 | ||
|
|
e437572355 | ||
|
|
f062eb43b3 | ||
|
|
28ffd217d6 | ||
|
|
b5d1ad0dd5 | ||
|
|
a6d6e0e13c | ||
|
|
fe774d565d | ||
|
|
120681541f | ||
|
|
784b7a2cfa | ||
|
|
d7a96c9fc5 |
2
.github/workflows/ci-android.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
|
paths:
|
||||||
|
- 'android/**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
87
.github/workflows/ci-linux-rust.yml
vendored
Normal 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 }}
|
||||||
9
.github/workflows/ci-linux.yml
vendored
@@ -1,9 +1,10 @@
|
|||||||
name: Build LibrePods Linux
|
name: Build LibrePods Linux
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches:
|
# push:
|
||||||
- '*'
|
# branches:
|
||||||
|
# - '*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux:
|
build-linux:
|
||||||
@@ -33,4 +34,4 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: librepods-linux
|
name: librepods-linux
|
||||||
path: linux/build/librepods
|
path: linux/build/librepods
|
||||||
|
|||||||
7
.gitignore
vendored
@@ -659,3 +659,10 @@ obj/
|
|||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
# End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux
|
||||||
linux/.qmlls.ini
|
linux/.qmlls.ini
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|||||||
47
README.md
@@ -78,10 +78,12 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
|||||||
#### Root Requirement
|
#### Root Requirement
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!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.
|
> 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
|
## Bluetooth DID (Device Identification) Hook
|
||||||
|
|
||||||
Turns out, if you change the manufacturerid to that of Apple, you get access to several special features!
|
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.
|
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`.
|
||||||
> 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.
|
|
||||||
|
|
||||||
#### A few notes
|
#### 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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
[](https://star-history.com/#kavishdevar/librepods&Date)
|
[](https://star-history.com/#kavishdevar/librepods&Date)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ android {
|
|||||||
minSdk = 28
|
minSdk = 28
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 8
|
versionCode = 8
|
||||||
versionName = "0.2.0-beta.1"
|
versionName = "0.2.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
LaunchedEffect(currentStep) {
|
LaunchedEffect(currentStep) {
|
||||||
instructionText = when (currentStep) {
|
instructionText = when (currentStep) {
|
||||||
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
|
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."
|
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."
|
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."
|
4 -> "Log collection complete! You can now save or share the logs."
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class AirPodsPro3: AirPodsBase(
|
|||||||
capabilities = setOf(
|
capabilities = setOf(
|
||||||
Capability.LISTENING_MODE,
|
Capability.LISTENING_MODE,
|
||||||
Capability.CONVERSATION_AWARENESS,
|
Capability.CONVERSATION_AWARENESS,
|
||||||
|
Capability.HEAD_GESTURES,
|
||||||
Capability.STEM_CONFIG,
|
Capability.STEM_CONFIG,
|
||||||
Capability.LOUD_SOUND_REDUCTION,
|
Capability.LOUD_SOUND_REDUCTION,
|
||||||
Capability.PPE,
|
Capability.PPE,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
@@ -1,77 +1,207 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
|
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
|
||||||
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
|
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
|
||||||
<string name="accessibility">辅助功能</string>
|
<string name="accessibility">辅助功能</string>
|
||||||
<string name="tone_volume">提示音音量</string>
|
<string name="tone_volume">提示音音量</string>
|
||||||
<string name="audio">音频</string>
|
<string name="tone_volume_description">调整 AirPods 播放的提示音音量。</string>
|
||||||
<string name="adaptive_audio">自适应音频</string>
|
<string name="audio">音频</string>
|
||||||
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
|
<string name="adaptive_audio">自适应音频</string>
|
||||||
<string name="buds">耳机</string>
|
<string name="customize_adaptive_audio">自定义自适应音频</string>
|
||||||
<string name="case_alt">充电盒</string>
|
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
|
||||||
<string name="test">测试</string>
|
<string name="buds">耳机</string>
|
||||||
<string name="name">名称</string>
|
<string name="case_alt">充电盒</string>
|
||||||
<string name="noise_control">噪音控制</string>
|
<string name="test">测试</string>
|
||||||
<string name="off">关闭</string>
|
<string name="name">名称</string>
|
||||||
<string name="transparency">通透模式</string>
|
<string name="noise_control">听音模式</string>
|
||||||
<string name="adaptive">自适应</string>
|
<string name="off">关闭</string>
|
||||||
<string name="noise_cancellation">主动降噪</string>
|
<string name="transparency">通透模式</string>
|
||||||
<string name="press_and_hold_airpods">按住 AirPods</string>
|
<string name="adaptive">自适应</string>
|
||||||
<string name="head_gestures">头部手势</string>
|
<string name="noise_cancellation">主动降噪</string>
|
||||||
<string name="left">左耳</string>
|
<string name="press_and_hold_airpods">按住 AirPods</string>
|
||||||
<string name="right">右耳</string>
|
<string name="press_and_hold_noise_control_description">按住耳机柄以在选定的听音模式之间循环切换。</string>
|
||||||
<string name="conversational_awareness">对话感知</string>
|
<string name="head_gestures">头部手势</string>
|
||||||
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
|
<string name="left">左耳</string>
|
||||||
<string name="personalized_volume">个性化音量</string>
|
<string name="right">右耳</string>
|
||||||
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
|
<string name="conversational_awareness">对话感知</string>
|
||||||
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
|
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
|
||||||
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
|
<string name="personalized_volume">个性化音量</string>
|
||||||
<string name="volume_control">音量控制</string>
|
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
|
||||||
<string name="volume_control_description">通过在 AirPods Pro 柄部传感器上下滑动调节音量。</string>
|
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
|
||||||
<string name="airpods_not_connected">AirPods 未连接</string>
|
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
|
||||||
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。如果卡在此处,请先关闭应用再重新打开。\n(不要强制结束应用!)</string>
|
<string name="volume_control">音量控制</string>
|
||||||
<string name="back">返回</string>
|
<string name="volume_control_description">通过在 AirPods Pro柄部传感器上下滑动调节音量。</string>
|
||||||
<string name="app_settings">自定义</string>
|
<string name="airpods_not_connected">AirPods 未连接</string>
|
||||||
<string name="relative_conversational_awareness_volume">相对音量</string>
|
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</string>
|
||||||
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
|
<string name="back">返回</string>
|
||||||
<string name="conversational_awareness_pause_music">暂停音乐</string>
|
<string name="app_settings">自定义</string>
|
||||||
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停。</string>
|
<string name="relative_conversational_awareness_volume">相对音量</string>
|
||||||
<string name="appwidget_text">示例</string>
|
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
|
||||||
<string name="add_widget">添加小组件</string>
|
<string name="conversational_awareness_pause_music">暂停音乐</string>
|
||||||
<string name="noise_control_widget_description">在主屏幕直接控制噪音模式。</string>
|
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停。</string>
|
||||||
<string name="island_connected_text">已连接</string>
|
<string name="appwidget_text">示例</string>
|
||||||
<string name="island_connected_remote_text">已连接到 Linux</string>
|
<string name="add_widget">添加小组件</string>
|
||||||
<string name="island_taking_over_text">已切换到手机</string>
|
<string name="noise_control_widget_description">直接在主屏幕上控制听音模式。</string>
|
||||||
<string name="island_moved_to_remote_text">已切换到 Linux</string>
|
<string name="island_connected_text">已连接</string>
|
||||||
<string name="head_tracking">头部追踪</string>
|
<string name="island_connected_remote_text">已连接到 Linux</string>
|
||||||
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
|
<string name="island_taking_over_text">已连接</string>
|
||||||
<string name="general_settings_header">通用</string>
|
<string name="island_moved_to_remote_text">已切换到 Linux</string>
|
||||||
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
|
<string name="island_moved_to_other_device_text">已切换到 %1$s</string>
|
||||||
<string name="qs_click_behavior_dialog_desc">点击时显示噪音控制对话框。</string>
|
<string name="island_moved_to_other_device_reversed_text">从通知中重新连接</string>
|
||||||
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
|
<string name="head_tracking">头部追踪</string>
|
||||||
<string name="developer_options_header">开发者</string>
|
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
|
||||||
<string name="more_settings_title">打开 AirPods 设置</string>
|
<string name="general_settings_header">通用</string>
|
||||||
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
|
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
|
||||||
<string name="ear_detection">自动入耳检测</string>
|
<string name="qs_click_behavior_dialog_desc">点击时显示听音模式控制对话框。</string>
|
||||||
<string name="auto_play">自动播放</string>
|
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
|
||||||
<string name="auto_pause">自动暂停</string>
|
<string name="developer_options_header">开发者</string>
|
||||||
<string name="troubleshooting">故障排查</string>
|
<string name="more_settings_title">打开 AirPods 设置</string>
|
||||||
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
|
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
|
||||||
<string name="collect_logs">收集日志</string>
|
<string name="ear_detection">自动入耳检测</string>
|
||||||
<string name="saved_logs">已保存的日志</string>
|
<string name="auto_play">自动播放</string>
|
||||||
<string name="no_logs_found">未找到保存的日志</string>
|
<string name="auto_pause">自动暂停</string>
|
||||||
<string name="takeover_header">自动连接偏好</string>
|
<string name="troubleshooting">故障排查</string>
|
||||||
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
|
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
|
||||||
<string name="takeover_disconnected">未连接</string>
|
<string name="collect_logs">收集日志</string>
|
||||||
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
|
<string name="saved_logs">已保存的日志</string>
|
||||||
<string name="takeover_idle">空闲</string>
|
<string name="no_logs_found">未找到保存的日志</string>
|
||||||
<string name="takeover_idle_desc">某设备已连接 AirPods,但未播放媒体或通话</string>
|
<string name="takeover_header">自动连接偏好</string>
|
||||||
<string name="takeover_music">正在播放媒体</string>
|
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
|
||||||
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
|
<string name="takeover_disconnected">未连接</string>
|
||||||
<string name="takeover_call">正在通话</string>
|
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
|
||||||
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
|
<string name="takeover_idle">空闲</string>
|
||||||
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods:</string>
|
<string name="takeover_idle_desc">某设备已连接 AirPods,但未播放媒体或通话</string>
|
||||||
<string name="takeover_ringing_call">来电中</string>
|
<string name="takeover_music">正在播放媒体</string>
|
||||||
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
|
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
|
||||||
<string name="takeover_media_start">开始播放媒体</string>
|
<string name="takeover_call">正在通话</string>
|
||||||
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
|
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
|
||||||
</resources>
|
<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>
|
||||||
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 222 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 115 KiB |
12
default.nix
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,7 @@
|
|||||||
|
**/*.flatpak
|
||||||
|
repo
|
||||||
|
dist
|
||||||
|
build-dir
|
||||||
|
vendor
|
||||||
|
.cargo
|
||||||
|
.flatpak-builder
|
||||||
5667
linux-rust/Cargo.lock
generated
Normal file
31
linux-rust/Cargo.toml
Normal 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
@@ -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}"
|
||||||
BIN
linux-rust/assets/font/DejaVuSans.ttf
Normal file
BIN
linux-rust/assets/font/sf_pro.otf
Normal file
BIN
linux-rust/assets/icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
6
linux-rust/assets/me.kavishdevar.librepods.desktop
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Name=LibrePods
|
||||||
|
Exec=librepods
|
||||||
|
Icon=me.kavishdevar.librepods
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;
|
||||||
23
linux-rust/flatpak/me.kavishdevar.librepods.metainfo.xml
Normal 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'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>
|
||||||
43
linux-rust/flatpak/me.kavishdevar.librepods.yaml
Normal 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
|
||||||
|
|
||||||
1201
linux-rust/src/bluetooth/aacp.rs
Normal file
275
linux-rust/src/bluetooth/att.rs
Normal 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.");
|
||||||
|
}
|
||||||
49
linux-rust/src/bluetooth/discovery.rs
Normal 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",
|
||||||
|
)))
|
||||||
|
}
|
||||||
379
linux-rust/src/bluetooth/le.rs
Normal 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(())
|
||||||
|
}
|
||||||
48
linux-rust/src/bluetooth/managers.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
5
linux-rust/src/bluetooth/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
pub mod aacp;
|
||||||
|
pub mod att;
|
||||||
|
pub(crate) mod discovery;
|
||||||
|
pub mod le;
|
||||||
|
pub mod managers;
|
||||||
354
linux-rust/src/devices/airpods.rs
Normal 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,
|
||||||
|
}
|
||||||
152
linux-rust/src/devices/enums.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
linux-rust/src/devices/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod airpods;
|
||||||
|
pub mod enums;
|
||||||
|
pub(crate) mod nothing;
|
||||||
179
linux-rust/src/devices/nothing.rs
Normal 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
@@ -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))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
1350
linux-rust/src/media_controller.rs
Normal file
533
linux-rust/src/ui/airpods.rs
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
11
linux-rust/src/ui/messages.rs
Normal 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
@@ -0,0 +1,5 @@
|
|||||||
|
mod airpods;
|
||||||
|
pub mod messages;
|
||||||
|
mod nothing;
|
||||||
|
pub mod tray;
|
||||||
|
pub mod window;
|
||||||
188
linux-rust/src/ui/nothing.rs
Normal 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
@@ -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
136
linux-rust/src/utils.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,20 @@ project(linux VERSION 0.1 LANGUAGES CXX)
|
|||||||
|
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
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(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
|
qt_add_executable(librepods
|
||||||
main.cpp
|
main.cpp
|
||||||
logger.h
|
logger.h
|
||||||
media/mediacontroller.cpp
|
media/mediacontroller.cpp
|
||||||
media/mediacontroller.h
|
media/mediacontroller.h
|
||||||
|
media/pulseaudiocontroller.cpp
|
||||||
|
media/pulseaudiocontroller.h
|
||||||
airpods_packets.h
|
airpods_packets.h
|
||||||
trayiconmanager.cpp
|
trayiconmanager.cpp
|
||||||
trayiconmanager.h
|
trayiconmanager.h
|
||||||
@@ -66,9 +70,11 @@ qt_add_resources(librepods "resources"
|
|||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(librepods
|
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)
|
include(GNUInstallDirs)
|
||||||
install(TARGETS librepods
|
install(TARGETS librepods
|
||||||
BUNDLE DESTINATION .
|
BUNDLE DESTINATION .
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ ApplicationWindow {
|
|||||||
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
|
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
|
||||||
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
|
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Switch {
|
||||||
|
visible: airPodsTrayApp.airpodsConnected
|
||||||
|
text: "Hearing Aid"
|
||||||
|
checked: airPodsTrayApp.deviceInfo.hearingAidEnabled
|
||||||
|
onCheckedChanged: airPodsTrayApp.setHearingAidEnabled(checked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RoundButton {
|
RoundButton {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ A native Linux application to control your AirPods, with support for:
|
|||||||
- Conversational Awareness
|
- Conversational Awareness
|
||||||
- Battery monitoring
|
- Battery monitoring
|
||||||
- Auto play/pause on ear detection
|
- 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
|
## Prerequisites
|
||||||
|
|
||||||
@@ -40,13 +43,7 @@ A native Linux application to control your AirPods, with support for:
|
|||||||
```
|
```
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Set the `PHONE_MAC_ADDRESS` environment variable to your phone's Bluetooth MAC address by running the following:
|
1. Build the application:
|
||||||
|
|
||||||
```bash
|
|
||||||
export PHONE_MAC_ADDRESS="XX:XX:XX:XX:XX:XX" # Replace with your phone's MAC
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Build the application:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir build
|
mkdir build
|
||||||
@@ -55,12 +52,46 @@ A native Linux application to control your AirPods, with support for:
|
|||||||
make -j $(nproc)
|
make -j $(nproc)
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Run the application:
|
2. Run the application:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./librepods
|
./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
|
## Usage
|
||||||
|
|
||||||
- Left-click the tray icon to view battery status
|
- 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
|
- Switch between noise control modes
|
||||||
- View battery levels
|
- View battery levels
|
||||||
- Control playback
|
- 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.
|
||||||
@@ -107,6 +107,34 @@ namespace AirPodsPackets
|
|||||||
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
|
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
|
// Allow Off Option
|
||||||
namespace AllowOffOption
|
namespace AllowOffOption
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class DeviceInfo : public QObject
|
|||||||
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
|
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
|
||||||
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
|
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
|
||||||
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
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(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
||||||
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
|
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
|
||||||
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
|
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; }
|
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
|
||||||
void setAdaptiveNoiseLevel(int level)
|
void setAdaptiveNoiseLevel(int level)
|
||||||
{
|
{
|
||||||
@@ -159,6 +170,7 @@ public:
|
|||||||
setNoiseControlMode(NoiseControlMode::Off);
|
setNoiseControlMode(NoiseControlMode::Off);
|
||||||
setBluetoothAddress("");
|
setBluetoothAddress("");
|
||||||
getEarDetection()->reset();
|
getEarDetection()->reset();
|
||||||
|
setHearingAidEnabled(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void saveToSettings(QSettings &settings)
|
void saveToSettings(QSettings &settings)
|
||||||
@@ -168,6 +180,7 @@ public:
|
|||||||
settings.setValue("model", static_cast<int>(model()));
|
settings.setValue("model", static_cast<int>(model()));
|
||||||
settings.setValue("magicAccIRK", magicAccIRK());
|
settings.setValue("magicAccIRK", magicAccIRK());
|
||||||
settings.setValue("magicAccEncKey", magicAccEncKey());
|
settings.setValue("magicAccEncKey", magicAccEncKey());
|
||||||
|
settings.setValue("hearingAidEnabled", hearingAidEnabled());
|
||||||
settings.endGroup();
|
settings.endGroup();
|
||||||
}
|
}
|
||||||
void loadFromSettings(const QSettings &settings)
|
void loadFromSettings(const QSettings &settings)
|
||||||
@@ -176,6 +189,7 @@ public:
|
|||||||
setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
|
setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
|
||||||
setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
|
setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
|
||||||
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
|
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
|
||||||
|
setHearingAidEnabled(settings.value("DeviceInfo/hearingAidEnabled", false).toBool());
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateBatteryStatus()
|
void updateBatteryStatus()
|
||||||
@@ -191,6 +205,7 @@ signals:
|
|||||||
void noiseControlModeChanged(NoiseControlMode mode);
|
void noiseControlModeChanged(NoiseControlMode mode);
|
||||||
void noiseControlModeChangedInt(int mode);
|
void noiseControlModeChangedInt(int mode);
|
||||||
void conversationalAwarenessChanged(bool enabled);
|
void conversationalAwarenessChanged(bool enabled);
|
||||||
|
void hearingAidEnabledChanged(bool enabled);
|
||||||
void adaptiveNoiseLevelChanged(int level);
|
void adaptiveNoiseLevelChanged(int level);
|
||||||
void deviceNameChanged(const QString &name);
|
void deviceNameChanged(const QString &name);
|
||||||
void primaryChanged();
|
void primaryChanged();
|
||||||
@@ -202,6 +217,7 @@ private:
|
|||||||
QString m_batteryStatus;
|
QString m_batteryStatus;
|
||||||
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
|
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
|
||||||
bool m_conversationalAwareness = false;
|
bool m_conversationalAwareness = false;
|
||||||
|
bool m_hearingAidEnabled = false;
|
||||||
int m_adaptiveNoiseLevel = 50;
|
int m_adaptiveNoiseLevel = 50;
|
||||||
QString m_deviceName;
|
QString m_deviceName;
|
||||||
Battery *m_battery;
|
Battery *m_battery;
|
||||||
|
|||||||
480
linux/hearing-aid-adjustments.py
Normal 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
|
After Width: | Height: | Size: 27 KiB |
@@ -42,6 +42,7 @@ class AirPodsTrayApp : public QObject {
|
|||||||
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
||||||
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
|
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
|
||||||
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
|
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
|
||||||
|
Q_PROPERTY(bool hearingAidEnabled READ hearingAidEnabled WRITE setHearingAidEnabled NOTIFY hearingAidEnabledChanged)
|
||||||
|
|
||||||
public:
|
public:
|
||||||
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
||||||
@@ -96,6 +97,15 @@ public:
|
|||||||
QBluetoothDeviceInfo device(address, "", 0);
|
QBluetoothDeviceInfo device(address, "", 0);
|
||||||
if (isAirPodsDevice(device)) {
|
if (isAirPodsDevice(device)) {
|
||||||
connectToDevice(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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +132,7 @@ public:
|
|||||||
bool hideOnStart() const { return m_hideOnStart; }
|
bool hideOnStart() const { return m_hideOnStart; }
|
||||||
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
|
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
|
||||||
QString phoneMacStatus() const { return m_phoneMacStatus; }
|
QString phoneMacStatus() const { return m_phoneMacStatus; }
|
||||||
|
bool hearingAidEnabled() const { return m_deviceInfo->hearingAidEnabled(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool debugMode;
|
bool debugMode;
|
||||||
@@ -358,6 +369,16 @@ public slots:
|
|||||||
emit phoneMacStatusChanged();
|
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)
|
bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
|
||||||
{
|
{
|
||||||
if (socket && socket->isOpen())
|
if (socket && socket->isOpen())
|
||||||
@@ -397,6 +418,23 @@ public slots:
|
|||||||
{
|
{
|
||||||
LOG_INFO("System is waking up, starting ble scan");
|
LOG_INFO("System is waking up, starting ble scan");
|
||||||
m_bleManager->startScan();
|
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:
|
private slots:
|
||||||
@@ -445,6 +483,20 @@ private slots:
|
|||||||
{
|
{
|
||||||
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
|
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
|
||||||
connectToDevice(device);
|
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)
|
void onDeviceDisconnected(const QBluetoothAddress &address)
|
||||||
@@ -642,6 +694,14 @@ private slots:
|
|||||||
LOG_INFO("Conversational awareness state received: " << m_deviceInfo->conversationalAwareness());
|
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
|
// Noise Control Mode
|
||||||
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
|
||||||
{
|
{
|
||||||
@@ -904,6 +964,7 @@ signals:
|
|||||||
void retryAttemptsChanged(int attempts);
|
void retryAttemptsChanged(int attempts);
|
||||||
void oneBudANCModeChanged(bool enabled);
|
void oneBudANCModeChanged(bool enabled);
|
||||||
void phoneMacStatusChanged();
|
void phoneMacStatusChanged();
|
||||||
|
void hearingAidEnabledChanged(bool enabled);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
QBluetoothSocket *socket = nullptr;
|
QBluetoothSocket *socket = nullptr;
|
||||||
@@ -927,7 +988,7 @@ int main(int argc, char *argv[]) {
|
|||||||
QApplication app(argc, argv);
|
QApplication app(argc, argv);
|
||||||
|
|
||||||
QSharedMemory sharedMemory;
|
QSharedMemory sharedMemory;
|
||||||
sharedMemory.setKey("TcpServer-Key");
|
sharedMemory.setKey("TcpServer-Key2");
|
||||||
|
|
||||||
// Check if app is already open
|
// Check if app is already open
|
||||||
if(sharedMemory.create(1) == false)
|
if(sharedMemory.create(1) == false)
|
||||||
|
|||||||
@@ -2,14 +2,21 @@
|
|||||||
#include "logger.h"
|
#include "logger.h"
|
||||||
#include "eardetection.hpp"
|
#include "eardetection.hpp"
|
||||||
#include "playerstatuswatcher.h"
|
#include "playerstatuswatcher.h"
|
||||||
|
#include "pulseaudiocontroller.h"
|
||||||
|
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QProcess>
|
#include <QProcess>
|
||||||
|
#include <QThread>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
#include <QDBusConnection>
|
#include <QDBusConnection>
|
||||||
#include <QDBusConnectionInterface>
|
#include <QDBusConnectionInterface>
|
||||||
|
|
||||||
MediaController::MediaController(QObject *parent) : QObject(parent) {
|
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)
|
void MediaController::handleEarDetection(EarDetection *earDetection)
|
||||||
@@ -46,6 +53,7 @@ void MediaController::handleEarDetection(EarDetection *earDetection)
|
|||||||
{
|
{
|
||||||
if (getCurrentMediaState() == Playing)
|
if (getCurrentMediaState() == Playing)
|
||||||
{
|
{
|
||||||
|
LOG_DEBUG("Pausing playback for ear detection");
|
||||||
pause();
|
pause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +65,7 @@ void MediaController::handleEarDetection(EarDetection *earDetection)
|
|||||||
activateA2dpProfile();
|
activateA2dpProfile();
|
||||||
|
|
||||||
// Resume if conditions are met and we previously paused
|
// Resume if conditions are met and we previously paused
|
||||||
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
|
if (shouldResume && !pausedByAppServices.isEmpty() && isActiveOutputDeviceAirPods())
|
||||||
{
|
{
|
||||||
play();
|
play();
|
||||||
}
|
}
|
||||||
@@ -87,12 +95,9 @@ void MediaController::followMediaChanges() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool MediaController::isActiveOutputDeviceAirPods() {
|
bool MediaController::isActiveOutputDeviceAirPods() {
|
||||||
QProcess process;
|
QString defaultSink = m_pulseAudio->getDefaultSink();
|
||||||
process.start("pactl", QStringList() << "get-default-sink");
|
LOG_DEBUG("Default sink: " << defaultSink);
|
||||||
process.waitForFinished();
|
return defaultSink.contains(connectedDeviceMacAddress);
|
||||||
QString output = process.readAllStandardOutput().trimmed();
|
|
||||||
LOG_DEBUG("Default sink: " << output);
|
|
||||||
return output.contains(connectedDeviceMacAddress);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void MediaController::handleConversationalAwareness(const QByteArray &data) {
|
void MediaController::handleConversationalAwareness(const QByteArray &data) {
|
||||||
@@ -102,51 +107,112 @@ void MediaController::handleConversationalAwareness(const QByteArray &data) {
|
|||||||
|
|
||||||
if (lowered) {
|
if (lowered) {
|
||||||
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
|
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
|
||||||
QProcess process;
|
QString defaultSink = m_pulseAudio->getDefaultSink();
|
||||||
process.start("pactl", QStringList()
|
initialVolume = m_pulseAudio->getSinkVolume(defaultSink);
|
||||||
<< "get-sink-volume" << "@DEFAULT_SINK@");
|
if (initialVolume == -1) {
|
||||||
process.waitForFinished();
|
LOG_ERROR("Failed to get initial volume");
|
||||||
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);
|
|
||||||
return;
|
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 {
|
} else {
|
||||||
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
|
if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
|
||||||
QProcess::execute("pactl", QStringList()
|
QString defaultSink = m_pulseAudio->getDefaultSink();
|
||||||
<< "set-sink-volume" << "@DEFAULT_SINK@"
|
if (m_pulseAudio->setSinkVolume(defaultSink, initialVolume)) {
|
||||||
<< QString::number(initialVolume) + "%");
|
LOG_INFO("Volume restored to " << initialVolume << "%");
|
||||||
LOG_INFO("Volume restored to " << initialVolume << "%");
|
} else {
|
||||||
|
LOG_ERROR("Failed to restore volume");
|
||||||
|
}
|
||||||
initialVolume = -1;
|
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() {
|
void MediaController::activateA2dpProfile() {
|
||||||
if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) {
|
if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) {
|
||||||
LOG_WARN("Connected device MAC address or output name is empty, cannot activate A2DP profile");
|
LOG_WARN("Connected device MAC address or output name is empty, cannot activate A2DP profile");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Activating A2DP profile for AirPods");
|
if (!isA2dpProfileAvailable()) {
|
||||||
int result = QProcess::execute(
|
LOG_WARN("A2DP profile not available, attempting to restart WirePlumber");
|
||||||
"pactl", QStringList()
|
if (restartWirePlumber()) {
|
||||||
<< "set-card-profile"
|
m_deviceOutputName = getAudioDeviceName();
|
||||||
<< m_deviceOutputName << "a2dp-sink");
|
if (!isA2dpProfileAvailable()) {
|
||||||
if (result != 0) {
|
LOG_ERROR("A2DP profile still not available after WirePlumber restart");
|
||||||
LOG_ERROR("Failed to activate A2DP profile");
|
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() {
|
void MediaController::removeAudioOutputDevice() {
|
||||||
@@ -156,11 +222,7 @@ void MediaController::removeAudioOutputDevice() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("Removing AirPods as audio output device");
|
LOG_INFO("Removing AirPods as audio output device");
|
||||||
int result = QProcess::execute(
|
if (!m_pulseAudio->setCardProfile(m_deviceOutputName, "off")) {
|
||||||
"pactl", QStringList()
|
|
||||||
<< "set-card-profile"
|
|
||||||
<< m_deviceOutputName << "off");
|
|
||||||
if (result != 0) {
|
|
||||||
LOG_ERROR("Failed to remove AirPods as audio output device");
|
LOG_ERROR("Failed to remove AirPods as audio output device");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +230,7 @@ void MediaController::removeAudioOutputDevice() {
|
|||||||
void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
|
void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
|
||||||
connectedDeviceMacAddress = macAddress;
|
connectedDeviceMacAddress = macAddress;
|
||||||
m_deviceOutputName = getAudioDeviceName();
|
m_deviceOutputName = getAudioDeviceName();
|
||||||
|
m_cachedA2dpProfile.clear();
|
||||||
LOG_INFO("Device output name set to: " << m_deviceOutputName);
|
LOG_INFO("Device output name set to: " << m_deviceOutputName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,31 +250,53 @@ MediaController::MediaState MediaController::getCurrentMediaState() const
|
|||||||
return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
|
return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool MediaController::sendMediaPlayerCommand(const QString &method)
|
QStringList MediaController::getPlayingMediaPlayers()
|
||||||
{
|
{
|
||||||
// Connect to the session bus
|
QStringList playingServices;
|
||||||
QDBusConnection bus = QDBusConnection::sessionBus();
|
QDBusConnection bus = QDBusConnection::sessionBus();
|
||||||
|
|
||||||
// Find available MPRIS-compatible media players
|
|
||||||
QStringList services = bus.interface()->registeredServiceNames().value();
|
QStringList services = bus.interface()->registeredServiceNames().value();
|
||||||
QStringList mprisServices;
|
|
||||||
for (const QString &service : services)
|
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");
|
LOG_INFO("No services to resume");
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool success = false;
|
QDBusConnection bus = QDBusConnection::sessionBus();
|
||||||
// Try each MPRIS service until one succeeds
|
int resumedCount = 0;
|
||||||
for (const QString &service : mprisServices)
|
|
||||||
|
for (const QString &service : pausedByAppServices)
|
||||||
{
|
{
|
||||||
QDBusInterface playerInterface(
|
QDBusInterface playerInterface(
|
||||||
service,
|
service,
|
||||||
@@ -221,63 +306,87 @@ bool MediaController::sendMediaPlayerCommand(const QString &method)
|
|||||||
|
|
||||||
if (!playerInterface.isValid())
|
if (!playerInterface.isValid())
|
||||||
{
|
{
|
||||||
LOG_ERROR("Invalid DBus interface for service: " << service);
|
LOG_WARN("Service no longer available: " << service);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the Play or Pause command
|
QDBusReply<void> reply = playerInterface.call("Play");
|
||||||
if (method == "Play" || method == "Pause")
|
if (reply.isValid())
|
||||||
{
|
{
|
||||||
QDBusReply<void> reply = playerInterface.call(method);
|
LOG_INFO("Resumed playback for: " << service);
|
||||||
if (reply.isValid())
|
resumedCount++;
|
||||||
{
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_ERROR("Unsupported method: " << method);
|
LOG_ERROR("Failed to resume " << service << ": " << reply.error().message());
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!success)
|
if (resumedCount > 0)
|
||||||
{
|
{
|
||||||
LOG_ERROR("No media player responded successfully to " << method);
|
LOG_INFO("Resumed " << resumedCount << " media player(s) via DBus");
|
||||||
}
|
pausedByAppServices.clear();
|
||||||
return success;
|
|
||||||
}
|
|
||||||
|
|
||||||
void MediaController::play()
|
|
||||||
{
|
|
||||||
if (sendMediaPlayerCommand("Play"))
|
|
||||||
{
|
|
||||||
LOG_INFO("Resumed playback via DBus");
|
|
||||||
wasPausedByApp = false;
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
LOG_ERROR("Failed to resume playback via DBus");
|
LOG_ERROR("Failed to resume any media players via DBus");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MediaController::pause()
|
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");
|
if (!service.startsWith("org.mpris.MediaPlayer2."))
|
||||||
wasPausedByApp = true;
|
{
|
||||||
|
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
|
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(); }
|
if (connectedDeviceMacAddress.isEmpty()) { return QString(); }
|
||||||
|
|
||||||
// Set up QProcess to run pactl directly
|
QString cardName = m_pulseAudio->getCardNameForDevice(connectedDeviceMacAddress);
|
||||||
QProcess process;
|
if (cardName.isEmpty()) {
|
||||||
process.start("pactl", QStringList() << "list" << "cards" << "short");
|
LOG_ERROR("No matching Bluetooth card found for MAC address: " << connectedDeviceMacAddress);
|
||||||
if (!process.waitForFinished(3000)) // Timeout after 3 seconds
|
|
||||||
{
|
|
||||||
LOG_ERROR("pactl command failed or timed out: " << process.errorString());
|
|
||||||
return QString();
|
|
||||||
}
|
}
|
||||||
|
return cardName;
|
||||||
// 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();
|
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
#define MEDIACONTROLLER_H
|
#define MEDIACONTROLLER_H
|
||||||
|
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include "pulseaudiocontroller.h"
|
||||||
|
|
||||||
class QProcess;
|
class QProcess;
|
||||||
class EarDetection;
|
class EarDetection;
|
||||||
@@ -37,6 +38,9 @@ public:
|
|||||||
void activateA2dpProfile();
|
void activateA2dpProfile();
|
||||||
void removeAudioOutputDevice();
|
void removeAudioOutputDevice();
|
||||||
void setConnectedDeviceMacAddress(const QString &macAddress);
|
void setConnectedDeviceMacAddress(const QString &macAddress);
|
||||||
|
bool isA2dpProfileAvailable();
|
||||||
|
QString getPreferredA2dpProfile();
|
||||||
|
bool restartWirePlumber();
|
||||||
|
|
||||||
void setEarDetectionBehavior(EarDetectionBehavior behavior);
|
void setEarDetectionBehavior(EarDetectionBehavior behavior);
|
||||||
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
|
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
|
||||||
@@ -51,14 +55,16 @@ Q_SIGNALS:
|
|||||||
private:
|
private:
|
||||||
MediaState mediaStateFromPlayerctlOutput(const QString &output) const;
|
MediaState mediaStateFromPlayerctlOutput(const QString &output) const;
|
||||||
QString getAudioDeviceName();
|
QString getAudioDeviceName();
|
||||||
bool sendMediaPlayerCommand(const QString &method);
|
QStringList getPlayingMediaPlayers();
|
||||||
|
|
||||||
bool wasPausedByApp = false;
|
QStringList pausedByAppServices;
|
||||||
int initialVolume = -1;
|
int initialVolume = -1;
|
||||||
QString connectedDeviceMacAddress;
|
QString connectedDeviceMacAddress;
|
||||||
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
|
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
|
||||||
QString m_deviceOutputName;
|
QString m_deviceOutputName;
|
||||||
PlayerStatusWatcher *playerStatusWatcher = nullptr;
|
PlayerStatusWatcher *playerStatusWatcher = nullptr;
|
||||||
|
PulseAudioController *m_pulseAudio = nullptr;
|
||||||
|
QString m_cachedA2dpProfile;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // MEDIACONTROLLER_H
|
#endif // MEDIACONTROLLER_H
|
||||||
297
linux/media/pulseaudiocontroller.cpp
Normal 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;
|
||||||
|
}
|
||||||
37
linux/media/pulseaudiocontroller.h
Normal 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
@@ -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
|
||||||