Skip to content

Commit c4633d6

Browse files
authored
[Linux] Read AirPods state from BLE broadcast when not connected (#138)
* Fix possible build error * [Linux] Read AirPods state from BLE broadcast when not connected * SImplify * Remove old code * Remove old code * Maintain charging state when state is unknown * Simplify * Remove unused var
1 parent 5dc7e51 commit c4633d6

File tree

14 files changed

+416
-617
lines changed

14 files changed

+416
-617
lines changed

linux/CMakeLists.txt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ project(linux VERSION 0.1 LANGUAGES CXX)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)
66

77
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
8+
find_package(OpenSSL REQUIRED)
89

910
qt_standard_project_setup(REQUIRES 6.4)
1011

1112
qt_add_executable(applinux
1213
main.cpp
13-
main.h
1414
logger.h
1515
mediacontroller.cpp
1616
mediacontroller.h
@@ -24,6 +24,10 @@ qt_add_executable(applinux
2424
autostartmanager.hpp
2525
BasicControlCommand.hpp
2626
deviceinfo.hpp
27+
ble/bleutils.cpp
28+
ble/bleutils.h
29+
ble/blemanager.cpp
30+
ble/blemanager.h
2731
)
2832

2933
qt_add_qml_module(applinux
@@ -54,7 +58,7 @@ qt_add_resources(applinux "resources"
5458
)
5559

5660
target_link_libraries(applinux
57-
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus
61+
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto
5862
)
5963

6064
include(GNUInstallDirs)

linux/airpods_packets.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <QByteArray>
66
#include <optional>
7+
#include <climits>
78

89
#include "enums.h"
910
#include "BasicControlCommand.hpp"

linux/battery.hpp

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,58 @@ class Battery : public QObject
130130
return true;
131131
}
132132

133+
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary)
134+
{
135+
// Validate packet size (expect 16 bytes based on provided payloads)
136+
if (packet.size() != 16)
137+
{
138+
return false;
139+
}
140+
141+
// Determine byte indices based on isFlipped
142+
int leftByteIndex = isLeftPodPrimary ? 1 : 2;
143+
int rightByteIndex = isLeftPodPrimary ? 2 : 1;
144+
145+
// Extract raw battery bytes
146+
unsigned char rawLeftBatteryByte = static_cast<unsigned char>(packet.at(leftByteIndex));
147+
unsigned char rawRightBatteryByte = static_cast<unsigned char>(packet.at(rightByteIndex));
148+
unsigned char rawCaseBatteryByte = static_cast<unsigned char>(packet.at(3));
149+
150+
// Extract battery data (charging status and raw level 0-127)
151+
auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte);
152+
auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte);
153+
auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte);
154+
155+
// If raw byte is 0xFF or (0x7F and charging), use the last known level
156+
if (rawLeftBatteryByte == 0xFF || (rawLeftBatteryByte == 0x7F && isLeftCharging)) {
157+
rawLeftBatteryByte = states.value(Component::Left).level; // Use last valid level
158+
isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging;
159+
}
160+
161+
// If raw byte is 0xFF or (0x7F and charging), use the last known level
162+
if (rawRightBatteryByte == 0xFF || (rawRightBatteryByte == 0x7F && isRightCharging)) {
163+
rawRightBattery = states.value(Component::Right).level; // Use last valid level
164+
isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging;
165+
}
166+
167+
// If raw byte is 0xFF or (0x7F and charging), use the last known level
168+
if (rawCaseBatteryByte == 0xFF || (rawCaseBatteryByte == 0x7F && isCaseCharging)) {
169+
rawCaseBattery = states.value(Component::Case).level; // Use last valid level
170+
isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging;
171+
}
172+
173+
// Update states
174+
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
175+
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
176+
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
177+
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
178+
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
179+
emit batteryStatusChanged();
180+
emit primaryChanged();
181+
182+
return true;
183+
}
184+
133185
// Get the raw state for a component
134186
BatteryState getState(Component comp) const
135187
{
@@ -187,7 +239,14 @@ class Battery : public QObject
187239
return states.value(component).status == status;
188240
}
189241

242+
std::pair<bool, int> formatBattery(unsigned char byteVal)
243+
{
244+
bool charging = (byteVal & 0x80) != 0;
245+
int level = byteVal & 0x7F;
246+
return std::make_pair(charging, level);
247+
}
248+
190249
QMap<Component, BatteryState> states;
191250
Component primaryPod;
192251
Component secondaryPod;
193-
};
252+
};

linux/ble/CMakeLists.txt

Lines changed: 0 additions & 29 deletions
This file was deleted.

