Compare commits

..

15 Commits

Author SHA1 Message Date
Kavish Devar
672e65ad36 ci: rename linux ci 2026-05-15 23:39:42 +05:30
Kavish Devar
4a9a2e7b64 linux-rust: handle disconnections and fix rename 2026-04-20 15:42:37 +05:30
Jack
a78a8bb6be fix(linux-rust): Retry L2CAP send on ENOTCONN during BlueZ Handshake (#495)
This fix allows the send thread to make 10 attempts to send it's data.
This gives access to seeing the battery status
2026-04-20 13:56:56 +05:30
Kavish Devar
2f1208ca87 linux-rust: bump dependencies and clean up media_controller 2026-04-06 13:54:33 +05:30
Andrey
a0cdbb2842 linux-rust(feat): add stem press track control and headless mode support (#469)
* feat: add stem press track control and headless mode support

- Parse STEM_PRESS packets and emit AACPEvent::StemPress with press type and bud side
- Enable double/triple tap detection on init via StemConfig control command (0x06)
- Double press → next track, triple press → previous track via MPRIS D-Bus
- Add next_track() and previous_track() to MediaController
- Add --no-tray flag for headless operation without a GUI
- Replace unwrap() on ui_tx.send() calls with graceful warn! logging

(vibecoded)

* Update main.rs

* feat: make stem press track control optional with GUI toggle

Add a --no-stem-control CLI flag and a toggle in the Settings tab for
environments that handle AirPods AVRCP commands natively (e.g. via
BlueZ/PipeWire). The feature remains enabled by default.

- Load stem_control from app settings JSON on startup; --no-stem-control
  overrides it to false regardless of the saved value
- Share an Arc<AtomicBool> between the async backend and the GUI thread;
  AirPodsDevice holds the Arc directly so the event loop reads the live
  value on every stem press — toggle takes effect immediately without
  reconnecting
- Persist stem_control to settings JSON alongside theme and tray_text_mode
- Add a "Controls" section to the Settings tab with a toggler labelled
  "Stem press track control", with a subtitle explaining the AVRCP
  conflict scenario
- Fix StemConfig bitmask comment to clarify it uses a separate numbering
  scheme from the StemPressType event enum values (0x05–0x08)
2026-03-31 09:32:20 +05:30
Ryder
decf070f9f fix(linux-rust): Clear stale tray battery on device switch (#447) 2026-02-10 08:49:03 +05:30
John Sanchirico
7ab8bd7240 fix(linux-rust): Prevent pulseaudio panic from crashing app (#443)
Wrap set_card_profile_sync call in std::panic::catch_unwind to handle
panics in libpulse-binding when deactivating A2DP profile.
2026-02-02 08:38:01 +05:30
doprz
c852b726de fix(linux-rust): format and fix syntax error 2025-12-14 10:23:32 +05:30
doprz
902b12a227 fix(clippy): fix cargo clippy warnings 2025-12-14 10:23:32 +05:30
doprz
6ded8ff3ff feat(nix): add comprehensive Nix flake for linux-rust 2025-12-14 10:23:32 +05:30
doprz
376c54247b feat(nix): add comprehensive Nix flake for linux-rust 2025-12-14 10:23:32 +05:30
Sophia
e2d17b8bae linux-rust: add nix flake (#371)
* build: 👷 add nix flake

* chore: 🙈 add nix's result to gitignore
2025-12-12 00:37:58 +05:30
Kavish Devar
6f0323ee6b linux-rust: parse single battery of AirPods Max 2025-11-23 00:35:09 +05:30
Kavish Devar
4737cbfc2c linux-rust: add battery to window and add option for text in tray 2025-11-20 18:56:17 +05:30
Kavish Devar
093554da07 linux-rust: add v0.1.0 to flatpak manifest 2025-11-10 14:26:26 +05:30
30 changed files with 4824 additions and 3518 deletions

1
.envrc Normal file
View File

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

View File

@@ -1,4 +1,4 @@
name: Linux Build & Release
name: Linux CI (Rust)
on:
push:

7
.gitignore vendored
View File

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

12
default.nix Normal file
View File

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

143
flake.lock generated Normal file
View File

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

141
flake.nix Normal file
View File

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

3321
linux-rust/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,24 @@ version = "0.1.0"
edition = "2024"
[dependencies]
tokio = {version = "1.47.1", features = ["full"]}
tokio = {version = "1.50.0", 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"
env_logger = {version = "0.11.10", features = ["auto-color"]}
uuid = "1.23.0"
log = "0.4.29"
dbus = "0.9.10"
hex = "0.4.3"
iced = { version = "0.13.1", features = ["tokio", "image"] }
iced = { version = "0.14.0", features = ["tokio", "image"] }
libpulse-binding = "2.30.1"
ksni = "0.3.1"
image = "0.25.8"
imageproc = "0.25.0"
imageproc = "0.26.1"
ab_glyph = "0.2.32"
clap = { version = "4.5.50", features = ["derive"] }
clap = { version = "4.6.0", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
aes = "0.8.4"
futures = "0.3.31"
aes = "0.9.0-rc.4"
futures = "0.3.32"
[profile.release]
opt-level = "s"

Binary file not shown.

View File

@@ -10,7 +10,6 @@ command: librepods
finish-args:
- --socket=wayland
- --socket=fallback-x11
- --share=ipc
- --socket=pulseaudio
- --system-talk-name=org.bluez
- --allow=bluetooth
@@ -31,14 +30,14 @@ modules:
env:
CARGO_NET_OFFLINE: 'true'
build-commands:
- cargo build --frozen --offline --verbose
- install -Dm755 target/debug/librepods ${FLATPAK_DEST}/bin/librepods
- 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: 287d31cbf6a1e1e8ab6a8ef02d4d9b31aa299652086b39c548a37b7fc8e31ba7
# 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

View File

@@ -1,16 +1,19 @@
use bluer::{l2cap::{SocketAddr, Socket, SeqPacket}, Address, AddressType, Result, Error};
use std::time::Duration;
use log::{info, error, debug};
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::{sleep, Instant};
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json;
use crate::devices::airpods::AirPodsInformation;
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType};
use crate::utils::get_devices_path;
use bluer::{
Address, AddressType, Error, Result,
l2cap::{SeqPacket, Socket, SocketAddr},
};
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use serde_json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, mpsc};
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
const PSM: u16 = 0x1001;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -25,7 +28,7 @@ pub mod opcodes {
pub const EAR_DETECTION: u8 = 0x06;
pub const CONVERSATION_AWARENESS: u8 = 0x4B;
pub const INFORMATION: u8 = 0x1D;
pub const RENAME: u8 = 0x1E;
pub const RENAME: u8 = 0x1A;
pub const PROXIMITY_KEYS_REQ: u8 = 0x30;
pub const PROXIMITY_KEYS_RSP: u8 = 0x31;
pub const STEM_PRESS: u8 = 0x19;
@@ -197,6 +200,18 @@ pub enum StemPressType {
LongPress = 0x08,
}
impl StemPressType {
fn from_u8(value: u8) -> Option<Self> {
match value {
0x05 => Some(Self::SinglePress),
0x06 => Some(Self::DoublePress),
0x07 => Some(Self::TriplePress),
0x08 => Some(Self::LongPress),
_ => None,
}
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StemPressBudType {
@@ -204,6 +219,16 @@ pub enum StemPressBudType {
Right = 0x02,
}
impl StemPressBudType {
fn from_u8(value: u8) -> Option<Self> {
match value {
0x01 => Some(Self::Left),
0x02 => Some(Self::Right),
_ => None,
}
}
}
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioSourceType {
@@ -215,9 +240,10 @@ pub enum AudioSourceType {
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatteryComponent {
Headphone = 1,
Left = 4,
Right = 2,
Case = 8
Case = 8,
}
#[repr(u8)]
@@ -225,7 +251,7 @@ pub enum BatteryComponent {
pub enum BatteryStatus {
Charging = 1,
NotCharging = 2,
Disconnected = 4
Disconnected = 4,
}
#[repr(u8)]
@@ -234,7 +260,7 @@ pub enum EarDetectionStatus {
InEar = 0x00,
OutOfEar = 0x01,
InCase = 0x02,
Disconnected = 0x03
Disconnected = 0x03,
}
impl AudioSourceType {
@@ -279,6 +305,7 @@ pub enum AACPEvent {
AudioSource(AudioSource),
ConnectedDevices(Vec<ConnectedDevice>, Vec<ConnectedDevice>),
OwnershipToFalseRequest,
StemPress(StemPressType, StemPressBudType),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -290,7 +317,8 @@ pub struct AirPodsLEKeys {
pub struct AACPManagerState {
pub sender: Option<mpsc::Sender<Vec<u8>>>,
pub control_command_status_list: Vec<ControlCommandStatus>,
pub control_command_subscribers: HashMap<ControlCommandIdentifiers, Vec<mpsc::UnboundedSender<Vec<u8>>>>,
pub control_command_subscribers:
HashMap<ControlCommandIdentifiers, Vec<mpsc::UnboundedSender<Vec<u8>>>>,
pub owns: bool,
pub old_connected_devices: Vec<ConnectedDevice>,
pub connected_devices: Vec<ConnectedDevice>,
@@ -306,8 +334,7 @@ pub struct AACPManagerState {
impl AACPManagerState {
fn new() -> Self {
let devices: HashMap<String, DeviceData> =
std::fs::read_to_string(get_devices_path())
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();
@@ -361,7 +388,8 @@ impl AACPManager {
}
};
let seq_packet = match tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await {
let seq_packet =
match tokio::time::timeout(CONNECT_TIMEOUT, socket.connect(target_sa)).await {
Ok(Ok(s)) => Arc::new(s),
Ok(Err(e)) => {
error!("L2CAP connect failed: {}", e);
@@ -380,7 +408,8 @@ impl AACPManager {
Ok(peer) if peer.cid != 0 => break,
Ok(_) => { /* still waiting */ }
Err(e) => {
if e.raw_os_error() == Some(107) { // ENOTCONN
if e.raw_os_error() == Some(107) {
// ENOTCONN
error!("Peer has disconnected during connection setup.");
return;
}
@@ -438,18 +467,39 @@ impl AACPManager {
state.event_tx = Some(tx);
}
pub async fn subscribe_to_control_command(&self, identifier: ControlCommandIdentifiers, tx: mpsc::UnboundedSender<Vec<u8>>) {
pub async fn subscribe_to_control_command(
&self,
identifier: ControlCommandIdentifiers,
tx: mpsc::UnboundedSender<Vec<u8>>,
) {
let mut state = self.state.lock().await;
state.control_command_subscribers.entry(identifier).or_default().push(tx);
state
.control_command_subscribers
.entry(identifier)
.or_default()
.push(tx);
// send initial value if available
if let Some(status) = state.control_command_status_list.iter().find(|s| s.identifier == identifier) {
let _ = state.control_command_subscribers.get(&identifier).unwrap().last().unwrap().send(status.value.clone());
if let Some(status) = state
.control_command_status_list
.iter()
.find(|s| s.identifier == identifier)
{
let _ = state
.control_command_subscribers
.get(&identifier)
.unwrap()
.last()
.unwrap()
.send(status.value.clone());
}
}
pub async fn receive_packet(&self, packet: &[u8]) {
if !packet.starts_with(&HEADER_BYTES) {
debug!("Received packet does not start with expected header: {}", hex::encode(packet));
debug!(
"Received packet does not start with expected header: {}",
hex::encode(packet)
);
return;
}
if packet.len() < 5 {
@@ -468,7 +518,10 @@ impl AACPManager {
}
let count = payload[2] as usize;
if payload.len() < 3 + count * 5 {
error!("Battery Info packet length mismatch: {}", hex::encode(payload));
error!(
"Battery Info packet length mismatch: {}",
hex::encode(payload)
);
return;
}
let mut batteries = Vec::with_capacity(count);
@@ -476,6 +529,7 @@ impl AACPManager {
let base_index = 3 + i * 5;
batteries.push(BatteryInfo {
component: match payload[base_index] {
0x01 => BatteryComponent::Headphone,
0x02 => BatteryComponent::Right,
0x04 => BatteryComponent::Left,
0x08 => BatteryComponent::Case,
@@ -493,7 +547,7 @@ impl AACPManager {
error!("Unknown battery status: {:#04x}", payload[base_index + 3]);
continue;
}
}
},
});
}
let mut state = self.state.lock().await;
@@ -518,9 +572,16 @@ impl AACPManager {
};
if let Some(identifier) = ControlCommandIdentifiers::from_u8(identifier_byte) {
let status = ControlCommandStatus { identifier, value: value.clone() };
let status = ControlCommandStatus {
identifier,
value: value.clone(),
};
let mut state = self.state.lock().await;
if let Some(existing) = state.control_command_status_list.iter_mut().find(|s| s.identifier == identifier) {
if let Some(existing) = state
.control_command_status_list
.iter_mut()
.find(|s| s.identifier == identifier)
{
existing.value = value.clone();
} else {
state.control_command_status_list.push(status.clone());
@@ -536,9 +597,16 @@ impl AACPManager {
if let Some(ref tx) = state.event_tx {
let _ = tx.send(AACPEvent::ControlCommand(status));
}
info!("Received Control Command: {:?}, value: {}", identifier, hex::encode(&value));
info!(
"Received Control Command: {:?}, value: {}",
identifier,
hex::encode(&value)
);
} else {
error!("Unknown Control Command identifier: {:#04x}", identifier_byte);
error!(
"Unknown Control Command identifier: {:#04x}",
identifier_byte
);
}
}
opcodes::EAR_DETECTION => {
@@ -570,10 +638,19 @@ impl AACPManager {
state.ear_detection_status = statuses.clone();
if let Some(ref tx) = state.event_tx {
debug!("Sending Ear Detection event: old: {:?}, new: {:?}", state.old_ear_detection_status, statuses);
let _ = tx.send(AACPEvent::EarDetection(state.old_ear_detection_status.clone(), statuses));
debug!(
"Sending Ear Detection event: old: {:?}, new: {:?}",
state.old_ear_detection_status, statuses
);
let _ = tx.send(AACPEvent::EarDetection(
state.old_ear_detection_status.clone(),
statuses,
));
}
info!("Received Ear Detection Status: {:?}", state.ear_detection_status);
info!(
"Received Ear Detection Status: {:?}",
state.ear_detection_status
);
}
opcodes::CONVERSATION_AWARENESS => {
if packet.len() == 10 {
@@ -585,7 +662,10 @@ impl AACPManager {
}
info!("Received Conversation Awareness: {}", status);
} else {
info!("Received Conversation Awareness packet with unexpected length: {}", packet.len());
info!(
"Received Conversation Awareness packet with unexpected length: {}",
packet.len()
);
}
}
opcodes::INFORMATION => {
@@ -617,7 +697,7 @@ impl AACPManager {
}
strings.remove(0);
let info = AirPodsInformation {
name: strings.get(0).cloned().unwrap_or_default(),
name: strings.first().cloned().unwrap_or_default(),
model_number: strings.get(1).cloned().unwrap_or_default(),
manufacturer: strings.get(2).cloned().unwrap_or_default(),
serial_number: strings.get(3).cloned().unwrap_or_default(),
@@ -634,28 +714,31 @@ impl AACPManager {
},
};
let mut state = self.state.lock().await;
if let Some(mac) = state.airpods_mac {
if let Some(device_data) = state.devices.get_mut(&mac.to_string()) {
if let Some(mac) = state.airpods_mac
&& let Some(device_data) = state.devices.get_mut(&mac.to_string())
{
device_data.name = info.name.clone();
device_data.information = Some(DeviceInformation::AirPods(info.clone()));
}
}
let json = serde_json::to_string(&state.devices).unwrap();
if let Some(parent) = get_devices_path().parent() {
if let Err(e) = tokio::fs::create_dir_all(&parent).await {
if let Some(parent) = get_devices_path().parent()
&& let Err(e) = tokio::fs::create_dir_all(&parent).await
{
error!("Failed to create directory for devices: {}", e);
return;
}
}
if let Err(e) = tokio::fs::write(&get_devices_path(), json).await {
error!("Failed to save devices: {}", e);
}
info!("Received Information: {:?}", info);
},
}
opcodes::PROXIMITY_KEYS_RSP => {
if payload.len() < 4 {
error!("Proximity Keys Response packet too short: {}", hex::encode(payload));
error!(
"Proximity Keys Response packet too short: {}",
hex::encode(payload)
);
return;
}
let key_count = payload[2] as usize;
@@ -664,68 +747,97 @@ impl AACPManager {
let mut keys = Vec::new();
for _ in 0..key_count {
if offset + 3 >= payload.len() {
error!("Proximity Keys Response packet too short while parsing keys: {}", hex::encode(payload));
error!(
"Proximity Keys Response packet too short while parsing keys: {}",
hex::encode(payload)
);
return;
}
let key_type = payload[offset];
let key_length = payload[offset + 2] as usize;
offset += 4;
if offset + key_length > payload.len() {
error!("Proximity Keys Response packet too short for key data: {}", hex::encode(payload));
error!(
"Proximity Keys Response packet too short for key data: {}",
hex::encode(payload)
);
return;
}
let key_data = payload[offset..offset + key_length].to_vec();
keys.push((key_type, key_data));
offset += key_length;
}
info!("Received Proximity Keys Response: {:?}", keys.iter().map(|(kt, kd)| (kt, hex::encode(kd))).collect::<Vec<_>>());
info!(
"Received Proximity Keys Response: {:?}",
keys.iter()
.map(|(kt, kd)| (kt, hex::encode(kd)))
.collect::<Vec<_>>()
);
let mut state = self.state.lock().await;
for (key_type, key_data) in &keys {
if let Some(kt) = ProximityKeyType::from_u8(*key_type) {
if let Some(mac) = state.airpods_mac {
if let Some(kt) = ProximityKeyType::from_u8(*key_type)
&& let Some(mac) = state.airpods_mac
{
let mac_str = mac.to_string();
let device_data = state.devices.entry(mac_str.clone()).or_insert(DeviceData {
let device_data =
state.devices.entry(mac_str.clone()).or_insert(DeviceData {
name: mac_str.clone(),
type_: DeviceType::AirPods,
information: None,
});
match kt {
ProximityKeyType::Irk => {
match device_data.information.as_mut() {
ProximityKeyType::Irk => match device_data.information.as_mut() {
Some(DeviceInformation::AirPods(info)) => {
info.le_keys.irk = hex::encode(key_data);
}
_ => {
error!("Device information is not AirPods for adding LE IRK.");
}
}
}
ProximityKeyType::EncKey => {
match device_data.information.as_mut() {
},
ProximityKeyType::EncKey => match device_data.information.as_mut() {
Some(DeviceInformation::AirPods(info)) => {
info.le_keys.enc_key = hex::encode(key_data);
}
_ => {
error!("Device information is not AirPods for adding LE encryption key.");
}
}
}
error!(
"Device information is not AirPods for adding LE encryption key."
);
}
},
}
}
}
let json = serde_json::to_string(&state.devices).unwrap();
if let Some(parent) = get_devices_path().parent() {
if let Err(e) = tokio::fs::create_dir_all(&parent).await {
if let Some(parent) = get_devices_path().parent()
&& let Err(e) = tokio::fs::create_dir_all(&parent).await
{
error!("Failed to create directory for devices: {}", e);
return;
}
}
if let Err(e) = tokio::fs::write(&get_devices_path(), json).await {
error!("Failed to save devices: {}", e);
}
},
opcodes::STEM_PRESS => info!("Received Stem Press packet."),
}
opcodes::STEM_PRESS => {
if payload.len() < 4 {
error!("Stem Press packet too short: {}", hex::encode(payload));
return;
}
let press_type = StemPressType::from_u8(payload[2]);
let bud_type = StemPressBudType::from_u8(payload[3]);
if let (Some(press), Some(bud)) = (press_type, bud_type) {
info!("Received Stem Press: {:?} on {:?}", press, bud);
let state = self.state.lock().await;
if let Some(ref tx) = state.event_tx {
let _ = tx.send(AACPEvent::StemPress(press, bud));
}
} else {
error!(
"Invalid Stem Press packet - type: {:?}, bud: {:?}",
press_type, bud_type
);
}
}
opcodes::AUDIO_SOURCE => {
if payload.len() < 9 {
error!("Audio Source packet too short: {}", hex::encode(payload));
@@ -746,12 +858,18 @@ impl AACPManager {
}
opcodes::CONNECTED_DEVICES => {
if payload.len() < 3 {
error!("Connected Devices packet too short: {}", hex::encode(payload));
error!(
"Connected Devices packet too short: {}",
hex::encode(payload)
);
return;
}
let count = payload[2] as usize;
if payload.len() < 3 + count * 8 {
error!("Connected Devices packet length mismatch: {}", hex::encode(payload));
error!(
"Connected Devices packet length mismatch: {}",
hex::encode(payload)
);
return;
}
let mut devices = Vec::with_capacity(count);
@@ -759,17 +877,30 @@ impl AACPManager {
let base = 5 + i * 8;
let mac = format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
payload[base], payload[base + 1], payload[base + 2], payload[base + 3], payload[base + 4], payload[base + 5]
payload[base],
payload[base + 1],
payload[base + 2],
payload[base + 3],
payload[base + 4],
payload[base + 5]
);
let info1 = payload[base + 6];
let info2 = payload[base + 7];
devices.push(ConnectedDevice { mac, info1, info2, r#type: None });
devices.push(ConnectedDevice {
mac,
info1,
info2,
r#type: None,
});
}
let mut state = self.state.lock().await;
state.old_connected_devices = state.connected_devices.clone();
state.connected_devices = devices.clone();
if let Some(ref tx) = state.event_tx {
let _ = tx.send(AACPEvent::ConnectedDevices(state.old_connected_devices.clone(), devices));
let _ = tx.send(AACPEvent::ConnectedDevices(
state.old_connected_devices.clone(),
devices,
));
}
info!("Received Connected Devices: {:?}", state.connected_devices);
}
@@ -807,15 +938,16 @@ impl AACPManager {
pub async fn send_handshake(&self) -> Result<()> {
let packet = [
0x00, 0x00, 0x04, 0x00,
0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
];
self.send_packet(&packet).await
}
pub async fn send_proximity_keys_request(&self, key_types: Vec<ProximityKeyType>) -> Result<()> {
pub async fn send_proximity_keys_request(
&self,
key_types: Vec<ProximityKeyType>,
) -> Result<()> {
let opcode = [opcodes::PROXIMITY_KEYS_REQ, 0x00];
let mut data = Vec::with_capacity(2);
data.push(key_types.iter().fold(0u8, |acc, kt| acc | (*kt as u8)));
@@ -827,16 +959,21 @@ impl AACPManager {
pub async fn send_rename_packet(&self, name: &str) -> Result<()> {
let name_bytes = name.as_bytes();
let size = name_bytes.len();
let mut packet = Vec::with_capacity(5 + size);
let mut packet = Vec::with_capacity(6 + size);
packet.push(opcodes::RENAME);
packet.push(0x00);
packet.push(0x01);
packet.push(size as u8);
packet.push(0x00);
packet.extend_from_slice(name_bytes);
self.send_data_packet(&packet).await
}
pub async fn send_control_command(&self, identifier: ControlCommandIdentifiers, value: &[u8]) -> Result<()> {
pub async fn send_control_command(
&self,
identifier: ControlCommandIdentifiers,
value: &[u8],
) -> Result<()> {
let opcode = [opcodes::CONTROL_COMMAND, 0x00];
let mut data = vec![identifier as u8];
for i in 0..4 {
@@ -846,10 +983,17 @@ impl AACPManager {
self.send_data_packet(&packet).await
}
pub async fn send_media_information_new_device(&self, self_mac_address: &str, target_mac_address: &str) -> Result<()> {
pub async fn send_media_information_new_device(
&self,
self_mac_address: &str,
target_mac_address: &str,
) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(112);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x68, 0x00]);
@@ -881,7 +1025,10 @@ impl AACPManager {
pub async fn send_hijack_request(&self, target_mac_address: &str) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(106);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x62, 0x00]);
buffer.extend_from_slice(&[0x01, 0xE5]);
@@ -909,10 +1056,18 @@ impl AACPManager {
self.send_data_packet(&packet).await
}
pub async fn send_media_information(&self, self_mac_address: &str, target_mac_address: &str, streaming_state: bool) -> Result<()> {
pub async fn send_media_information(
&self,
self_mac_address: &str,
target_mac_address: &str,
streaming_state: bool,
) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(138);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x82, 0x00]);
buffer.extend_from_slice(&[0x01, 0xE5, 0x4A]);
@@ -945,7 +1100,10 @@ impl AACPManager {
pub async fn send_smart_routing_show_ui(&self, target_mac_address: &str) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(134);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x7E, 0x00]);
buffer.extend_from_slice(&[0x01, 0xE6, 0x5B]);
@@ -978,7 +1136,10 @@ impl AACPManager {
pub async fn send_hijack_reversed(&self, target_mac_address: &str) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(97);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x59, 0x00]);
buffer.extend_from_slice(&[0x01, 0xE3]);
@@ -1001,10 +1162,17 @@ impl AACPManager {
self.send_data_packet(&packet).await
}
pub async fn send_add_tipi_device(&self, self_mac_address: &str, target_mac_address: &str) -> Result<()> {
pub async fn send_add_tipi_device(
&self,
self_mac_address: &str,
target_mac_address: &str,
) -> Result<()> {
let opcode = [opcodes::SMART_ROUTING, 0x00];
let mut buffer = Vec::with_capacity(86);
let target_mac_bytes: Vec<u8> = target_mac_address.split(':').map(|s| u8::from_str_radix(s, 16).unwrap()).collect();
let target_mac_bytes: Vec<u8> = target_mac_address
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect();
buffer.extend_from_slice(&target_mac_bytes.iter().rev().cloned().collect::<Vec<u8>>());
buffer.extend_from_slice(&[0x4E, 0x00]);
buffer.extend_from_slice(&[0x01, 0xE5]);
@@ -1029,10 +1197,8 @@ impl AACPManager {
}
pub async fn send_some_packet(&self) -> Result<()> {
self.send_data_packet(&[
0x29, 0x00,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
]).await
self.send_data_packet(&[0x29, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
.await
}
}
@@ -1050,8 +1216,10 @@ async fn recv_thread(manager: AACPManager, sp: Arc<SeqPacket>) {
manager.receive_packet(data).await;
}
Err(e) => {
error!("Read error: {}", e);
debug!("We have probably disconnected, clearing state variables (owns=false, connected_devices=empty, control_command_status_list=empty).");
debug!("Read error: {}", e);
info!(
"We have probably disconnected, clearing state variables (owns=false, connected_devices=empty, control_command_status_list=empty)."
);
let mut state = manager.state.lock().await;
state.owns = false;
state.connected_devices.clear();
@@ -1066,11 +1234,23 @@ async fn recv_thread(manager: AACPManager, sp: Arc<SeqPacket>) {
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);
let mut attempts = 0;
loop {
match sp.send(&data).await {
Ok(_) => {
debug!("Sent {} bytes: {}", data.len(), hex::encode(&data));
break;
}
debug!("Sent {} bytes: {}", data.len(), hex::encode(&data));
Err(e) if e.raw_os_error() == Some(107) && attempts < 10 => {
attempts += 1;
sleep(Duration::from_millis(100)).await;
}
Err(e) => {
error!("Failed to send data: {}", e);
return;
}
}
}
}
info!("Send thread finished.");
}

View File

@@ -1,12 +1,12 @@
use bluer::l2cap::{SocketAddr, Socket, SeqPacket};
use bluer::{Address, AddressType, Result, Error};
use log::{info, error, debug};
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::{sleep, Duration, Instant};
use std::collections::HashMap;
use hex;
use tokio::time::{Duration, Instant, sleep};
const PSM_ATT: u16 = 0x001F;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -25,7 +25,7 @@ pub enum ATTHandles {
AirPodsLoudSoundReduction = 0x1B,
AirPodsHearingAid = 0x2A,
NothingEverything = 0x8002,
NothingEverythingRead = 0x8005 // for some reason, and not the same as the write handle
NothingEverythingRead = 0x8005, // for some reason, and not the same as the write handle
}
#[repr(u16)]
@@ -43,7 +43,7 @@ impl From<ATTHandles> for ATTCCCDHandles {
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
ATTHandles::NothingEverythingRead => panic!("No CCD for NothingEverythingRead handle"), // it sends notifications without CCCD
}
}
}
@@ -57,7 +57,7 @@ impl ATTManagerState {
fn new() -> Self {
ATTManagerState {
sender: None,
listeners: HashMap::new()
listeners: HashMap::new(),
}
}
}
@@ -82,11 +82,15 @@ impl ATTManager {
}
pub async fn connect(&mut self, addr: Address) -> Result<()> {
info!("ATTManager connecting to {} on PSM {:#06X}...", addr, PSM_ATT);
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_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)) => {
@@ -95,7 +99,10 @@ impl ATTManager {
}
Err(_) => {
error!("L2CAP connect timed out");
return Err(Error::from(std::io::Error::new(std::io::ErrorKind::TimedOut, "Connection timeout")));
return Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Connection timeout",
)));
}
};
@@ -106,7 +113,8 @@ impl ATTManager {
Ok(peer) if peer.cid != 0 => break,
Ok(_) => {}
Err(e) => {
if e.raw_os_error() == Some(107) { // ENOTCONN
if e.raw_os_error() == Some(107) {
// ENOTCONN
error!("Peer has disconnected during connection setup.");
return Err(e.into());
}
@@ -115,7 +123,10 @@ impl ATTManager {
}
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")));
return Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Connection timeout",
)));
}
sleep(POLL_INTERVAL).await;
}
@@ -180,11 +191,17 @@ impl ATTManager {
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"))
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")))
Err(Error::from(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"L2CAP stream not connected",
)))
}
}
@@ -195,11 +212,11 @@ impl ATTManager {
Ok(Some(resp)) => Ok(resp),
Ok(None) => Err(Error::from(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Response channel closed"
"Response channel closed",
))),
Err(_) => Err(Error::from(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Response timeout"
"Response timeout",
))),
}
}

View File

@@ -1,6 +1,6 @@
use std::io::Error;
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();
@@ -8,20 +8,24 @@ pub(crate) async fn find_connected_airpods(adapter: &Adapter) -> bluer::Result<b
let addrs = adapter.device_addresses().await?;
for addr in addrs {
let device = adapter.device(addr)?;
if device.is_connected().await.unwrap_or(false) {
if let Ok(uuids) = device.uuids().await {
if let Some(uuids) = uuids {
if uuids.iter().any(|u| *u == target_uuid) {
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")))
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>> {
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 {
@@ -38,5 +42,8 @@ pub async fn find_other_managed_devices(adapter: &Adapter, managed_macs: Vec<Str
return Ok(devices);
}
debug!("No other managed devices found");
Err(bluer::Error::from(Error::new(std::io::ErrorKind::NotFound, "No other managed devices found")))
Err(bluer::Error::from(Error::new(
std::io::ErrorKind::NotFound,
"No other managed devices found",
)))
}

View File

@@ -1,30 +1,31 @@
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::Array;
use aes::cipher::{BlockCipherDecrypt, KeyInit};
use bluer::monitor::{Monitor, MonitorEvent, Pattern};
use bluer::{Address, Session};
use aes::Aes128;
use aes::cipher::{KeyInit, BlockDecrypt};
use aes::cipher::generic_array::GenericArray;
use std::collections::{HashMap, HashSet};
use log::{info, debug};
use serde_json;
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;
use crate::bluetooth::aacp::BatteryStatus;
use crate::ui::tray::MyTray;
use crate::devices::enums::{DeviceData, DeviceInformation, DeviceType};
use crate::utils::{get_devices_path, get_preferences_path, ah};
fn decrypt(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
let cipher = Aes128::new(&GenericArray::from(*key));
let mut block = GenericArray::from(*data);
let cipher = Aes128::new(&Array::from(*key));
let mut block = Array::from(*data);
cipher.decrypt_block(&mut block);
block.into()
}
fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool {
let rpa: Vec<u8> = addr.split(':')
let rpa: Vec<u8> = addr
.split(':')
.map(|s| u8::from_str_radix(s, 16).unwrap())
.collect::<Vec<_>>()
.into_iter()
@@ -38,7 +39,10 @@ fn verify_rpa(addr: &str, irk: &[u8; 16]) -> bool {
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);
debug!(
"Verifying RPA: addr={}, hash={:?}, computed_hash={:?}",
addr, hash, computed_hash
);
hash == computed_hash
}
@@ -47,8 +51,7 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
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())
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();
@@ -97,23 +100,27 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
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 {
if let Some(DeviceInformation::AirPods(info)) = &device_data.information {
if let Ok(irk_bytes) = hex::decode(&info.le_keys.irk) {
if irk_bytes.len() == 16 {
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);
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);
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);
@@ -124,17 +131,14 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
}
}
if let Some(ref mac) = matched_airpods_mac {
if let Some(device_data) = all_devices.get(mac) {
if let Some(DeviceInformation::AirPods(info)) = &device_data.information {
if let Ok(enc_key_bytes) = hex::decode(&info.le_keys.enc_key) {
if enc_key_bytes.len() == 16 {
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?;
@@ -144,49 +148,69 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
while let Some(ev) = events.next().await {
match ev {
bluer::DeviceEvent::PropertyChanged(prop) => {
match prop {
bluer::DeviceProperty::ManufacturerData(data) => {
if let Some(enc_key) = &matched_enc_key {
if let Some(apple_data) = data.get(&76) {
if apple_data.len() > 20 {
let last_16: [u8; 16] = apple_data[apple_data.len() - 16..].try_into().unwrap();
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));
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)
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())
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);
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 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());
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());
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")
let output =
tokio::process::Command::new("bluetoothctl")
.arg("connect")
.arg(matched_airpods_mac.as_ref().unwrap())
.output()
@@ -194,18 +218,38 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
match output {
Ok(output) => {
if output.status.success() {
info!("Successfully connected to AirPods {}", matched_airpods_mac.as_ref().unwrap());
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);
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!(
"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());
info!(
"Auto-connect is disabled for {}, not attempting to connect.",
matched_airpods_mac.as_ref().unwrap()
);
}
}
@@ -213,8 +257,16 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
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_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 };
@@ -229,7 +281,8 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
} else {
(left_byte & 0x7F, (left_byte & 0x80) != 0)
};
let (right_battery, right_charging) = if right_byte == 0xff {
let (right_battery, right_charging) = if right_byte == 0xff
{
(0, false)
} else {
(right_byte & 0x7F, (right_byte & 0x80) != 0)
@@ -241,29 +294,81 @@ pub async fn start_le_monitor(tray_handle: Option<ksni::Handle<MyTray>>) -> blue
};
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;
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);
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
);
}
}
}
}
_ => {}
}
}
}
}
});
}

View File

@@ -1,6 +1,6 @@
use std::sync::Arc;
use crate::bluetooth::aacp::AACPManager;
use crate::bluetooth::att::ATTManager;
use std::sync::Arc;
pub struct DeviceManagers {
att: Option<Arc<ATTManager>>,
@@ -9,16 +9,25 @@ pub struct DeviceManagers {
impl DeviceManagers {
pub fn with_aacp(aacp: AACPManager) -> Self {
Self { att: None, aacp: Some(Arc::new(aacp)) }
Self {
att: None,
aacp: Some(Arc::new(aacp)),
}
}
pub fn with_att(att: ATTManager) -> Self {
Self { att: Some(Arc::new(att)), aacp: None }
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)) }
Self {
att: Some(Arc::new(att)),
aacp: Some(Arc::new(aacp)),
}
}
pub fn set_aacp(&mut self, manager: AACPManager) {

View File

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

View File

@@ -1,16 +1,16 @@
use crate::bluetooth::aacp::{AACPManager, ProximityKeyType, AACPEvent, AirPodsLEKeys};
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::bluetooth::att::ATTManager;
use crate::bluetooth::aacp::{AACPEvent, AACPManager, AirPodsLEKeys, ProximityKeyType};
use crate::media_controller::MediaController;
use bluer::Address;
use log::{debug, info, error};
use std::sync::Arc;
use ksni::Handle;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::{sleep, Duration};
use crate::ui::tray::MyTray;
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};
use crate::utils::get_app_settings_path;
pub struct AirPodsDevice {
pub mac_address: Address,
@@ -34,7 +34,19 @@ impl AirPodsDevice {
// 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;
handle
.update(|tray: &mut MyTray| {
tray.connected = true;
tray.battery_headphone = None;
tray.battery_headphone_status = None;
tray.battery_l = None;
tray.battery_l_status = None;
tray.battery_r = None;
tray.battery_r_status = None;
tray.battery_c = None;
tray.battery_c_status = None;
})
.await;
}
info!("Sending handshake");
@@ -42,14 +54,14 @@ impl AirPodsDevice {
error!("Failed to send handshake to AirPods device: {}", e);
}
sleep(Duration::from_millis(100)).await;
sleep(Duration::from_millis(300)).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;
sleep(Duration::from_millis(300)).await;
info!("Requesting notifications");
if let Err(e) = aacp_manager.send_notification_request().await {
@@ -62,24 +74,63 @@ impl AirPodsDevice {
}
info!("Requesting Proximity Keys: IRK and ENC_KEY");
if let Err(e) = aacp_manager.send_proximity_keys_request(
vec![ProximityKeyType::Irk, ProximityKeyType::EncKey],
).await {
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 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 stem_control = settings
.clone()
.and_then(|v| v.get("stem_control").cloned())
.and_then(|s| serde_json::from_value(s).ok())
.unwrap_or(false);
let media_controller = Arc::new(Mutex::new(MediaController::new(mac_address.to_string(), local_mac.clone())));
if stem_control {
// Enable stem press detection (double and triple tap)
// StemConfig bitmask for the control command: single=0x01, double=0x02, triple=0x04, long=0x08
// We want double and triple: 0x02 | 0x04 = 0x06
// Note: these bitmask values differ from the StemPressType event enum values (0x050x08)
info!("Enabling stem press detection for double and triple tap");
if let Err(e) = aacp_manager
.send_control_command(ControlCommandIdentifiers::StemConfig, &[0x06])
.await
{
error!("Failed to enable stem press detection: {}", 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;
handle
.update(|tray: &mut MyTray| tray.command_tx = Some(command_tx.clone()))
.await;
}
let aacp_manager_clone = aacp_manager.clone();
@@ -93,54 +144,80 @@ impl AirPodsDevice {
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;
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;
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| {
handle
.update(|tray: &mut MyTray| {
tray.listening_mode = Some(value[0]);
}).await;
})
.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;
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| {
handle
.update(|tray: &mut MyTray| {
tray.allow_off_option = Some(value[0]);
}).await;
})
.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 (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| {
handle
.update(|tray: &mut MyTray| {
tray.conversation_detect_enabled = Some(value[0] == 0x01);
}).await;
})
.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;
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.get(0).copied().unwrap_or(0) != 0;
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;
@@ -159,17 +236,30 @@ impl AirPodsDevice {
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);
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;
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| {
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);
@@ -185,16 +275,23 @@ impl AirPodsDevice {
_ => {}
}
}
}).await;
})
.await;
}
debug!("Updated tray with new battery info");
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(mac_address.to_string(), event_clone));
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));
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac_address.to_string(),
event_clone,
));
debug!("Sent ControlCommand event to UI");
}
AACPEvent::ConversationalAwareness(status) => {
@@ -205,37 +302,85 @@ impl AirPodsDevice {
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_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);
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 {
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 {
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]));
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;
}
AACPEvent::StemPress(press_type, bud_type) => {
use crate::bluetooth::aacp::StemPressType;
info!(
"Received Stem Press: {:?} on {:?}",
press_type, bud_type
);
if stem_control {
let controller = mc_clone.lock().await;
match press_type {
StemPressType::DoublePress => {
info!("Double press detected, skipping to next track");
controller.next_track().await;
}
StemPressType::TriplePress => {
info!("Triple press detected, going to previous track");
controller.previous_track().await;
}
_ => {
debug!("Unhandled stem press type: {:?}", press_type);
}
}
} else {
debug!("Stem control disabled, ignoring stem press event");
}
}
_ => {
debug!("Received unhandled AACP event: {:?}", event);
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(mac_address.to_string(), event_clone));
let _ = ui_tx_clone.send(BluetoothUIMessage::AACPUIEvent(
mac_address.to_string(),
event_clone,
));
debug!("Sent unhandled AACP event to UI");
}
}
@@ -265,5 +410,5 @@ pub struct AirPodsInformation {
pub left_serial_number: String,
pub right_serial_number: String,
pub version3: String,
pub le_keys: AirPodsLEKeys
pub le_keys: AirPodsLEKeys,
}

View File

@@ -1,14 +1,14 @@
use std::fmt::Display;
use iced::widget::{combo_box, ComboBox};
use serde::{Deserialize, Serialize};
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)]
#[derive(PartialEq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum DeviceType {
AirPods,
Nothing
Nothing,
}
impl Display for DeviceType {
@@ -20,12 +20,11 @@ impl Display for DeviceType {
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data")]
pub enum DeviceInformation {
AirPods(AirPodsInformation),
Nothing(NothingInformation)
Nothing(NothingInformation),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -35,7 +34,6 @@ pub struct DeviceData {
pub information: Option<DeviceInformation>,
}
#[derive(Clone, Debug)]
pub enum DeviceState {
AirPods(AirPodsState),
@@ -58,7 +56,8 @@ pub struct AirPodsState {
pub noise_control_state: combo_box::State<AirPodsNoiseControlMode>,
pub conversation_awareness_enabled: bool,
pub personalized_volume_enabled: bool,
pub allow_off_mode: bool
pub allow_off_mode: bool,
pub battery: Vec<BatteryInfo>,
}
#[derive(Clone, Debug)]
@@ -66,7 +65,7 @@ pub enum AirPodsNoiseControlMode {
Off,
NoiseCancellation,
Transparency,
Adaptive
Adaptive,
}
impl Display for AirPodsNoiseControlMode {
@@ -113,7 +112,7 @@ pub enum NothingAncMode {
MidNoiseCancellation,
HighNoiseCancellation,
AdaptiveNoiseCancellation,
Transparency
Transparency,
}
impl Display for NothingAncMode {

View File

@@ -1,43 +1,44 @@
use std::collections::HashMap;
use std::time::Duration;
use bluer::Address;
use log::{debug, info};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use tokio::time::sleep;
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 firmware_version: String,
}
pub struct NothingDevice {
pub att_manager: ATTManager,
pub information: NothingInformation
pub information: NothingInformation,
}
impl NothingDevice {
pub async fn new(
mac_address: Address,
ui_tx: mpsc::UnboundedSender<BluetoothUIMessage>
ui_tx: mpsc::UnboundedSender<BluetoothUIMessage>,
) -> Self {
let mut att_manager = ATTManager::new();
att_manager.connect(mac_address).await.expect("Failed to connect");
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;
att_manager
.register_listener(ATTHandles::NothingEverythingRead, tx)
.await;
let devices: HashMap<String, DeviceData> =
std::fs::read_to_string(get_devices_path())
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();
@@ -49,119 +50,130 @@ impl NothingDevice{
} else {
NothingInformation {
serial_number: String::new(),
firmware_version: String::new()
firmware_version: String::new(),
}
}
} else {
NothingInformation {
serial_number: String::new(),
firmware_version: String::new()
firmware_version: String::new(),
}
};
// Request version information
att_manager.write(
att_manager
.write(
ATTHandles::NothingEverything,
&[
0x55, 0x20,
0x01, 0x42,
0xC0, 0x00,
0x00, 0x00,
0x00, 0x00 // something, idk
]
).await.expect("Failed to write");
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(
att_manager
.write(
ATTHandles::NothingEverything,
&[
0x55, 0x20,
0x01, 0x06,
0xC0, 0x00,
0x00, 0x13,
0x00, 0x00
]
).await.expect("Failed to write");
&[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
]) {
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);
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()
firmware_version: firmware_version.clone(),
};
let mut new_devices = devices.clone();
new_devices.insert(
device_key.clone(),
DeviceData {
name: devices.get(&device_key)
name: devices
.get(&device_key)
.map(|d| d.name.clone())
.unwrap_or("Nothing Device".to_string()),
type_: devices.get(&device_key)
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()
} 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);
&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()
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)
name: devices
.get(&device_key)
.map(|d| d.name.clone())
.unwrap_or("Nothing Device".to_string()),
type_: devices.get(&device_key)
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");
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!(
"Serial number format unexpected from Nothing device {}: {:?}",
mac_address, data
);
}
}
else {}
debug!("Received data from (Nothing) device {}, data: {:?}", mac_address, data);
debug!(
"Received data from (Nothing) device {}, data: {:?}",
mac_address, data
);
}
});
NothingDevice {
att_manager,
information
information,
}
}
}

View File

@@ -1,61 +1,106 @@
mod bluetooth;
mod devices;
mod media_controller;
mod ui;
mod utils;
mod devices;
use std::env;
use log::info;
use dbus::blocking::Connection;
use dbus::blocking::stdintf::org_freedesktop_dbus::Properties;
use dbus::message::MatchRule;
use dbus::arg::{RefArg, Variant};
use std::collections::HashMap;
use std::sync::Arc;
use crate::bluetooth::discovery::{find_connected_airpods, find_other_managed_devices};
use devices::airpods::AirPodsDevice;
use bluer::{Address, InternalErrorKind};
use ksni::TrayMethods;
use crate::ui::tray::MyTray;
use clap::Parser;
use crate::bluetooth::le::start_le_monitor;
use tokio::sync::mpsc::unbounded_channel;
use tokio::sync::RwLock;
use crate::bluetooth::managers::DeviceManagers;
use crate::devices::enums::DeviceData;
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_devices_path;
use crate::ui::tray::MyTray;
use crate::utils::{get_app_settings_path, 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::{debug, info, warn};
use std::collections::HashMap;
use std::env;
use std::sync::atomic::{AtomicBool};
use std::sync::Arc;
use tokio::sync::RwLock;
use tokio::sync::mpsc::unbounded_channel;
#[derive(Parser)]
struct Args {
#[arg(long)]
#[arg(long, short = 'd', help = "Enable debug logging")]
debug: bool,
#[arg(long)]
#[arg(
long,
help = "Disable system tray, useful if your environment doesn't support AppIndicator or StatusNotifier"
)]
no_tray: bool,
#[arg(long)]
#[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 wayland_display && env::var("WGPU_BACKEND").is_err() {
// unsafe { env::set_var("WGPU_BACKEND", "gl") };
// }
if env::var("RUST_LOG").is_err() {
unsafe { env::set_var("RUST_LOG", log_level.to_owned() + ",winit=warn,tracing=warn,,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,librepods_rust::bluetooth::le=warn,cosmic_text=warn,naga=warn,iced_winit=warn") };
unsafe {
env::set_var(
"RUST_LOG",
log_level.to_owned()
+ &format!(
",zbus=warn,winit=warn,tracing=warn,iced_wgpu=warn,wgpu_hal=warn,wgpu_core=warn,cosmic_text=warn,naga=warn,iced_winit=warn,librepods::bluetooth::le={}",
if args.le_debug { "debug" } else { "info" }
),
)
};
}
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: Arc<RwLock<HashMap<String, DeviceManagers>>> =
Arc::new(RwLock::new(HashMap::new()));
// Load stem_control initial value from settings JSON, then apply CLI override.
if args.no_tray {
// Run headless without UI
info!("Running in headless mode (no GUI)");
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async_main(ui_tx, device_managers)).unwrap();
Ok(())
} else {
// Run with UI
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();
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>,
@@ -70,17 +115,15 @@ async fn async_main(
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| {
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() {
match device_data.type_ {
devices::enums::DeviceType::Nothing => {
if device_data.type_ == devices::enums::DeviceType::Nothing {
managed_devices_mac.push(mac.clone());
}
_ => {}
}
}
let tray_handle = if args.no_tray {
@@ -88,6 +131,8 @@ async fn async_main(
} 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,
@@ -121,9 +166,13 @@ async fn async_main(
info!("Checking for connected devices...");
match find_connected_airpods(&adapter).await {
Ok(device) => {
let name = device.name().await?.unwrap_or_else(|| "Unknown".to_string());
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 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());
@@ -133,7 +182,11 @@ async fn async_main(
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx.send(BluetoothUIMessage::DeviceConnected(device.address().to_string())).unwrap();
if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceConnected(
device.address().to_string(),
)) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
}
Err(_) => {
info!("No connected AirPods found.");
@@ -144,23 +197,29 @@ async fn async_main(
Ok(devices) => {
for device in devices {
let addr_str = device.address().to_string();
info!("Found connected managed device: {}, initializing.", addr_str);
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;
match type_ {
devices::enums::DeviceType::Nothing => {
let dev = devices::nothing::NothingDevice::new(device.address(), ui_tx_clone.clone()).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();
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str)) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
_ => {}
}
drop(managers)
});
@@ -168,7 +227,9 @@ async fn async_main(
}
Err(e) => {
log::debug!("type of error: {:?}", e.kind);
if e.kind != bluer::ErrorKind::Internal(InternalErrorKind::Io(std::io::ErrorKind::NotFound)) {
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.");
@@ -179,34 +240,49 @@ async fn async_main(
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; };
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 {
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 {
let Some(connected_var) = changed.get("Connected") else {
return true;
}
};
let Some(is_connected) = connected_var.0.as_ref().as_u64() else {
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 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; };
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 is_connected==0 {
if let Err(e) = ui_tx.send(BluetoothUIMessage::DeviceDisconnected(addr_str.clone())) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
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();
match type_ {
devices::enums::DeviceType::Nothing => {
if type_ == devices::enums::DeviceType::Nothing {
let ui_tx_clone = ui_tx.clone();
let device_managers = device_managers.clone();
tokio::spawn(async move {
@@ -218,10 +294,10 @@ async fn async_main(
.or_insert(dev_managers)
.set_att(dev.att_manager);
drop(managers);
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
});
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
_ => {}
});
}
return true;
}
@@ -229,7 +305,9 @@ async fn async_main(
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());
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();
@@ -244,7 +322,9 @@ async fn async_main(
.or_insert(dev_managers)
.set_aacp(airpods_device.aacp_manager);
drop(managers);
ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())).unwrap();
if let Err(e) = ui_tx_clone.send(BluetoothUIMessage::DeviceConnected(addr_str.clone())) {
warn!("Failed to send DeviceConnected UI message: {:?}", e);
}
});
true
})?;

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,18 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::thread;
use iced::widget::{button, column, combo_box, container, row, rule, text, text_input, toggler, Rule, Space};
use iced::{Background, Border, Center, Color, Length, Padding, Theme};
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::{
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::aacp::{AACPManager, ControlCommandIdentifiers};
// use crate::bluetooth::att::ATTManager;
use crate::devices::enums::{AirPodsState, DeviceData, DeviceInformation, DeviceState};
use crate::ui::window::Message;
@@ -24,30 +26,25 @@ pub fn airpods_view<'a>(
) -> 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| {
Space::new().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
)
}),
Space::new().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| {
.style(|theme: &Theme, _status| {
text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Default::default(),
@@ -56,30 +53,29 @@ pub fn airpods_view<'a>(
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(
{
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");
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)
.align_y(Center),
)
.padding(Padding {
top: 5.0,
@@ -87,26 +83,23 @@ pub fn airpods_view<'a>(
left: 10.0,
right: 10.0,
})
.style(
|theme: &Theme| {
.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 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),
}),
Space::new().width(Length::Fill),
{
let state_clone = state.clone();
let mac = mac.clone();
@@ -120,61 +113,57 @@ pub fn airpods_view<'a>(
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(
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");
}
);
&[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 {
.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)
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 {
.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)
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)),
}
}
)
selected_background: Background::Color(
theme.palette().primary.scale_alpha(0.3),
),
shadow: Default::default()
})
}
]
.align_y(Center)
.align_y(Center),
)
.padding(Padding {
top: 5.0,
@@ -182,16 +171,14 @@ pub fn airpods_view<'a>(
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
.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();
@@ -226,9 +213,8 @@ pub fn airpods_view<'a>(
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
).width(Length::Fill),
].width(Length::Fill),
toggler(state.personalized_volume_enabled)
.on_toggle(
{
@@ -255,14 +241,15 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
},
Rule::horizontal(8).style(
rule::horizontal(1).style(
|theme: &Theme| {
rule::Style {
color: theme.palette().text,
width: 1,
color: theme.palette().text.scale_alpha(0.2),
radius: Radius::from(12),
fill_mode: FillMode::Full
fill_mode: FillMode::Full,
snap: false
}
}
),
@@ -277,9 +264,8 @@ pub fn airpods_view<'a>(
style.color = Some(theme.palette().text.scale_alpha(0.7));
style
}
)
],
Space::with_width(Length::Fill),
).width(Length::Fill),
].width(Length::Fill),
toggler(state.conversation_awareness_enabled)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_conv_detect.clone();
@@ -299,6 +285,7 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
}
]
.spacing(4)
@@ -328,15 +315,14 @@ pub fn airpods_view<'a>(
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(
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
}
)
],
Space::with_width(Length::Fill),
).width(Length::Fill)
].width(Length::Fill),
toggler(state.allow_off_mode)
.on_toggle(move |is_enabled| {
let aacp_manager = aacp_manager_olm.clone();
@@ -356,6 +342,7 @@ pub fn airpods_view<'a>(
.size(20)
]
.align_y(Center)
.spacing(8)
)
.padding(Padding{
top: 5.0,
@@ -380,127 +367,103 @@ pub fn airpods_view<'a>(
if let Some(DeviceInformation::AirPods(ref airpods_info)) = device.information {
let info_rows = column![
row![
text("Model Number").size(16).style(
|theme: &Theme| {
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),
}),
Space::new().width(Length::Fill),
text(airpods_info.model_number.clone()).size(16)
],
row![
text("Manufacturer").size(16).style(
|theme: &Theme| {
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),
}),
Space::new().width(Length::Fill),
text(airpods_info.manufacturer.clone()).size(16)
],
row![
text("Serial Number").size(16).style(
|theme: &Theme| {
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| {
}),
Space::new().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| {
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| {
}),
Space::new().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()))
.on_press(Message::CopyToClipboard(
airpods_info.left_serial_number.clone()
))
],
row![
text("Right Serial Number").size(16).style(
|theme: &Theme| {
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| {
}),
Space::new().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()))
.on_press(Message::CopyToClipboard(
airpods_info.right_serial_number.clone()
))
],
row![
text("Version 1").size(16).style(
|theme: &Theme| {
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),
}),
Space::new().width(Length::Fill),
text(airpods_info.version1.clone()).size(16)
],
row![
text("Version 2").size(16).style(
|theme: &Theme| {
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),
}),
Space::new().width(Length::Fill),
text(airpods_info.version2.clone()).size(16)
],
row![
text("Version 3").size(16).style(
|theme: &Theme| {
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),
}),
Space::new().width(Length::Fill),
text(airpods_info.version3.clone()).size(16)
]
]
@@ -508,15 +471,12 @@ pub fn airpods_view<'a>(
.padding(8);
information_col = column![
container(
text("Device Information").size(18).style(
|theme: &Theme| {
container(text("Device Information").size(18).style(|theme: &Theme| {
let mut style = text::Style::default();
style.color = Some(theme.palette().primary);
style
}
)
).padding(Padding{
}))
.padding(Padding {
top: 5.0,
bottom: 5.0,
left: 18.0,
@@ -529,35 +489,35 @@ pub fn airpods_view<'a>(
left: 10.0,
right: 10.0,
})
.style(
|theme: &Theme| {
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
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());
error!(
"Expected AirPodsInformation for device {}, got something else",
mac.clone()
);
}
}
container(
column![
container(column![
rename_input,
Space::with_height(Length::from(20)),
Space::new().height(Length::from(20)),
listening_mode,
Space::with_height(Length::from(20)),
Space::new().height(Length::from(20)),
audio_settings_col,
Space::with_height(Length::from(20)),
Space::new().height(Length::from(20)),
off_listening_mode_toggle,
Space::with_height(Length::from(20)),
Space::new().height(Length::from(20)),
information_col
]
)
])
.padding(20)
.center_x(Length::Fill)
.height(Length::Fill)

View File

@@ -7,5 +7,5 @@ pub enum BluetoothUIMessage {
DeviceDisconnected(String), // mac
AACPUIEvent(String, AACPEvent), // mac, event
ATTNotification(String, u16, Vec<u8>), // mac, handle, data
NoOp
NoOp,
}

View File

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

View File

@@ -1,74 +1,63 @@
use std::collections::HashMap;
use std::sync::Arc;
use iced::{Background, Border, Length, Theme};
use iced::widget::{container, text, column, row, Space};
use iced::widget::combo_box;
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 tokio::runtime::Runtime;
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 crate::bluetooth::att::{ATTManager, ATTHandles};
use crate::devices::enums::{DeviceData, DeviceInformation, NothingState, DeviceState};
use crate::ui::window::Message;
use tokio::runtime::Runtime;
pub fn nothing_view<'a>(
mac: &'a str,
devices_list: &HashMap<String, DeviceData>,
state: &'a NothingState,
att_manager: Arc<ATTManager>
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()) {
if let Some(DeviceInformation::Nothing(ref nothing_info)) = device.information {
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| {
.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| {
}))
.push(Space::new().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),
}),
Space::new().width(Length::Fill),
text(nothing_info.serial_number.clone()).size(16)
]
)
.push(
iced::widget::row![
text("Firmware Version").size(16).style(
|theme: &Theme| {
])
.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),
}),
Space::new().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 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),
}),
Space::new().width(Length::Fill),
{
let state_clone = state.clone();
let mac = mac.clone();
@@ -82,71 +71,77 @@ pub fn nothing_view<'a>(
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(
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);
}
}
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 {
.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)
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 {
.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)
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)),
}
}
)
selected_background: Background::Color(
theme.palette().primary.scale_alpha(0.3),
),
shadow: Default::default()
})
}
]
.align_y(iced::Alignment::Center)
.align_y(iced::Alignment::Center),
)
.padding(iced::Padding {
top: 5.0,
@@ -154,35 +149,30 @@ pub fn nothing_view<'a>(
left: 18.0,
right: 18.0,
})
.style(
|theme: &Theme| {
.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![
container(column![
noise_control_mode,
Space::with_height(Length::from(20)),
Space::new().height(Length::from(20)),
container(information_col)
.style(
|theme: &Theme| {
.style(|theme: &Theme| {
let mut style = container::Style::default();
style.background = Some(Background::Color(theme.palette().primary.scale_alpha(0.1)));
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)

View File

@@ -4,23 +4,26 @@ use ab_glyph::{Font, ScaleFont};
use ksni::{Icon, ToolTip};
use tokio::sync::mpsc::UnboundedSender;
use crate::bluetooth::aacp::ControlCommandIdentifiers;
use crate::bluetooth::aacp::{BatteryStatus, ControlCommandIdentifiers};
use crate::ui::messages::BluetoothUIMessage;
use crate::utils::get_app_settings_path;
#[derive(Debug)]
pub(crate) struct MyTray {
pub(crate) conversation_detect_enabled: Option<bool>,
pub(crate) battery_l: Option<u8>,
pub(crate) battery_l_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) battery_r: Option<u8>,
pub(crate) battery_r_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) battery_c: Option<u8>,
pub(crate) battery_c_status: Option<crate::bluetooth::aacp::BatteryStatus>,
pub(crate) connected: bool,
pub(crate) listening_mode: Option<u8>,
pub(crate) allow_off_option: Option<u8>,
pub(crate) command_tx: Option<UnboundedSender<(ControlCommandIdentifiers, Vec<u8>)>>,
pub(crate) ui_tx: Option<UnboundedSender<BluetoothUIMessage>>,
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 {
@@ -33,21 +36,27 @@ impl ksni::Tray for MyTray {
fn icon_pixmap(&self) -> Vec<Icon> {
let text = {
let mut levels: Vec<u8> = Vec::new();
if let Some(l) = self.battery_l {
if self.battery_l_status != Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) {
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 {
if self.battery_r_status != Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) {
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(crate::bluetooth::aacp::BatteryStatus::Disconnected) {
// 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)
@@ -55,18 +64,28 @@ impl ksni::Tray for MyTray {
"?".to_string()
}
};
let any_bud_charging = matches!(self.battery_l_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging))
|| matches!(self.battery_r_status, Some(crate::bluetooth::aacp::BatteryStatus::Charging));
let icon = generate_icon(&text, false, any_bud_charging);
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<crate::bluetooth::aacp::BatteryStatus>| -> String {
let format_component =
|label: &str, level: Option<u8>, status: Option<BatteryStatus>| -> String {
match status {
Some(crate::bluetooth::aacp::BatteryStatus::Disconnected) => format!("{}: -", label),
Some(BatteryStatus::Disconnected) => format!("{}: -", label),
_ => {
let pct = level.map(|b| format!("{}%", b)).unwrap_or("?".to_string());
let suffix = if status == Some(crate::bluetooth::aacp::BatteryStatus::Charging) {
let suffix = if status == Some(BatteryStatus::Charging) {
""
} else {
""
@@ -104,9 +123,10 @@ impl ksni::Tray for MyTray {
("Adaptive", 0x04),
]
};
let selected = self.listening_mode.and_then(|mode| {
options.iter().position(|&(_, val)| val == mode)
}).unwrap_or(0);
let selected = self
.listening_mode
.and_then(|mode| options.iter().position(|&(_, val)| val == mode))
.unwrap_or(0);
let options_clone = options.clone();
vec![
StandardItem {
@@ -118,19 +138,26 @@ impl ksni::Tray for MyTray {
}
}),
..Default::default()
}.into(),
}
.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 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 {
options: options
.into_iter()
.map(|(label, _)| RadioItem {
label: label.into(),
..Default::default()
}).collect(),
})
.collect(),
..Default::default()
}
.into(),
@@ -140,14 +167,17 @@ impl ksni::Tray for MyTray {
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 {
if let Some(is_enabled) = this.conversation_detect_enabled {
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]));
let _ = tx.send((
ControlCommandIdentifiers::ConversationDetectConfig,
vec![value],
));
this.conversation_detect_enabled = Some(new_state);
}
}
}),
..Default::default()
}
@@ -212,7 +242,8 @@ fn generate_icon(text: &str, text_mode: bool, charging: bool) -> Icon {
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);
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]));
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockEncrypt, KeyInit};
use aes::Aes128;
use aes::cipher::Array;
use aes::cipher::{BlockCipherEncrypt, KeyInit};
use iced::Theme;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
@@ -8,19 +8,47 @@ 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")
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")
.unwrap_or_else(|_| format!("{}/.config", std::env::var("HOME").unwrap_or_default()));
PathBuf::from(config_dir)
.join("librepods")
.join("preferences.json")
}
pub fn get_app_settings_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_default();
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")
.unwrap_or_else(|_| format!("{}/.config", home));
let data_dir = std::env::var("XDG_DATA_HOME")
.unwrap_or_else(|_| format!("{}/.local/share", home));
let new_path = PathBuf::from(&config_dir)
.join("librepods")
.join("app_settings.json");
let old_path = PathBuf::from(&data_dir)
.join("app_settings.json");
// migrate if needed
if old_path.exists() && !new_path.exists() {
if let Some(parent) = new_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::copy(&old_path, &new_path).is_ok() {
let _ = std::fs::remove_file(&old_path);
}
}
new_path
}
fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
@@ -28,8 +56,8 @@ fn e(key: &[u8; 16], data: &[u8; 16]) -> [u8; 16] {
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);
let cipher = Aes128::new(&Array::from(swapped_key));
let mut block = Array::from(swapped_data);
cipher.encrypt_block(&mut block);
let mut result: [u8; 16] = block.into();
result.reverse();

12
shell.nix Normal file
View File

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