Skip to content

Commit 8a5d608

Browse files
linux: AirPods Max battery status support (#272)
1 parent f12fe90 commit 8a5d608

File tree

8 files changed

+108
-52
lines changed

8 files changed

+108
-52
lines changed

.editorconfig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@ indent_size = 4
1616
trim_trailing_whitespace = false
1717
max_line_length = off
1818

19-
[*.{py,java,r,R,kt,xml,kts}]
19+
[*.{py,java,r,R,kt,xml,kts,h,hpp,cpp,qml}]
2020
indent_size = 4

linux/Main.qml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ ApplicationWindow {
118118
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
119119
isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging
120120
}
121+
122+
PodColumn {
123+
visible: airPodsTrayApp.deviceInfo.battery.headsetAvailable
124+
inEar: true
125+
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
126+
batteryLevel: airPodsTrayApp.deviceInfo.battery.headsetLevel
127+
isCharging: airPodsTrayApp.deviceInfo.battery.headsetCharging
128+
}
121129
}
122130

123131
SegmentedControl {
@@ -318,4 +326,4 @@ ApplicationWindow {
318326
}
319327
}
320328
}
321-
}
329+
}

linux/airpods_packets.h

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,24 +113,24 @@ namespace AirPodsPackets
113113
static const QByteArray HEADER = ControlCommand::HEADER + static_cast<char>(0x2C);
114114
static const QByteArray ENABLED = ControlCommand::createCommand(0x2C, 0x01, 0x01);
115115
static const QByteArray DISABLED = ControlCommand::createCommand(0x2C, 0x02, 0x02);
116-
116+
117117
inline std::optional<bool> parseState(const QByteArray &data)
118118
{
119119
if (!data.startsWith(HEADER) || data.size() < HEADER.size() + 2)
120120
return std::nullopt;
121-
121+
122122
QByteArray value = data.mid(HEADER.size(), 2);
123123
if (value.size() != 2)
124124
return std::nullopt;
125-
125+
126126
char b1 = value.at(0);
127127
char b2 = value.at(1);
128-
128+
129129
if (b1 == 0x01 && b2 == 0x01)
130130
return true;
131131
if (b1 == 0x02 || b2 == 0x02)
132132
return false;
133-
133+
134134
return std::nullopt;
135135
}
136136
}