linux/ble/blemanager.cpp

Lines changed: 98 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,86 @@
11
#include "blemanager.h"
2+
#include "enums.h"
23
#include <QDebug>
34
#include <QTimer>
5+
#include "logger.h"
6+
#include <QMap>
7+
8+
AirpodsTrayApp::Enums::AirPodsModel getModelName(quint16 modelId)
9+
{
10+
using namespace AirpodsTrayApp::Enums;
11+
static const QMap<quint16, AirPodsModel> modelMap = {
12+
{0x0220, AirPodsModel::AirPods1},
13+
{0x0F20, AirPodsModel::AirPods2},
14+
{0x1320, AirPodsModel::AirPods3},
15+
{0x1920, AirPodsModel::AirPods4},
16+
{0x1B20, AirPodsModel::AirPods4ANC},
17+
{0x0A20, AirPodsModel::AirPodsMaxLightning},
18+
{0x1F20, AirPodsModel::AirPodsMaxUSBC},
19+
{0x0E20, AirPodsModel::AirPodsPro},
20+
{0x1420, AirPodsModel::AirPodsPro2Lightning},
21+
{0x2420, AirPodsModel::AirPodsPro2USBC}
22+
};
23+
24+
return modelMap.value(modelId, AirPodsModel::Unknown);
25+
}
26+
27+
QString getColorName(quint8 colorId)
28+
{
29+
switch (colorId)
30+
{
31+
case 0x00:
32+
return "White";
33+
case 0x01:
34+
return "Black";
35+
case 0x02:
36+
return "Red";
37+
case 0x03:
38+
return "Blue";
39+
case 0x04:
40+
return "Pink";
41+
case 0x05:
42+
return "Gray";
43+
case 0x06:
44+
return "Silver";
45+
case 0x07:
46+
return "Gold";
47+
case 0x08:
48+
return "Rose Gold";
49+
case 0x09:
50+
return "Space Gray";
51+
case 0x0A:
52+
return "Dark Blue";
53+
case 0x0B:
54+
return "Light Blue";
55+
case 0x0C:
56+
return "Yellow";
57+
default:
58+
return "Unknown";
59+
}
60+
}
61+
62+
QString getConnectionStateName(BleInfo::ConnectionState state)
63+
{
64+
using ConnectionState = BleInfo::ConnectionState;
65+
switch (state)
66+
{
67+
case ConnectionState::DISCONNECTED:
68+
return QString("Disconnected");
69+
case ConnectionState::IDLE:
70+
return QString("Idle");
71+
case ConnectionState::MUSIC:
72+
return QString("Playing Music");
73+
case ConnectionState::CALL:
74+
return QString("On Call");
75+
case ConnectionState::RINGING:
76+
return QString("Ringing");
77+
case ConnectionState::HANGING_UP:
78+
return QString("Hanging Up");
79+
case ConnectionState::UNKNOWN:
80+
default:
81+
return QString("Unknown");
82+
}
83+
}
484

585
BleManager::BleManager(QObject *parent) : QObject(parent)
686
{
@@ -13,38 +93,25 @@ BleManager::BleManager(QObject *parent) : QObject(parent)
1393
this, &BleManager::onScanFinished);
1494
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred,
1595
this, &BleManager::onErrorOccurred);
16-
17-
// Set up pruning timer
18-
pruneTimer = new QTimer(this);
19-
connect(pruneTimer, &QTimer::timeout, this, &BleManager::pruneOldDevices);
20-
pruneTimer->start(PRUNE_INTERVAL_MS); // Start timer (runs every 5 seconds)
2196
}
2297

2398
BleManager::~BleManager()
2499
{
25100
delete discoveryAgent;
26-
delete pruneTimer;
27101
}
28102

29103
void BleManager::startScan()
30104
{
31-
qDebug() << "Starting BLE scan...";
32-
devices.clear();
105+
LOG_DEBUG("Starting BLE scan...");
33106
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
34-
pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running
35107
}
36108

37109
void BleManager::stopScan()
38110
{
39-
qDebug() << "Stopping BLE scan...";
111+
LOG_DEBUG("Stopping BLE scan...");
40112
discoveryAgent->stop();
41113
}
42114

