Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion head-tracking/gestures.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import argparse
import logging
import statistics
import time
Expand Down Expand Up @@ -347,6 +348,10 @@ def start_detection(self) -> None:
log.info(f"{Colors.GREEN}Gesture detection complete.{Colors.RESET}")

if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Detect AirPods head gestures")
parser.add_argument("mac_address", nargs="?", help="Bluetooth MAC address of the AirPods")
args = parser.parse_args()

print(f"{Colors.BG_BLACK}{Colors.CYAN}╔════════════════════════════════════════╗{Colors.RESET}")
print(f"{Colors.BG_BLACK}{Colors.CYAN}║ AirPods Head Gesture Detector ║{Colors.RESET}")
print(f"{Colors.BG_BLACK}{Colors.CYAN}╚════════════════════════════════════════╝{Colors.RESET}")
Expand All @@ -355,4 +360,6 @@ def start_detection(self) -> None:
print(f"{Colors.RED}• NO: {Colors.WHITE}shaking head left and right{Colors.RESET}\n")

detector: GestureDetector = GestureDetector()
detector.start_detection()
if args.mac_address:
detector.bt_addr = args.mac_address
detector.start_detection()
8 changes: 8 additions & 0 deletions linux/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ qt_standard_project_setup()
# Translation files
set(TS_FILES
translations/librepods_tr.ts
translations/librepods_es.ts
)