linux/battery.hpp

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class Battery : public QObject
1919
Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged)
2020
Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged)
2121
Q_PROPERTY(bool rightPodAvailable READ isRightPodAvailable NOTIFY batteryStatusChanged)
22+
Q_PROPERTY(quint8 headsetLevel READ getHeadsetLevel NOTIFY batteryStatusChanged)
23+
Q_PROPERTY(bool headsetCharging READ isHeadsetCharging NOTIFY batteryStatusChanged)
24+
Q_PROPERTY(bool headsetAvailable READ isHeadsetAvailable NOTIFY batteryStatusChanged)
2225
Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged)
2326
Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged)
2427
Q_PROPERTY(bool caseAvailable READ isCaseAvailable NOTIFY batteryStatusChanged)
@@ -32,6 +35,7 @@ class Battery : public QObject
3235
void reset()
3336
{
3437
// Initialize all components to unknown state
38+
states[Component::Headset] = {};
3539
states[Component::Left] = {};
3640
states[Component::Right] = {};
3741
states[Component::Case] = {};
@@ -41,6 +45,7 @@ class Battery : public QObject
4145
// Enum for AirPods components
4246
enum class Component
4347
{
48+
Headset = 0x01, // AirPods Max
4449
Right = 0x02,
4550
Left = 0x04,
4651
Case = 0x08,
@@ -105,7 +110,7 @@ class Battery : public QObject
105110
}
106111

107112
// If this is a pod (Left or Right), add it to the list
108-
if (comp == Component::Left || comp == Component::Right)
113+
if (comp == Component::Left || comp == Component::Right || comp == Component::Headset)
109114
{
110115
podsInPacket.append(comp);
111116
}
@@ -117,11 +122,17 @@ class Battery : public QObject
117122
// Set primary and secondary pods based on order
118123
if (!podsInPacket.isEmpty())
119124
{
120-
Component newPrimaryPod = podsInPacket[0]; // First pod is primary
121-
if (newPrimaryPod != primaryPod)
122-
{
123-
primaryPod = newPrimaryPod;
125+
if (podsInPacket.count() == 1 && podsInPacket[0] == Component::Headset) {
126+
// AirPods Max
127+
primaryPod = podsInPacket[0];
124128
emit primaryChanged();
129+
} else {
130+
Component newPrimaryPod = podsInPacket[0]; // First pod is primary
131+
if (newPrimaryPod != primaryPod)
132+
{
133+
primaryPod = newPrimaryPod;
134+
emit primaryChanged();
135+
}
125136
}
126137
}
127138
if (podsInPacket.size() >= 2)
@@ -132,14 +143,18 @@ class Battery : public QObject
132143
// Emit signal to notify about battery status change
133144
emit batteryStatusChanged();
134145

135-
// Log which is left and right pod
136-
LOG_INFO("Primary Pod:" << primaryPod);
137-
LOG_INFO("Secondary Pod:" << secondaryPod);
146+
if (primaryPod == Component::Headset) {
147+
LOG_INFO("Primary Pod:" << primaryPod);
148+
} else {
149+
// Log which is left and right pod
150+
LOG_INFO("Primary Pod:" << primaryPod);
151+
LOG_INFO("Secondary Pod:" << secondaryPod);
152+
}
138153

139154
return true;
140155
}
141156

142-
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase)
157+
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase, bool isHeadset)
143158
{
144159
// Validate packet size (expect 16 bytes based on provided payloads)
145160
if (packet.size() != 16)
@@ -160,30 +175,42 @@ class Battery : public QObject
160175
auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte);
161176
auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte);
162177
auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte);
178+
if (isHeadset) {
179+
int batteries[] = {rawLeftBattery, rawRightBattery, rawCaseBattery};
180+
bool statuses[] = {isLeftCharging, isRightCharging, isCaseCharging};
181+
// Find the first battery that isn't CHAR_MAX
182+
auto it = std::find_if(std::begin(batteries), std::end(batteries), [](int i) { return i != CHAR_MAX; });
183+
if (it != std::end(batteries)) {
184+
std::size_t idx = it - std::begin(batteries);
185+
int battery = *it;
186+
primaryPod = Component::Headset;
187+
states[Component::Headset] = {static_cast<quint8>(battery), statuses[idx] ? BatteryStatus::Charging : BatteryStatus::Discharging};
188+
}
189+
} else {
190+
if (rawLeftBattery == CHAR_MAX) {
191+
rawLeftBattery = states.value(Component::Left).level; // Use last valid level
192+
isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging;
193+
}
163194

164-
if (rawLeftBattery == CHAR_MAX) {
165-
rawLeftBattery = states.value(Component::Left).level; // Use last valid level
166-
isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging;
167-
}
168-
169-
if (rawRightBattery == CHAR_MAX) {
170-
rawRightBattery = states.value(Component::Right).level; // Use last valid level
171-
isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging;
172-
}
195+
if (rawRightBattery == CHAR_MAX) {
196+
rawRightBattery = states.value(Component::Right).level; // Use last valid level
197+
isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging;
198+
}
173199

174-
if (rawCaseBattery == CHAR_MAX) {
175-
rawCaseBattery = states.value(Component::Case).level; // Use last valid level
176-
isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging;
177-
}
200+
if (rawCaseBattery == CHAR_MAX) {
201+
rawCaseBattery = states.value(Component::Case).level; // Use last valid level
202+
isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging;
203+
}
178204

179-
// Update states
180-
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
181-
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
182-
if (podInCase) {
183-
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
205+
// Update states
206+
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
207+
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
208+
if (podInCase) {
209+
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
210+
}
211+
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
212+
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
184213
}
185-
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
186-
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
187214
emit batteryStatusChanged();
188215
emit primaryChanged();
189216

@@ -236,6 +263,9 @@ class Battery : public QObject
236263
quint8 getCaseLevel() const { return states.value(Component::Case).level; }
237264
bool isCaseCharging() const { return isStatus(Component::Case, BatteryStatus::Charging); }
238265
bool isCaseAvailable() const { return !isStatus(Component::Case, BatteryStatus::Disconnected); }
266+
quint8 getHeadsetLevel() const { return states.value(Component::Headset).level; }
267+
bool isHeadsetCharging() const { return isStatus(Component::Headset, BatteryStatus::Charging); }
268+
bool isHeadsetAvailable() const { return !isStatus(Component::Headset, BatteryStatus::Disconnected); }
239269

240270
signals:
241271
void batteryStatusChanged();
@@ -257,4 +287,4 @@ class Battery : public QObject
257287
QMap<Component, BatteryState> states;
258288
Component primaryPod;
259289
Component secondaryPod;
260-
};
290+
};

linux/deviceinfo.hpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,12 @@ class DeviceInfo : public QObject
197197
int leftLevel = getBattery()->getState(Battery::Component::Left).level;
198198
int rightLevel = getBattery()->getState(Battery::Component::Right).level;
199199
int caseLevel = getBattery()->getState(Battery::Component::Case).level;
200-
setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel));
200+
if (getBattery()->getPrimaryPod() == Battery::Component::Headset) {
201+
int headsetLevel = getBattery()->getState(Battery::Component::Headset).level;
202+
setBatteryStatus(QString("Headset: %1%").arg(headsetLevel));
203+
} else {
204+
setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel));
205+
}
201206
}
202207

