diff --git a/.editorconfig b/.editorconfig index 005f2c7..2b8edd1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,5 +16,5 @@ indent_size = 4 trim_trailing_whitespace = false max_line_length = off -[*.{py,java,r,R,kt,xml,kts}] +[*.{py,java,r,R,kt,xml,kts,h,hpp,cpp,qml}] indent_size = 4 diff --git a/linux/Main.qml b/linux/Main.qml index 983a2a5..4defa9b 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -118,6 +118,14 @@ ApplicationWindow { batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging } + + PodColumn { + visible: airPodsTrayApp.deviceInfo.battery.headsetAvailable + inEar: true + iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon + batteryLevel: airPodsTrayApp.deviceInfo.battery.headsetLevel + isCharging: airPodsTrayApp.deviceInfo.battery.headsetCharging + } } SegmentedControl { @@ -318,4 +326,4 @@ ApplicationWindow { } } } -} \ No newline at end of file +} diff --git a/linux/airpods_packets.h b/linux/airpods_packets.h index 5266272..94153a4 100644 --- a/linux/airpods_packets.h +++ b/linux/airpods_packets.h @@ -113,24 +113,24 @@ namespace AirPodsPackets static const QByteArray HEADER = ControlCommand::HEADER + static_cast(0x2C); static const QByteArray ENABLED = ControlCommand::createCommand(0x2C, 0x01, 0x01); static const QByteArray DISABLED = ControlCommand::createCommand(0x2C, 0x02, 0x02); - + inline std::optional parseState(const QByteArray &data) { if (!data.startsWith(HEADER) || data.size() < HEADER.size() + 2) return std::nullopt; - + QByteArray value = data.mid(HEADER.size(), 2); if (value.size() != 2) return std::nullopt; - + char b1 = value.at(0); char b2 = value.at(1); - + if (b1 == 0x01 && b2 == 0x01) return true; if (b1 == 0x02 || b2 == 0x02) return false; - + return std::nullopt; } } diff --git a/linux/battery.hpp b/linux/battery.hpp index 99119e4..63f30e0 100644 --- a/linux/battery.hpp +++ b/linux/battery.hpp @@ -19,6 +19,9 @@ class Battery : public QObject Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged) Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged) Q_PROPERTY(bool rightPodAvailable READ isRightPodAvailable NOTIFY batteryStatusChanged) + Q_PROPERTY(quint8 headsetLevel READ getHeadsetLevel NOTIFY batteryStatusChanged) + Q_PROPERTY(bool headsetCharging READ isHeadsetCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(bool headsetAvailable READ isHeadsetAvailable NOTIFY batteryStatusChanged) Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged) Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged) Q_PROPERTY(bool caseAvailable READ isCaseAvailable NOTIFY batteryStatusChanged) @@ -32,6 +35,7 @@ public: void reset() { // Initialize all components to unknown state + states[Component::Headset] = {}; states[Component::Left] = {}; states[Component::Right] = {}; states[Component::Case] = {}; @@ -41,6 +45,7 @@ public: // Enum for AirPods components enum class Component { + Headset = 0x01, // AirPods Max Right = 0x02, Left = 0x04, Case = 0x08, @@ -105,7 +110,7 @@ public: } // If this is a pod (Left or Right), add it to the list - if (comp == Component::Left || comp == Component::Right) + if (comp == Component::Left || comp == Component::Right || comp == Component::Headset) { podsInPacket.append(comp); } @@ -117,11 +122,17 @@ public: // Set primary and secondary pods based on order if (!podsInPacket.isEmpty()) { - Component newPrimaryPod = podsInPacket[0]; // First pod is primary - if (newPrimaryPod != primaryPod) - { - primaryPod = newPrimaryPod; + if (podsInPacket.count() == 1 && podsInPacket[0] == Component::Headset) { + // AirPods Max + primaryPod = podsInPacket[0]; emit primaryChanged(); + } else { + Component newPrimaryPod = podsInPacket[0]; // First pod is primary + if (newPrimaryPod != primaryPod) + { + primaryPod = newPrimaryPod; + emit primaryChanged(); + } } } if (podsInPacket.size() >= 2) @@ -132,14 +143,18 @@ public: // Emit signal to notify about battery status change emit batteryStatusChanged(); - // Log which is left and right pod - LOG_INFO("Primary Pod:" << primaryPod); - LOG_INFO("Secondary Pod:" << secondaryPod); + if (primaryPod == Component::Headset) { + LOG_INFO("Primary Pod:" << primaryPod); + } else { + // Log which is left and right pod + LOG_INFO("Primary Pod:" << primaryPod); + LOG_INFO("Secondary Pod:" << secondaryPod); + } return true; } - bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase) + bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase, bool isHeadset) { // Validate packet size (expect 16 bytes based on provided payloads) if (packet.size() != 16) @@ -160,30 +175,42 @@ public: auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte); auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte); auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte); + if (isHeadset) { + int batteries[] = {rawLeftBattery, rawRightBattery, rawCaseBattery}; + bool statuses[] = {isLeftCharging, isRightCharging, isCaseCharging}; + // Find the first battery that isn't CHAR_MAX + auto it = std::find_if(std::begin(batteries), std::end(batteries), [](int i) { return i != CHAR_MAX; }); + if (it != std::end(batteries)) { + std::size_t idx = it - std::begin(batteries); + int battery = *it; + primaryPod = Component::Headset; + states[Component::Headset] = {static_cast(battery), statuses[idx] ? BatteryStatus::Charging : BatteryStatus::Discharging}; + } + } else { + if (rawLeftBattery == CHAR_MAX) { + rawLeftBattery = states.value(Component::Left).level; // Use last valid level + isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging; + } - if (rawLeftBattery == CHAR_MAX) { - rawLeftBattery = states.value(Component::Left).level; // Use last valid level - isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging; - } + if (rawRightBattery == CHAR_MAX) { + rawRightBattery = states.value(Component::Right).level; // Use last valid level + isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging; + } - if (rawRightBattery == CHAR_MAX) { - rawRightBattery = states.value(Component::Right).level; // Use last valid level - isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging; - } + if (rawCaseBattery == CHAR_MAX) { + rawCaseBattery = states.value(Component::Case).level; // Use last valid level + isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging; + } - if (rawCaseBattery == CHAR_MAX) { - rawCaseBattery = states.value(Component::Case).level; // Use last valid level - isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging; + // Update states + states[Component::Left] = {static_cast(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; + states[Component::Right] = {static_cast(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; + if (podInCase) { + states[Component::Case] = {static_cast(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; + } + primaryPod = isLeftPodPrimary ? Component::Left : Component::Right; + secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left; } - - // Update states - states[Component::Left] = {static_cast(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; - states[Component::Right] = {static_cast(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; - if (podInCase) { - states[Component::Case] = {static_cast(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging}; - } - primaryPod = isLeftPodPrimary ? Component::Left : Component::Right; - secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left; emit batteryStatusChanged(); emit primaryChanged(); @@ -236,6 +263,9 @@ public: quint8 getCaseLevel() const { return states.value(Component::Case).level; } bool isCaseCharging() const { return isStatus(Component::Case, BatteryStatus::Charging); } bool isCaseAvailable() const { return !isStatus(Component::Case, BatteryStatus::Disconnected); } + quint8 getHeadsetLevel() const { return states.value(Component::Headset).level; } + bool isHeadsetCharging() const { return isStatus(Component::Headset, BatteryStatus::Charging); } + bool isHeadsetAvailable() const { return !isStatus(Component::Headset, BatteryStatus::Disconnected); } signals: void batteryStatusChanged(); @@ -257,4 +287,4 @@ private: QMap states; Component primaryPod; Component secondaryPod; -}; \ No newline at end of file +}; diff --git a/linux/deviceinfo.hpp b/linux/deviceinfo.hpp index 7a4c7a0..a3ac8af 100644 --- a/linux/deviceinfo.hpp +++ b/linux/deviceinfo.hpp @@ -197,7 +197,12 @@ public: int leftLevel = getBattery()->getState(Battery::Component::Left).level; int rightLevel = getBattery()->getState(Battery::Component::Right).level; int caseLevel = getBattery()->getState(Battery::Component::Case).level; - setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel)); + if (getBattery()->getPrimaryPod() == Battery::Component::Headset) { + int headsetLevel = getBattery()->getState(Battery::Component::Headset).level; + setBatteryStatus(QString("Headset: %1%").arg(headsetLevel)); + } else { + setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel)); + } } signals: @@ -229,4 +234,4 @@ private: QString m_manufacturer; QString m_bluetoothAddress; EarDetection *m_earDetection; -}; \ No newline at end of file +}; diff --git a/linux/enums.h b/linux/enums.h index 347e338..815415d 100644 --- a/linux/enums.h +++ b/linux/enums.h @@ -85,11 +85,23 @@ namespace AirpodsTrayApp return {"podpro.png", "podpro_case.png"}; case AirPodsModel::AirPodsMaxLightning: case AirPodsModel::AirPodsMaxUSBC: - return {"max.png", "max_case.png"}; + return {"podmax.png", "max_case.png"}; default: return {"pod.png", "pod_case.png"}; // Default icon for unknown models } } + // TODO: Only used for parseEncryptedPacket for battery status. Is it possible to determine this + // from the data in the packet rather than by model? i.e number of batteries + inline bool isModelHeadset(AirPodsModel model) { + switch (model) { + case AirPodsModel::AirPodsMaxLightning: + case AirPodsModel::AirPodsMaxUSBC: + return true; + default: + return false; + } + } + } -} \ No newline at end of file +} diff --git a/linux/main.cpp b/linux/main.cpp index 9e8d7b9..39402cd 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -666,7 +666,7 @@ private slots: else if (data.startsWith(AirPodsPackets::Parse::FEATURES_ACK)) { writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: "); - + QTimer::singleShot(2000, this, [this]() { if (m_deviceInfo->batteryStatus().isEmpty()) { writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: "); @@ -718,7 +718,7 @@ private slots: mediaController->handleEarDetection(m_deviceInfo->getEarDetection()); } // Battery Status - else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS)) + else if ((data.size() == 22 || data.size() == 12) && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS)) { m_deviceInfo->getBattery()->parsePacket(data); m_deviceInfo->updateBatteryStatus(); @@ -766,7 +766,7 @@ private slots: } QBluetoothAddress phoneAddress("00:00:00:00:00:00"); // Default address, will be overwritten if PHONE_MAC_ADDRESS is set QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - + if (!env.value("PHONE_MAC_ADDRESS").isEmpty()) { phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS")); @@ -875,7 +875,7 @@ private slots: if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) { m_deviceInfo->setModel(device.modelName); auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey()); - m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase); + m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase, isModelHeadset(m_deviceInfo->model())); m_deviceInfo->getEarDetection()->overrideEarDetectionStatus(device.isPrimaryInEar, device.isSecondaryInEar); } } @@ -991,7 +991,7 @@ int main(int argc, char *argv[]) { sharedMemory.setKey("TcpServer-Key2"); // Check if app is already open - if(sharedMemory.create(1) == false) + if(sharedMemory.create(1) == false) { LOG_INFO("Another instance already running! Opening App Window Instead"); QLocalSocket socket; @@ -1083,7 +1083,7 @@ int main(int argc, char *argv[]) { LOG_ERROR("Failed to connect to the duplicate app instance"); LOG_DEBUG("Connection error: " << socket->errorString()); }); - + // Handle server-level errors QObject::connect(&server, &QLocalServer::serverError, [&]() { LOG_ERROR("Server failed to accept a new connection"); diff --git a/linux/trayiconmanager.cpp b/linux/trayiconmanager.cpp index 0f2a92a..5c81191 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -109,20 +109,21 @@ void TrayIconManager::updateIconFromBattery(const QString &status) { int leftLevel = 0; int rightLevel = 0; + int minLevel = 0; if (!status.isEmpty()) { // Parse the battery status string QStringList parts = status.split(", "); - if (parts.size() >= 2) - { + if (parts.size() >= 2) { leftLevel = parts[0].split(": ")[1].replace("%", "").toInt(); rightLevel = parts[1].split(": ")[1].replace("%", "").toInt(); + minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel + : qMin(leftLevel, rightLevel); + } else if (parts.size() == 1) { + minLevel = parts[0].split(": ")[1].replace("%", "").toInt(); } } - - int minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel - : qMin(leftLevel, rightLevel); QPixmap pixmap(32, 32); pixmap.fill(Qt::transparent);