qt_add_executable(librepods
Expand Down Expand Up @@ -100,6 +101,13 @@ install(FILES assets/me.kavishdevar.librepods.desktop
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications")
install(FILES assets/librepods.svg
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps")
install(FILES hearing-aid-adjustments.py
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/librepods")
install(FILES
../head-tracking/gestures.py
../head-tracking/connection_manager.py
../head-tracking/colors.py
DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/librepods/head-tracking")

# Translation support
qt_add_translations(librepods
Expand Down
1,360 changes: 1,218 additions & 142 deletions linux/Main.qml

Large diffs are not rendered by default.

35 changes: 33 additions & 2 deletions linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,10 @@ librepods-ctl noise:transparency

## Hearing Aid

To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. But, to adjust the settings and set the audiogram, you need to use a different script which is located in this folder as `hearing_aid.py`. You can run it with:
To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. The Linux UI also exposes a button to launch the advanced adjustments tool for the currently connected AirPods. Under the hood it uses the separate script in this folder, `hearing-aid-adjustments.py`, which you can also run manually with:

```bash
python3 hearing_aid.py
python3 hearing-aid-adjustments.py <MAC_ADDRESS>
```

The script will load the current settings from the AirPods and allow you to adjust them. You can set the audiogram by providing the values for 8 frequencies (250Hz, 500Hz, 1kHz, 2kHz, 3kHz, 4kHz, 6kHz, 8kHz) for both left and right ears. There are also options to adjust amplification, balance, tone, ambient noise reduction, own voice amplification, and conversation boost.
Expand All @@ -189,3 +189,34 @@ It is possible that the AirPods disconnect after a short period of time and play
### Why a separate script?

Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application.

## Head Tracking / Gestures

Linux now exposes the existing head gesture detector from the `head-tracking/` folder through the Settings UI. When AirPods are connected, use `Open Head Gesture Detector` to launch the Python script in a terminal for the current Bluetooth MAC address.

You can also run it manually:

```bash
python3 head-tracking/gestures.py <MAC_ADDRESS>
```

Requirements:

- Python 3
- A terminal emulator available in `PATH`
- The Python bluetooth dependency used by the head-tracking scripts (`pybluez` from `head-tracking/requirements.txt`)

## Multi-Device / Handoff

Linux already includes a Bluetooth relay to the Android app using the phone MAC address and the cross-device UUID `1abbb9a4-10e4-4000-a75c-8953c5471342`. The Settings UI now makes that flow safer to validate:

- The phone MAC can be configured even before AirPods are connected.
- The UI shows whether the phone relay is currently connected.
- You can manually retry the phone relay connection.
- You can fetch Magic Cloud Keys from connected AirPods and only open the QR export once both keys are available.

Current limitations:

- Linux still depends on the Android app implementing the server side of the relay protocol.
- Linux can request Magic Cloud Keys and export them as QR, but it does not import them back from a QR or push them to Android automatically.
- The existing handoff protocol is still packet relay plus disconnect requests; this phase does not redesign the transport.
34 changes: 26 additions & 8 deletions linux/SegmentedControl.qml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ import QtQuick.Controls 2.15
Control {
id: root

signal activated(int index)

// Properties
property var model: ["Option 1", "Option 2"] // Default model
property int currentIndex: 0
property color accentColor: palette.highlight
property color disabledBackgroundColor: "#eef1f5"
property color disabledSelectedColor: "#b9c4d2"
property color disabledTextColor: "#8b96a3"

// Colors using system palette
readonly property color backgroundColor: palette.light
readonly property color selectedColor: palette.highlight
readonly property color textColor: palette.buttonText
readonly property color backgroundColor: root.enabled ? palette.light : root.disabledBackgroundColor
readonly property color selectedColor: root.enabled ? root.accentColor : root.disabledSelectedColor
readonly property color textColor: root.enabled ? palette.buttonText : root.disabledTextColor
readonly property color selectedTextColor: palette.highlightedText

// System palette
Expand Down Expand Up @@ -52,6 +58,7 @@ Control {
// Removed: width: (root.availableWidth - (root.model.length - 1) * root.padding) / root.model.length
height: root.availableHeight
focusPolicy: Qt.NoFocus // Let the root control handle focus
enabled: root.enabled

// Add explicit text color
contentItem: Text {
Expand Down Expand Up @@ -81,6 +88,7 @@ Control {
onClicked: {
if (root.currentIndex !== index) {
root.currentIndex = index;
root.activated(index);
}
}
}
Expand All @@ -92,23 +100,33 @@ Control {
if (event.key === Qt.Key_Left) {
if (root.currentIndex > 0) {
root.currentIndex--;
root.activated(root.currentIndex);
event.accepted = true;
}
} else if (event.key === Qt.Key_Right) {
if (root.currentIndex < root.model.length - 1) {
root.currentIndex++;
root.activated(root.currentIndex);
event.accepted = true;
}
} else if (event.key === Qt.Key_Home) {
root.currentIndex = 0;
event.accepted = true;
if (root.currentIndex !== 0) {
root.currentIndex = 0;
root.activated(root.currentIndex);
event.accepted = true;
}
} else if (event.key === Qt.Key_End) {
root.currentIndex = root.model.length - 1;
event.accepted = true;
const lastIndex = root.model.length - 1;
if (root.currentIndex !== lastIndex) {
root.currentIndex = lastIndex;
root.activated(root.currentIndex);
event.accepted = true;
}
} else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) {
const index = event.key - Qt.Key_1;
if (index <= root.model.length) {
if (index < root.model.length && root.currentIndex !== index) {
root.currentIndex = index;
root.activated(root.currentIndex);
event.accepted = true;
}
}
Expand Down
95 changes: 95 additions & 0 deletions linux/airpods_packets.h
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,101 @@ namespace AirPodsPackets
}
}

// Case Charging Sounds (AirPods Pro 2 / AirPods 4 only)
namespace CaseChargingSounds
{
static QByteArray getPacket(bool enabled)
{
// 12 3A 00 01 00 08 [00=On, 01=Off]
QByteArray packet = QByteArray::fromHex("123A00010008");
packet.append(enabled ? static_cast<char>(0x00) : static_cast<char>(0x01));
return packet;
}
}

// Stem Long Press Configuration
// Bitmask: bit0=Off(0x01), bit1=ANC(0x02), bit2=Transparency(0x04), bit3=Adaptive(0x08)
// Min 2 bits must be set. Must be re-sent on every connection.
namespace StemLongPress
{
static const QByteArray HEADER = ControlCommand::HEADER + static_cast<char>(0x1A);
static const quint8 BIT_OFF = 0x01;
static const quint8 BIT_ANC = 0x02;
static const quint8 BIT_TRANSPARENCY = 0x04;
static const quint8 BIT_ADAPTIVE = 0x08;

static QByteArray getPacket(quint8 modes)
{
return ControlCommand::createCommand(0x1A, modes);
}

inline std::optional<quint8> parseModes(const QByteArray &data)
{
if (!data.startsWith(HEADER) || data.size() < 8)
return std::nullopt;
return static_cast<quint8>(data.at(7));
}
}

// Customize Transparency Mode (per-bud EQ + parameters as IEEE 754 floats LE)
namespace CustomizeTransparency
{
static const QByteArray HEADER = QByteArray::fromHex("121800");

struct BudSettings {
float eq[8] = {0,0,0,0,0,0,0,0}; // 0-100
float amplification = 0.0f; // 0-2
float tone = 0.0f; // 0-2
float conversationBoost = 0.0f; // 0 or 1
float ambientNoise = 0.0f; // 0-1
};

static QByteArray getPacket(bool enabled, const BudSettings &left, const BudSettings &right)
{
QByteArray packet = HEADER;

auto appendF = [&](float f) {
char bytes[4];
memcpy(bytes, &f, 4);
packet.append(bytes, 4);
};

appendF(enabled ? 1.0f : 0.0f);

for (const BudSettings *b : {&left, &right}) {
for (int i = 0; i < 8; i++) appendF(b->eq[i]);
appendF(b->amplification);
appendF(b->tone);
appendF(b->conversationBoost);
appendF(b->ambientNoise);
}

return packet;
}
}

// Headphone Accommodation (8-band EQ for Phone/Media, uint16 LE per band, triplicated)
namespace HeadphoneAccommodation
{
static QByteArray getPacket(bool phoneEnabled, bool mediaEnabled, const QList<int> &eq8)
{
QByteArray packet = QByteArray::fromHex("04000400530084000202");
packet.append(phoneEnabled ? static_cast<char>(0x01) : static_cast<char>(0x02));
packet.append(mediaEnabled ? static_cast<char>(0x01) : static_cast<char>(0x02));

QByteArray eqBytes;
for (int i = 0; i < 8; i++) {
quint16 v = static_cast<quint16>((i < eq8.size()) ? eq8[i] : 0);
eqBytes.append(static_cast<char>(v & 0xFF));
eqBytes.append(static_cast<char>((v >> 8) & 0xFF));
}
packet.append(eqBytes);
packet.append(eqBytes);
packet.append(eqBytes);
return packet;
}
}

// Parsing Headers
namespace Parse
{
Expand Down
Loading