203208
signals:
@@ -229,4 +234,4 @@ class DeviceInfo : public QObject
229234
QString m_manufacturer;
230235
QString m_bluetoothAddress;
231236
EarDetection *m_earDetection;
232-
};
237+
};

linux/enums.h

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,23 @@ namespace AirpodsTrayApp
8585
return {"podpro.png", "podpro_case.png"};
8686
case AirPodsModel::AirPodsMaxLightning:
8787
case AirPodsModel::AirPodsMaxUSBC:
88-
return {"max.png", "max_case.png"};
88+
return {"podmax.png", "max_case.png"};
8989
default:
9090
return {"pod.png", "pod_case.png"}; // Default icon for unknown models
9191
}
9292
}
9393

94+
// TODO: Only used for parseEncryptedPacket for battery status. Is it possible to determine this
95+
// from the data in the packet rather than by model? i.e number of batteries
96+
inline bool isModelHeadset(AirPodsModel model) {
97+
switch (model) {
98+
case AirPodsModel::AirPodsMaxLightning:
99+
case AirPodsModel::AirPodsMaxUSBC:
100+
return true;
101+
default:
102+
return false;
103+
}
104+
}
105+
94106
}
95-
}
107+
}

linux/main.cpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ private slots:
666666
else if (data.startsWith(AirPodsPackets::Parse::FEATURES_ACK))
667667
{
668668
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
669-
669+
670670
QTimer::singleShot(2000, this, [this]() {
671671
if (m_deviceInfo->batteryStatus().isEmpty()) {
672672
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
@@ -718,7 +718,7 @@ private slots:
718718
mediaController->handleEarDetection(m_deviceInfo->getEarDetection());
719719
}
720720
// Battery Status
721-
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
721+
else if ((data.size() == 22 || data.size() == 12) && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
722722
{
723723
m_deviceInfo->getBattery()->parsePacket(data);
724724
m_deviceInfo->updateBatteryStatus();
@@ -766,7 +766,7 @@ private slots:
766766
}
767767
QBluetoothAddress phoneAddress("00:00:00:00:00:00"); // Default address, will be overwritten if PHONE_MAC_ADDRESS is set
768768
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
769-
769+
770770
if (!env.value("PHONE_MAC_ADDRESS").isEmpty())
771771
{
772772
phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS"));
@@ -875,7 +875,7 @@ private slots:
875875
if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) {
876876
m_deviceInfo->setModel(device.modelName);
877877
auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey());
878-
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase);
878+
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase, isModelHeadset(m_deviceInfo->model()));
879879
m_deviceInfo->getEarDetection()->overrideEarDetectionStatus(device.isPrimaryInEar, device.isSecondaryInEar);
880880
}
881881
}
@@ -991,7 +991,7 @@ int main(int argc, char *argv[]) {
991991
sharedMemory.setKey("TcpServer-Key2");
992992

993993
// Check if app is already open
994-
if(sharedMemory.create(1) == false)
994+
if(sharedMemory.create(1) == false)
995995
{
996996
LOG_INFO("Another instance already running! Opening App Window Instead");
997997
QLocalSocket socket;
@@ -1083,7 +1083,7 @@ int main(int argc, char *argv[]) {
10831083
LOG_ERROR("Failed to connect to the duplicate app instance");
10841084
LOG_DEBUG("Connection error: " << socket->errorString());
10851085
});
1086-
1086+
10871087
// Handle server-level errors
10881088
QObject::connect(&server, &QLocalServer::serverError, [&]() {
10891089
LOG_ERROR("Server failed to accept a new connection");

linux/trayiconmanager.cpp

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,21 @@ void TrayIconManager::updateIconFromBattery(const QString &status)
109109
{
110110
int leftLevel = 0;
111111
int rightLevel = 0;
112+
int minLevel = 0;
112113

113114
if (!status.isEmpty())
114115
{
115116
// Parse the battery status string
116117
QStringList parts = status.split(", ");
117-
if (parts.size() >= 2)
118-
{
118+
if (parts.size() >= 2) {
119119
leftLevel = parts[0].split(": ")[1].replace("%", "").toInt();
120120
rightLevel = parts[1].split(": ")[1].replace("%", "").toInt();
121+
minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel
122+
: qMin(leftLevel, rightLevel);
123+
} else if (parts.size() == 1) {
124+
minLevel = parts[0].split(": ")[1].replace("%", "").toInt();
121125
}
122126
}
123-
124-
int minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel
125-
: qMin(leftLevel, rightLevel);
126127

127128
QPixmap pixmap(32, 32);
128129
pixmap.fill(Qt::transparent);

0 commit comments

Comments
 (0)