From c4633d6871107da40b1119137c1c98e242c0b55d Mon Sep 17 00:00:00 2001 From: Tim Gromeyer <58736434+tim-gromeyer@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:10:55 +0200 Subject: [PATCH] [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 --- linux/CMakeLists.txt | 8 +- linux/airpods_packets.h | 1 + linux/battery.hpp | 61 +++++- linux/ble/CMakeLists.txt | 29 --- linux/ble/blemanager.cpp | 147 ++++++++++----- linux/ble/blemanager.h | 27 ++- linux/ble/blescanner.cpp | 398 --------------------------------------- linux/ble/blescanner.h | 61 ------ linux/ble/bleutils.cpp | 138 ++++++++++++++ linux/ble/bleutils.h | 52 +++++ linux/ble/main.cpp | 10 - linux/deviceinfo.hpp | 25 +-- linux/main.cpp | 40 +++- linux/main.h | 36 ---- 14 files changed, 416 insertions(+), 617 deletions(-) delete mode 100644 linux/ble/CMakeLists.txt delete mode 100644 linux/ble/blescanner.cpp delete mode 100644 linux/ble/blescanner.h create mode 100644 linux/ble/bleutils.cpp create mode 100644 linux/ble/bleutils.h delete mode 100644 linux/ble/main.cpp delete mode 100644 linux/main.h diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index cae68ef..7a5988a 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -5,12 +5,12 @@ project(linux VERSION 0.1 LANGUAGES CXX) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus) +find_package(OpenSSL REQUIRED) qt_standard_project_setup(REQUIRES 6.4) qt_add_executable(applinux main.cpp - main.h logger.h mediacontroller.cpp mediacontroller.h @@ -24,6 +24,10 @@ qt_add_executable(applinux autostartmanager.hpp BasicControlCommand.hpp deviceinfo.hpp + ble/bleutils.cpp + ble/bleutils.h + ble/blemanager.cpp + ble/blemanager.h ) qt_add_qml_module(applinux @@ -54,7 +58,7 @@ qt_add_resources(applinux "resources" ) target_link_libraries(applinux - PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus + PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto ) include(GNUInstallDirs) diff --git a/linux/airpods_packets.h b/linux/airpods_packets.h index 58e1361..2d4d691 100644 --- a/linux/airpods_packets.h +++ b/linux/airpods_packets.h @@ -4,6 +4,7 @@ #include #include +#include #include "enums.h" #include "BasicControlCommand.hpp" diff --git a/linux/battery.hpp b/linux/battery.hpp index a8a95a2..64395fd 100644 --- a/linux/battery.hpp +++ b/linux/battery.hpp @@ -130,6 +130,58 @@ public: return true; } + bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary) + { + // Validate packet size (expect 16 bytes based on provided payloads) + if (packet.size() != 16) + { + return false; + } + + // Determine byte indices based on isFlipped + int leftByteIndex = isLeftPodPrimary ? 1 : 2; + int rightByteIndex = isLeftPodPrimary ? 2 : 1; + + // Extract raw battery bytes + unsigned char rawLeftBatteryByte = static_cast(packet.at(leftByteIndex)); + unsigned char rawRightBatteryByte = static_cast(packet.at(rightByteIndex)); + unsigned char rawCaseBatteryByte = static_cast(packet.at(3)); + + // Extract battery data (charging status and raw level 0-127) + auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte); + auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte); + auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte); + + // If raw byte is 0xFF or (0x7F and charging), use the last known level + if (rawLeftBatteryByte == 0xFF || (rawLeftBatteryByte == 0x7F && isLeftCharging)) { + rawLeftBatteryByte = states.value(Component::Left).level; // Use last valid level + isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging; + } + + // If raw byte is 0xFF or (0x7F and charging), use the last known level + if (rawRightBatteryByte == 0xFF || (rawRightBatteryByte == 0x7F && isRightCharging)) { + rawRightBattery = states.value(Component::Right).level; // Use last valid level + isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging; + } + + // If raw byte is 0xFF or (0x7F and charging), use the last known level + if (rawCaseBatteryByte == 0xFF || (rawCaseBatteryByte == 0x7F && isCaseCharging)) { + 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}; + 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(); + + return true; + } + // Get the raw state for a component BatteryState getState(Component comp) const { @@ -187,7 +239,14 @@ private: return states.value(component).status == status; } + std::pair formatBattery(unsigned char byteVal) + { + bool charging = (byteVal & 0x80) != 0; + int level = byteVal & 0x7F; + return std::make_pair(charging, level); + } + QMap states; Component primaryPod; Component secondaryPod; -}; +}; \ No newline at end of file diff --git a/linux/ble/CMakeLists.txt b/linux/ble/CMakeLists.txt deleted file mode 100644 index 43964b4..0000000 --- a/linux/ble/CMakeLists.txt +++ /dev/null @@ -1,29 +0,0 @@ -cmake_minimum_required(VERSION 3.16) - -project(ble_monitor VERSION 0.1 LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) - -find_package(Qt6 6.4 REQUIRED COMPONENTS Core Bluetooth Widgets) - -qt_add_executable(ble_monitor - main.cpp - blemanager.h - blemanager.cpp - blescanner.h - blescanner.cpp -) - -target_link_libraries(ble_monitor - PRIVATE Qt6::Core Qt6::Bluetooth Qt6::Widgets -) - -install(TARGETS ble_monitor - BUNDLE DESTINATION . - LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} - RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} -) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 55d4559..3da8767 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -1,6 +1,86 @@ #include "blemanager.h" +#include "enums.h" #include #include +#include "logger.h" +#include + +AirpodsTrayApp::Enums::AirPodsModel getModelName(quint16 modelId) +{ + using namespace AirpodsTrayApp::Enums; + static const QMap modelMap = { + {0x0220, AirPodsModel::AirPods1}, + {0x0F20, AirPodsModel::AirPods2}, + {0x1320, AirPodsModel::AirPods3}, + {0x1920, AirPodsModel::AirPods4}, + {0x1B20, AirPodsModel::AirPods4ANC}, + {0x0A20, AirPodsModel::AirPodsMaxLightning}, + {0x1F20, AirPodsModel::AirPodsMaxUSBC}, + {0x0E20, AirPodsModel::AirPodsPro}, + {0x1420, AirPodsModel::AirPodsPro2Lightning}, + {0x2420, AirPodsModel::AirPodsPro2USBC} + }; + + return modelMap.value(modelId, AirPodsModel::Unknown); +} + +QString getColorName(quint8 colorId) +{ + switch (colorId) + { + case 0x00: + return "White"; + case 0x01: + return "Black"; + case 0x02: + return "Red"; + case 0x03: + return "Blue"; + case 0x04: + return "Pink"; + case 0x05: + return "Gray"; + case 0x06: + return "Silver"; + case 0x07: + return "Gold"; + case 0x08: + return "Rose Gold"; + case 0x09: + return "Space Gray"; + case 0x0A: + return "Dark Blue"; + case 0x0B: + return "Light Blue"; + case 0x0C: + return "Yellow"; + default: + return "Unknown"; + } +} + +QString getConnectionStateName(BleInfo::ConnectionState state) +{ + using ConnectionState = BleInfo::ConnectionState; + switch (state) + { + case ConnectionState::DISCONNECTED: + return QString("Disconnected"); + case ConnectionState::IDLE: + return QString("Idle"); + case ConnectionState::MUSIC: + return QString("Playing Music"); + case ConnectionState::CALL: + return QString("On Call"); + case ConnectionState::RINGING: + return QString("Ringing"); + case ConnectionState::HANGING_UP: + return QString("Hanging Up"); + case ConnectionState::UNKNOWN: + default: + return QString("Unknown"); + } +} BleManager::BleManager(QObject *parent) : QObject(parent) { @@ -13,38 +93,25 @@ BleManager::BleManager(QObject *parent) : QObject(parent) this, &BleManager::onScanFinished); connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred, this, &BleManager::onErrorOccurred); - - // Set up pruning timer - pruneTimer = new QTimer(this); - connect(pruneTimer, &QTimer::timeout, this, &BleManager::pruneOldDevices); - pruneTimer->start(PRUNE_INTERVAL_MS); // Start timer (runs every 5 seconds) } BleManager::~BleManager() { delete discoveryAgent; - delete pruneTimer; } void BleManager::startScan() { - qDebug() << "Starting BLE scan..."; - devices.clear(); + LOG_DEBUG("Starting BLE scan..."); discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); - pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running } void BleManager::stopScan() { - qDebug() << "Stopping BLE scan..."; + LOG_DEBUG("Stopping BLE scan..."); discoveryAgent->stop(); } -const QMap &BleManager::getDevices() const -{ - return devices; -} - void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) { // Check for Apple's manufacturer ID (0x004C) @@ -55,10 +122,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) if (data.size() >= 10 && data[0] == 0x07) { QString address = info.address().toString(); - DeviceInfo deviceInfo; + BleInfo deviceInfo; deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); deviceInfo.address = address; - deviceInfo.rawData = data; + deviceInfo.rawData = data.left(data.size() - 16); + deviceInfo.encryptedPayload = data.mid(data.size() - 16); // data[1] is the length of the data, so we can skip it @@ -68,8 +136,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) return; // Skip pairing mode devices (the values are differently structured) } + // Parse device model (big-endian: high byte at data[3], low byte at data[4]) - deviceInfo.deviceModel = static_cast(data[4]) | (static_cast(data[3]) << 8); + deviceInfo.modelName = getModelName(static_cast(data[4]) | (static_cast(data[3]) << 8)); // Status byte for primary pod and other flags quint8 status = static_cast(data[5]); @@ -83,9 +152,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) // Lid open counter and device color quint8 lidIndicator = static_cast(data[8]); - deviceInfo.deviceColor = static_cast(data[9]); + deviceInfo.color = getColorName((quint8)(data[9])); - deviceInfo.connectionState = static_cast(data[10]); + deviceInfo.connectionState = static_cast(data[10]); // Next: Encrypted Payload: 16 bytes @@ -93,6 +162,8 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary + deviceInfo.primaryLeft = primaryLeft; // Store primary pod information + // Parse battery levels int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F; int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F; @@ -117,6 +188,10 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1 deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3 + // Determine primary and secondary in-ear status + deviceInfo.isPrimaryInEar = primaryLeft ? deviceInfo.isLeftPodInEar : deviceInfo.isRightPodInEar; + deviceInfo.isSecondaryInEar = primaryLeft ? deviceInfo.isRightPodInEar : deviceInfo.isLeftPodInEar; + // Microphone status deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase; deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase; @@ -124,27 +199,19 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) deviceInfo.lidOpenCounter = lidIndicator & 0x07; // Extract bits 0-2 (count) quint8 lidState = static_cast((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state) if (deviceInfo.isThisPodInTheCase) { - deviceInfo.lidState = static_cast(lidState); + deviceInfo.lidState = static_cast(lidState); } // Update timestamp deviceInfo.lastSeen = QDateTime::currentDateTime(); - // Store device info in the map - devices[address] = deviceInfo; - - // Debug output - qDebug() << "Found device:" << deviceInfo.name - << "Left:" << (deviceInfo.leftPodBattery >= 0 ? QString("%1%").arg(deviceInfo.leftPodBattery) : "N/A") - << "Right:" << (deviceInfo.rightPodBattery >= 0 ? QString("%1%").arg(deviceInfo.rightPodBattery) : "N/A") - << "Case:" << (deviceInfo.caseBattery >= 0 ? QString("%1%").arg(deviceInfo.caseBattery) : "N/A"); + emit deviceFound(deviceInfo); // Emit signal for device found } } } void BleManager::onScanFinished() { - qDebug() << "Scan finished."; if (discoveryAgent->isActive()) { discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); @@ -153,24 +220,6 @@ void BleManager::onScanFinished() void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) { - qDebug() << "Error occurred:" << error; + LOG_ERROR("BLE scan error occurred:" << error); stopScan(); } - -void BleManager::pruneOldDevices() -{ - QDateTime now = QDateTime::currentDateTime(); - auto it = devices.begin(); - while (it != devices.end()) - { - if (it.value().lastSeen.msecsTo(now) > DEVICE_TIMEOUT_MS) - { - qDebug() << "Removing old device:" << it.value().name << "at" << it.key(); - it = devices.erase(it); // Remove device if not seen recently - } - else - { - ++it; - } - } -} \ No newline at end of file diff --git a/linux/ble/blemanager.h b/linux/ble/blemanager.h index 380d814..df61785 100644 --- a/linux/ble/blemanager.h +++ b/linux/ble/blemanager.h @@ -6,10 +6,11 @@ #include #include #include +#include "enums.h" class QTimer; -class DeviceInfo +class BleInfo { public: QString name; @@ -20,20 +21,24 @@ public: bool leftCharging = false; bool rightCharging = false; bool caseCharging = false; - quint16 deviceModel = 0; + AirpodsTrayApp::Enums::AirPodsModel modelName = AirpodsTrayApp::Enums::AirPodsModel::Unknown; quint8 lidOpenCounter = 0; - quint8 deviceColor = 0; + QString color = "Unknown"; // Default color quint8 status = 0; QByteArray rawData; + QByteArray encryptedPayload; // 16 bytes of encrypted payload // Additional status flags from Kotlin version bool isLeftPodInEar = false; bool isRightPodInEar = false; + bool isPrimaryInEar = false; + bool isSecondaryInEar = false; bool isLeftPodMicrophone = false; bool isRightPodMicrophone = false; bool isThisPodInTheCase = false; bool isOnePodInCase = false; bool areBothPodsInCase = false; + bool primaryLeft = true; // True if left pod is primary, false if right pod is primary // Lid state enumeration enum class LidState @@ -41,8 +46,7 @@ public: OPEN = 0x0, CLOSED = 0x1, UNKNOWN, - }; - LidState lidState = LidState::UNKNOWN; + } lidState = LidState::UNKNOWN; // Connection state enumeration enum class ConnectionState : uint8_t @@ -54,8 +58,7 @@ public: RINGING = 0x07, HANGING_UP = 0x09, UNKNOWN = 0xFF // Using 0xFF for representing null in the original - }; - ConnectionState connectionState = ConnectionState::UNKNOWN; + } connectionState = ConnectionState::UNKNOWN; QDateTime lastSeen; // Timestamp of last detection }; @@ -69,21 +72,17 @@ public: void startScan(); void stopScan(); - const QMap &getDevices() const; private slots: void onDeviceDiscovered(const QBluetoothDeviceInfo &info); void onScanFinished(); void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error); - void pruneOldDevices(); + +signals: + void deviceFound(const BleInfo &device); private: QBluetoothDeviceDiscoveryAgent *discoveryAgent; - QMap devices; - - QTimer *pruneTimer; // Timer for periodic pruning - static const int PRUNE_INTERVAL_MS = 5000; // Check every 5 seconds - static const int DEVICE_TIMEOUT_MS = 10000; // Remove after 10 seconds }; #endif // BLEMANAGER_H \ No newline at end of file diff --git a/linux/ble/blescanner.cpp b/linux/ble/blescanner.cpp deleted file mode 100644 index 83ba15a..0000000 --- a/linux/ble/blescanner.cpp +++ /dev/null @@ -1,398 +0,0 @@ -#include "blescanner.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -BleScanner::BleScanner(QWidget *parent) : QMainWindow(parent) -{ - setWindowTitle("AirPods Battery Monitor"); - resize(600, 400); - - QWidget *centralWidget = new QWidget(this); - QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget); - setCentralWidget(centralWidget); - - QHBoxLayout *buttonLayout = new QHBoxLayout(); - scanButton = new QPushButton("Start Scan", this); - stopButton = new QPushButton("Stop Scan", this); - stopButton->setEnabled(false); - buttonLayout->addWidget(scanButton); - buttonLayout->addWidget(stopButton); - buttonLayout->addStretch(); - mainLayout->addLayout(buttonLayout); - - deviceTable = new QTableWidget(0, 5, this); - deviceTable->setHorizontalHeaderLabels({"Device", "Left Pod", "Right Pod", "Case", "Address"}); - deviceTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); - deviceTable->setSelectionBehavior(QTableWidget::SelectRows); - deviceTable->setEditTriggers(QTableWidget::NoEditTriggers); - mainLayout->addWidget(deviceTable); - - detailsGroup = new QGroupBox("Device Details", this); - QGridLayout *detailsLayout = new QGridLayout(detailsGroup); - - // Row 0: Left Pod - detailsLayout->addWidget(new QLabel("Left Pod:"), 0, 0); - leftBatteryBar = new QProgressBar(this); - leftBatteryBar->setRange(0, 100); - leftBatteryBar->setTextVisible(true); - detailsLayout->addWidget(leftBatteryBar, 0, 1); - leftChargingLabel = new QLabel(this); - detailsLayout->addWidget(leftChargingLabel, 0, 2); - - // Row 1: Right Pod - detailsLayout->addWidget(new QLabel("Right Pod:"), 1, 0); - rightBatteryBar = new QProgressBar(this); - rightBatteryBar->setRange(0, 100); - rightBatteryBar->setTextVisible(true); - detailsLayout->addWidget(rightBatteryBar, 1, 1); - rightChargingLabel = new QLabel(this); - detailsLayout->addWidget(rightChargingLabel, 1, 2); - - // Row 2: Case - detailsLayout->addWidget(new QLabel("Case:"), 2, 0); - caseBatteryBar = new QProgressBar(this); - caseBatteryBar->setRange(0, 100); - caseBatteryBar->setTextVisible(true); - detailsLayout->addWidget(caseBatteryBar, 2, 1); - caseChargingLabel = new QLabel(this); - detailsLayout->addWidget(caseChargingLabel, 2, 2); - - // Row 3: Model - detailsLayout->addWidget(new QLabel("Model:"), 3, 0); - modelLabel = new QLabel(this); - detailsLayout->addWidget(modelLabel, 3, 1); - - // Row 4: Status - detailsLayout->addWidget(new QLabel("Status:"), 4, 0); - statusLabel = new QLabel(this); - detailsLayout->addWidget(statusLabel, 4, 1); - - // Row 5: Lid State (replaces Lid Opens) - detailsLayout->addWidget(new QLabel("Lid State:"), 5, 0); - lidStateLabel = new QLabel(this); - detailsLayout->addWidget(lidStateLabel, 5, 1); - - // Row 6: Color - detailsLayout->addWidget(new QLabel("Color:"), 6, 0); - colorLabel = new QLabel(this); - detailsLayout->addWidget(colorLabel, 6, 1); - - // Row 7: Raw Data - detailsLayout->addWidget(new QLabel("Raw Data:"), 7, 0); - rawDataLabel = new QLabel(this); - rawDataLabel->setWordWrap(true); - rawDataLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - detailsLayout->addWidget(rawDataLabel, 7, 1, 1, 2); - - // New Rows for Additional Info - // Row 8: Left Pod In Ear - detailsLayout->addWidget(new QLabel("Left Pod In Ear:"), 8, 0); - leftInEarLabel = new QLabel(this); - detailsLayout->addWidget(leftInEarLabel, 8, 1); - - // Row 9: Right Pod In Ear - detailsLayout->addWidget(new QLabel("Right Pod In Ear:"), 9, 0); - rightInEarLabel = new QLabel(this); - detailsLayout->addWidget(rightInEarLabel, 9, 1); - - // Row 10: Left Pod Microphone - detailsLayout->addWidget(new QLabel("Left Pod Microphone:"), 10, 0); - leftMicLabel = new QLabel(this); - detailsLayout->addWidget(leftMicLabel, 10, 1); - - // Row 11: Right Pod Microphone - detailsLayout->addWidget(new QLabel("Right Pod Microphone:"), 11, 0); - rightMicLabel = new QLabel(this); - detailsLayout->addWidget(rightMicLabel, 11, 1); - - // Row 12: This Pod In Case - detailsLayout->addWidget(new QLabel("This Pod In Case:"), 12, 0); - thisPodInCaseLabel = new QLabel(this); - detailsLayout->addWidget(thisPodInCaseLabel, 12, 1); - - // Row 13: One Pod In Case - detailsLayout->addWidget(new QLabel("One Pod In Case:"), 13, 0); - onePodInCaseLabel = new QLabel(this); - detailsLayout->addWidget(onePodInCaseLabel, 13, 1); - - // Row 14: Both Pods In Case - detailsLayout->addWidget(new QLabel("Both Pods In Case:"), 14, 0); - bothPodsInCaseLabel = new QLabel(this); - detailsLayout->addWidget(bothPodsInCaseLabel, 14, 1); - - // Row 15: Connection State - detailsLayout->addWidget(new QLabel("Connection State:"), 15, 0); - connectionStateLabel = new QLabel(this); - detailsLayout->addWidget(connectionStateLabel, 15, 1); - - mainLayout->addWidget(detailsGroup); - detailsGroup->setVisible(false); - - bleManager = new BleManager(this); - refreshTimer = new QTimer(this); - - connect(scanButton, &QPushButton::clicked, this, &BleScanner::startScan); - connect(stopButton, &QPushButton::clicked, this, &BleScanner::stopScan); - connect(deviceTable, &QTableWidget::itemSelectionChanged, this, &BleScanner::onDeviceSelected); - connect(refreshTimer, &QTimer::timeout, this, &BleScanner::updateDeviceList); -} - -void BleScanner::startScan() -{ - scanButton->setEnabled(false); - stopButton->setEnabled(true); - deviceTable->setRowCount(0); - detailsGroup->setVisible(false); - bleManager->startScan(); - refreshTimer->start(500); -} - -void BleScanner::stopScan() -{ - bleManager->stopScan(); - refreshTimer->stop(); - scanButton->setEnabled(true); - stopButton->setEnabled(false); -} - -void BleScanner::updateDeviceList() -{ - const QMap &devices = bleManager->getDevices(); - QString selectedAddress; - if (deviceTable->selectionModel()->hasSelection()) - { - int row = deviceTable->selectionModel()->selectedRows().first().row(); - selectedAddress = deviceTable->item(row, 4)->text(); - } - - deviceTable->setRowCount(0); - deviceTable->setRowCount(devices.size()); - int row = 0; - for (auto it = devices.begin(); it != devices.end(); ++it, ++row) - { - const DeviceInfo &device = it.value(); - deviceTable->setItem(row, 0, new QTableWidgetItem(device.name)); - QString leftStatus = (device.leftPodBattery >= 0 ? QString::number(device.leftPodBattery) + "%" : "N/A") + - (device.leftCharging ? " ⚡" : ""); - deviceTable->setItem(row, 1, new QTableWidgetItem(leftStatus)); - QString rightStatus = (device.rightPodBattery >= 0 ? QString::number(device.rightPodBattery) + "%" : "N/A") + - (device.rightCharging ? " ⚡" : ""); - deviceTable->setItem(row, 2, new QTableWidgetItem(rightStatus)); - QString caseStatus = (device.caseBattery >= 0 ? QString::number(device.caseBattery) + "%" : "N/A") + - (device.caseCharging ? " ⚡" : ""); - deviceTable->setItem(row, 3, new QTableWidgetItem(caseStatus)); - deviceTable->setItem(row, 4, new QTableWidgetItem(device.address)); - if (device.address == selectedAddress) - { - deviceTable->selectRow(row); - } - } - - if (deviceTable->selectedItems().isEmpty()) { - deviceTable->selectRow(0); - } -} - -void BleScanner::onDeviceSelected() -{ - if (!deviceTable->selectionModel()->hasSelection()) - { - detailsGroup->setVisible(false); - return; - } - - int row = deviceTable->selectionModel()->selectedRows().first().row(); - QString address = deviceTable->item(row, 4)->text(); - const QMap &devices = bleManager->getDevices(); - if (!devices.contains(address)) - { - detailsGroup->setVisible(false); - return; - } - - const DeviceInfo &device = devices[address]; - - // Battery bars with N/A handling - if (device.leftPodBattery >= 0) - { - leftBatteryBar->setValue(device.leftPodBattery); - leftBatteryBar->setFormat("%p%"); - } - else - { - leftBatteryBar->setValue(0); - leftBatteryBar->setFormat("N/A"); - } - - if (device.rightPodBattery >= 0) - { - rightBatteryBar->setValue(device.rightPodBattery); - rightBatteryBar->setFormat("%p%"); - } - else - { - rightBatteryBar->setValue(0); - rightBatteryBar->setFormat("N/A"); - } - - if (device.caseBattery >= 0) - { - caseBatteryBar->setValue(device.caseBattery); - caseBatteryBar->setFormat("%p%"); - } - else - { - caseBatteryBar->setValue(0); - caseBatteryBar->setFormat("N/A"); - } - - leftChargingLabel->setText(device.leftCharging ? "Charging" : "Not Charging"); - rightChargingLabel->setText(device.rightCharging ? "Charging" : "Not Charging"); - caseChargingLabel->setText(device.caseCharging ? "Charging" : "Not Charging"); - - QString modelName = getModelName(device.deviceModel); - modelLabel->setText(modelName + " (0x" + QString::number(device.deviceModel, 16).toUpper() + ")"); - - QString statusBinary = QString("%1").arg(device.status, 8, 2, QChar('0')); - statusLabel->setText(QString("0x%1 (%2) - Binary: %3") - .arg(device.status, 2, 16, QChar('0')) - .toUpper() - .arg(device.status) - .arg(statusBinary)); - - // Lid State enum handling - QString lidStateStr; - - switch (device.lidState) - { - case DeviceInfo::LidState::OPEN: - lidStateStr.append("Open"); - break; - case DeviceInfo::LidState::CLOSED: - lidStateStr.append("Closed"); - break; - case DeviceInfo::LidState::UNKNOWN: - lidStateStr.append("Unknown"); - break; - } - lidStateStr.append(" (0x" + QString::number(device.lidOpenCounter, 16).toUpper() + " = " + QString::number(device.lidOpenCounter) + ")"); - lidStateLabel->setText(lidStateStr); - - QString colorName = getColorName(device.deviceColor); - colorLabel->setText(colorName + " (" + QString::number(device.deviceColor) + ")"); - - QString rawDataStr = "Bytes: "; - for (int i = 0; i < device.rawData.size(); ++i) - { - rawDataStr += QString("0x%1 ").arg(static_cast(device.rawData[i]), 2, 16, QChar('0')).toUpper(); - } - rawDataLabel->setText(rawDataStr); - - // Set new status labels - leftInEarLabel->setText(device.isLeftPodInEar ? "Yes" : "No"); - rightInEarLabel->setText(device.isRightPodInEar ? "Yes" : "No"); - leftMicLabel->setText(device.isLeftPodMicrophone ? "Yes" : "No"); - rightMicLabel->setText(device.isRightPodMicrophone ? "Yes" : "No"); - thisPodInCaseLabel->setText(device.isThisPodInTheCase ? "Yes" : "No"); - onePodInCaseLabel->setText(device.isOnePodInCase ? "Yes" : "No"); - bothPodsInCaseLabel->setText(device.areBothPodsInCase ? "Yes" : "No"); - connectionStateLabel->setText(getConnectionStateName(device.connectionState)); - - detailsGroup->setVisible(true); -} - -QString BleScanner::getModelName(quint16 modelId) -{ - switch (modelId) - { - case 0x0220: - return "AirPods 1st Gen"; - case 0x0F20: - return "AirPods 2nd Gen"; - case 0x1320: - return "AirPods 3rd Gen"; - case 0x1920: - return "AirPods 4th Gen"; - case 0x1B20: - return "AirPods 4th Gen (ANC)"; - case 0x0A20: - return "AirPods Max"; - case 0x1F20: - return "AirPods Max (USB-C)"; - case 0x0E20: - return "AirPods Pro"; - case 0x1420: - return "AirPods Pro 2nd Gen"; - case 0x2420: - return "AirPods Pro 2nd Gen (USB-C)"; - default: - return "Unknown Apple Device"; - } -} - -QString BleScanner::getColorName(quint8 colorId) -{ - switch (colorId) - { - case 0x00: - return "White"; - case 0x01: - return "Black"; - case 0x02: - return "Red"; - case 0x03: - return "Blue"; - case 0x04: - return "Pink"; - case 0x05: - return "Gray"; - case 0x06: - return "Silver"; - case 0x07: - return "Gold"; - case 0x08: - return "Rose Gold"; - case 0x09: - return "Space Gray"; - case 0x0A: - return "Dark Blue"; - case 0x0B: - return "Light Blue"; - case 0x0C: - return "Yellow"; - default: - return "Unknown"; - } -} - -QString BleScanner::getConnectionStateName(DeviceInfo::ConnectionState state) -{ - using ConnectionState = DeviceInfo::ConnectionState; - switch (state) - { - case ConnectionState::DISCONNECTED: - return QString("Disconnected"); - case ConnectionState::IDLE: - return QString("Idle"); - case ConnectionState::MUSIC: - return QString("Playing Music"); - case ConnectionState::CALL: - return QString("On Call"); - case ConnectionState::RINGING: - return QString("Ringing"); - case ConnectionState::HANGING_UP: - return QString("Hanging Up"); - case ConnectionState::UNKNOWN: - default: - return QString("Unknown"); - } -} \ No newline at end of file diff --git a/linux/ble/blescanner.h b/linux/ble/blescanner.h deleted file mode 100644 index a3958f6..0000000 --- a/linux/ble/blescanner.h +++ /dev/null @@ -1,61 +0,0 @@ -#ifndef BLESCANNER_H -#define BLESCANNER_H - -#include -#include "blemanager.h" -#include -#include - -class QTableWidget; -class QGroupBox; -class QProgressBar; -class QLabel; -class QPushButton; - -class BleScanner : public QMainWindow -{ - Q_OBJECT -public: - explicit BleScanner(QWidget *parent = nullptr); - -private slots: - void startScan(); - void stopScan(); - void updateDeviceList(); - void onDeviceSelected(); - -private: - QString getModelName(quint16 modelId); - QString getColorName(quint8 colorId); - QString getConnectionStateName(DeviceInfo::ConnectionState state); - - BleManager *bleManager; - QTimer *refreshTimer; - QPushButton *scanButton; - QPushButton *stopButton; - QTableWidget *deviceTable; - QGroupBox *detailsGroup; - QProgressBar *leftBatteryBar; - QProgressBar *rightBatteryBar; - QProgressBar *caseBatteryBar; - QLabel *leftChargingLabel; - QLabel *rightChargingLabel; - QLabel *caseChargingLabel; - QLabel *modelLabel; - QLabel *statusLabel; - QLabel *lidStateLabel; // Renamed from lidOpenLabel - QLabel *colorLabel; - QLabel *rawDataLabel; - - // New labels for additional DeviceInfo fields - QLabel *leftInEarLabel; - QLabel *rightInEarLabel; - QLabel *leftMicLabel; - QLabel *rightMicLabel; - QLabel *thisPodInCaseLabel; - QLabel *onePodInCaseLabel; - QLabel *bothPodsInCaseLabel; - QLabel *connectionStateLabel; -}; - -#endif // BLESCANNER_H \ No newline at end of file diff --git a/linux/ble/bleutils.cpp b/linux/ble/bleutils.cpp new file mode 100644 index 0000000..780b538 --- /dev/null +++ b/linux/ble/bleutils.cpp @@ -0,0 +1,138 @@ +#include +#include "deviceinfo.hpp" +#include "bleutils.h" +#include +#include +#include +#include +#include // For memset + +BLEUtils::BLEUtils(QObject *parent) : QObject(parent) +{ +} + +bool BLEUtils::verifyRPA(const QString &address, const QByteArray &irk) +{ + if (address.isEmpty() || irk.isEmpty() || irk.size() != 16) + { + return false; + } + + // Split address into bytes and reverse order + QStringList parts = address.split(':'); + if (parts.size() != 6) + { + return false; + } + + QByteArray rpa; + bool ok; + for (int i = parts.size() - 1; i >= 0; --i) + { + rpa.append(static_cast(parts[i].toInt(&ok, 16))); + if (!ok) + { + return false; + } + } + + if (rpa.size() != 6) + { + return false; + } + + QByteArray prand = rpa.mid(3, 3); + QByteArray hash = rpa.left(3); + QByteArray computedHash = ah(irk, prand); + + return hash == computedHash; +} + +bool BLEUtils::isValidIrkRpa(const QByteArray &irk, const QString &rpa) +{ + return verifyRPA(rpa, irk); +} + +QByteArray BLEUtils::e(const QByteArray &key, const QByteArray &data) +{ + if (key.size() != 16 || data.size() != 16) + { + return QByteArray(); + } + + // Prepare key and data (needs to be reversed) + QByteArray reversedKey(key); + std::reverse(reversedKey.begin(), reversedKey.end()); + + QByteArray reversedData(data); + std::reverse(reversedData.begin(), reversedData.end()); + + // Set up AES encryption + AES_KEY aesKey; + if (AES_set_encrypt_key(reinterpret_cast(reversedKey.constData()), 128, &aesKey) != 0) + { + return QByteArray(); + } + + unsigned char out[16]; + AES_encrypt(reinterpret_cast(reversedData.constData()), out, &aesKey); + + // Convert output to QByteArray and reverse it + QByteArray result(reinterpret_cast(out), 16); + std::reverse(result.begin(), result.end()); + + return result; +} + +QByteArray BLEUtils::ah(const QByteArray &k, const QByteArray &r) +{ + if (r.size() < 3) + { + return QByteArray(); + } + + // Pad the random part to 16 bytes + QByteArray rPadded(16, 0); + rPadded.replace(0, 3, r.left(3)); + + QByteArray encrypted = e(k, rPadded); + if (encrypted.isEmpty()) + { + return QByteArray(); + } + + return encrypted.left(3); +} + +QByteArray BLEUtils::decryptLastBytes(const QByteArray &data, const QByteArray &key) +{ + if (data.size() < 16 || key.size() != 16) + { + qDebug() << "Invalid input: data size < 16 or key size != 16"; + return QByteArray(); + } + + // Extract the last 16 bytes + QByteArray block = data.right(16); + + // Set up AES decryption key (use key directly, no reversal) + AES_KEY aesKey; + if (AES_set_decrypt_key(reinterpret_cast(key.constData()), 128, &aesKey) != 0) + { + qDebug() << "Failed to set AES decryption key"; + return QByteArray(); + } + + unsigned char out[16]; + unsigned char iv[16]; + memset(iv, 0, 16); // Zero IV for CBC mode + + // Perform AES decryption using CBC mode with zero IV + // AES_cbc_encrypt is used for both encryption and decryption depending on the key schedule + AES_cbc_encrypt(reinterpret_cast(block.constData()), out, 16, &aesKey, iv, AES_DECRYPT); + + // Convert output to QByteArray (no reversal) + QByteArray result(reinterpret_cast(out), 16); + + return result; +} \ No newline at end of file diff --git a/linux/ble/bleutils.h b/linux/ble/bleutils.h new file mode 100644 index 0000000..121b1d1 --- /dev/null +++ b/linux/ble/bleutils.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +class BLEUtils : public QObject +{ + Q_OBJECT +public: + explicit BLEUtils(QObject *parent = nullptr); + + /** + * @brief Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK) + * @param address The Bluetooth address to verify + * @param irk The Identity Resolving Key to use for verification + * @return true if the address is verified as an RPA matching the IRK + */ + static bool verifyRPA(const QString &address, const QByteArray &irk); + + /** + * @brief Checks if the given IRK and RPA are valid + * @param irk The Identity Resolving Key + * @param rpa The Resolvable Private Address + * @return true if the RPA is valid for the given IRK + */ + Q_INVOKABLE static bool isValidIrkRpa(const QByteArray &irk, const QString &rpa); + + /** + * @brief Decrypts the last 16 bytes of the input data using the provided key with AES-128 ECB + * @param data The input data containing at least 16 bytes + * @param key The 16-byte key for decryption + * @return The decrypted 16 bytes, or an empty QByteArray on failure + */ + static QByteArray decryptLastBytes(const QByteArray &data, const QByteArray &key); + +private: + /** + * @brief Performs E function (AES-128) as specified in Bluetooth Core Specification + * @param key The key for encryption + * @param data The data to encrypt + * @return The encrypted data + */ + static QByteArray e(const QByteArray &key, const QByteArray &data); + + /** + * @brief Performs the ah function as specified in Bluetooth Core Specification + * @param k The IRK key + * @param r The random part of the address + * @return The hash part of the address + */ + static QByteArray ah(const QByteArray &k, const QByteArray &r); +}; \ No newline at end of file diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp deleted file mode 100644 index 02dd425..0000000 --- a/linux/ble/main.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include "blescanner.h" -#include - -int main(int argc, char *argv[]) -{ - QApplication app(argc, argv); - BleScanner scanner; - scanner.show(); - return app.exec(); -} \ No newline at end of file diff --git a/linux/deviceinfo.hpp b/linux/deviceinfo.hpp index ceca235..0e6cd4e 100644 --- a/linux/deviceinfo.hpp +++ b/linux/deviceinfo.hpp @@ -189,26 +189,21 @@ public: setBluetoothAddress(""); } - void save() const + void saveToSettings(QSettings &settings) { - QSettings settings("AirpodsTrayApp", "DeviceInfo"); settings.beginGroup("DeviceInfo"); - settings.setValue("deviceName", m_deviceName); - settings.setValue("bluetoothAddress", m_bluetoothAddress); - settings.setValue("magicAccIRK", m_magicAccIRK.toBase64()); - settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64()); + settings.setValue("deviceName", deviceName()); + settings.setValue("model", static_cast(model())); + settings.setValue("magicAccIRK", magicAccIRK()); + settings.setValue("magicAccEncKey", magicAccEncKey()); settings.endGroup(); } - - void load() + void loadFromSettings(const QSettings &settings) { - QSettings settings("AirpodsTrayApp", "DeviceInfo"); - settings.beginGroup("DeviceInfo"); - setDeviceName(settings.value("deviceName", "").toString()); - setBluetoothAddress(settings.value("bluetoothAddress", "").toString()); - setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray())); - setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray())); - settings.endGroup(); + setDeviceName(settings.value("DeviceInfo/deviceName", "").toString()); + setModel(static_cast(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt())); + setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray()); + setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray()); } signals: diff --git a/linux/main.cpp b/linux/main.cpp index 7cb09a9..6f4b34e 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -1,7 +1,17 @@ #include #include #include -#include "main.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + #include "airpods_packets.h" #include "logger.h" #include "mediacontroller.h" @@ -11,6 +21,8 @@ #include "BluetoothMonitor.h" #include "autostartmanager.hpp" #include "deviceinfo.hpp" +#include "ble/blemanager.h" +#include "ble/bleutils.h" using namespace AirpodsTrayApp::Enums; @@ -29,7 +41,9 @@ class AirPodsTrayApp : public QObject { public: AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr) - : QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")), m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent), m_deviceInfo(new DeviceInfo(this)) + : QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")) + , m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent) + , m_deviceInfo(new DeviceInfo(this)), m_bleManager(new BleManager(this)) { QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false")); LOG_INFO("Initializing AirPodsTrayApp"); @@ -59,6 +73,7 @@ public: connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected); connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected); + connect(m_bleManager, &BleManager::deviceFound, this, &AirPodsTrayApp::bleDeviceFound); connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged); // Load settings @@ -379,6 +394,8 @@ private slots: // Clear the device name and model m_deviceInfo->reset(); + m_bleManager->startScan(); + emit airPodsStatusChanged(); // Show system notification trayManager->showNotification( @@ -545,6 +562,7 @@ private slots: // Store the keys m_deviceInfo->setMagicAccIRK(keys.magicAccIRK); m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey); + m_deviceInfo->saveToSettings(*m_settings); } // Get CA state else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) { @@ -604,6 +622,7 @@ private slots: { mediaController->activateA2dpProfile(); } + m_bleManager->stopScan(); emit airPodsStatusChanged(); } else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) { @@ -733,6 +752,17 @@ private slots: QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data)); } + void bleDeviceFound(const BleInfo &device) + { + 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); + m_deviceInfo->setPrimaryInEar(device.isPrimaryInEar); + m_deviceInfo->setSecondaryInEar(device.isSecondaryInEar); + } + } + public: void handleMediaStateChange(MediaController::MediaState state) { if (state == MediaController::MediaState::Playing) { @@ -822,6 +852,11 @@ public: void initializeBluetooth() { connectToPhone(); + + m_deviceInfo->loadFromSettings(*m_settings); + if (!areAirpodsConnected()) { + m_bleManager->startScan(); + } } void loadMainModule() { @@ -857,6 +892,7 @@ private: int m_retryAttempts = 3; bool m_hideOnStart = false; DeviceInfo *m_deviceInfo; + BleManager *m_bleManager; }; int main(int argc, char *argv[]) { diff --git a/linux/main.h b/linux/main.h deleted file mode 100644 index 8e212c5..0000000 --- a/linux/main.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifndef MAIN_H -#define MAIN_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0" - -#define MANUFACTURER_ID 0x1234 -#define MANUFACTURER_DATA "ALN_AirPods" - -#endif