43-
const QMap<QString, DeviceInfo> &BleManager::getDevices() const
44-
{
45-
return devices;
46-
}
47-
48115
void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
49116
{
50117
// Check for Apple's manufacturer ID (0x004C)
@@ -55,10 +122,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
55122
if (data.size() >= 10 && data[0] == 0x07)
56123
{
57124
QString address = info.address().toString();
58-
DeviceInfo deviceInfo;
125+
BleInfo deviceInfo;
59126
deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name();
60127
deviceInfo.address = address;
61-
deviceInfo.rawData = data;
128+
deviceInfo.rawData = data.left(data.size() - 16);
129+
deviceInfo.encryptedPayload = data.mid(data.size() - 16);
62130

63131
// data[1] is the length of the data, so we can skip it
64132

@@ -68,8 +136,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
68136
return; // Skip pairing mode devices (the values are differently structured)
69137
}
70138

139+
71140
// Parse device model (big-endian: high byte at data[3], low byte at data[4])
72-
deviceInfo.deviceModel = static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8);
141+
deviceInfo.modelName = getModelName(static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8));
73142

74143
// Status byte for primary pod and other flags
75144
quint8 status = static_cast<quint8>(data[5]);
@@ -83,16 +152,18 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
83152

84153
// Lid open counter and device color
85154
quint8 lidIndicator = static_cast<quint8>(data[8]);
86-
deviceInfo.deviceColor = static_cast<quint8>(data[9]);
155+
deviceInfo.color = getColorName((quint8)(data[9]));
87156

88-
deviceInfo.connectionState = static_cast<DeviceInfo::ConnectionState>(data[10]);
157+
deviceInfo.connectionState = static_cast<BleInfo::ConnectionState>(data[10]);
89158

90159
// Next: Encrypted Payload: 16 bytes
91160

92161
// Determine primary pod (bit 5 of status) and value flipping
93162
bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary
94163
bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary
95164

165+
deviceInfo.primaryLeft = primaryLeft; // Store primary pod information
166+
96167
// Parse battery levels
97168
int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F;
98169
int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F;
@@ -117,34 +188,30 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
117188
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
118189
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
119190

191+
// Determine primary and secondary in-ear status
192+
deviceInfo.isPrimaryInEar = primaryLeft ? deviceInfo.isLeftPodInEar : deviceInfo.isRightPodInEar;
193+
deviceInfo.isSecondaryInEar = primaryLeft ? deviceInfo.isRightPodInEar : deviceInfo.isLeftPodInEar;
194+
120195
// Microphone status
121196
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
122197
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
123198

124199
deviceInfo.lidOpenCounter = lidIndicator & 0x07; // Extract bits 0-2 (count)
125200
quint8 lidState = static_cast<quint8>((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state)
126201
if (deviceInfo.isThisPodInTheCase) {
127-
deviceInfo.lidState = static_cast<DeviceInfo::LidState>(lidState);
202+
deviceInfo.lidState = static_cast<BleInfo::LidState>(lidState);
128203
}
129204

130205
// Update timestamp
131206
deviceInfo.lastSeen = QDateTime::currentDateTime();
132207

133-
// Store device info in the map
134-
devices[address] = deviceInfo;
135-
136-
// Debug output
137-
qDebug() << "Found device:" << deviceInfo.name
138-
<< "Left:" << (deviceInfo.leftPodBattery >= 0 ? QString("%1%").arg(deviceInfo.leftPodBattery) : "N/A")
139-
<< "Right:" << (deviceInfo.rightPodBattery >= 0 ? QString("%1%").arg(deviceInfo.rightPodBattery) : "N/A")
140-
<< "Case:" << (deviceInfo.caseBattery >= 0 ? QString("%1%").arg(deviceInfo.caseBattery) : "N/A");
208+
emit deviceFound(deviceInfo); // Emit signal for device found
141209
}
142210
}
143211
}
144212

145213
void BleManager::onScanFinished()
146214
{
147-
qDebug() << "Scan finished.";
148215
if (discoveryAgent->isActive())
149216
{
150217
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
@@ -153,24 +220,6 @@ void BleManager::onScanFinished()
153220

154221
void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error)
155222
{
156-
qDebug() << "Error occurred:" << error;
223+
LOG_ERROR("BLE scan error occurred:" << error);
157224
stopScan();
158225
}
159-
160-
void BleManager::pruneOldDevices()
161-
{
162-
QDateTime now = QDateTime::currentDateTime();
163-
auto it = devices.begin();
164-
while (it != devices.end())
165-
{
166-
if (it.value().lastSeen.msecsTo(now) > DEVICE_TIMEOUT_MS)
167-
{
168-
qDebug() << "Removing old device:" << it.value().name << "at" << it.key();
169-
it = devices.erase(it); // Remove device if not seen recently
170-
}
171-
else
172-
{
173-
++it;
174-
}
175-
}
176-
}

0 commit comments

Comments
 (0)