diff --git a/linux/ble/CMakeLists.txt b/linux/ble/CMakeLists.txt index 71f1998e..529d401b 100644 --- a/linux/ble/CMakeLists.txt +++ b/linux/ble/CMakeLists.txt @@ -16,6 +16,8 @@ qt_add_executable(ble_monitor blemanager.cpp blescanner.h blescanner.cpp + proximityparser.h + proximityparser.cpp ) target_link_libraries(ble_monitor diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 55d45592..53a394a9 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -47,99 +47,52 @@ const QMap &BleManager::getDevices() const void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) { - // Check for Apple's manufacturer ID (0x004C) - if (info.manufacturerData().contains(0x004C)) + if (info.manufacturerData().contains(0x004C)) // Apple manufacturer ID { QByteArray data = info.manufacturerData().value(0x004C); - // Ensure data is long enough and starts with prefix 0x07 (indicates Proximity Pairing Message) - if (data.size() >= 10 && data[0] == 0x07) + QString address = info.address().toString(); + DeviceInfo deviceInfo; + deviceInfo.name = info.name(); + deviceInfo.address = address; + deviceInfo.rawData = data; + + // Try to parse as Proximity message + if (data.size() >= 1 && data[0] == 0x07 && ProximityParser::parseAppleProximityData(data, deviceInfo)) { - QString address = info.address().toString(); - DeviceInfo deviceInfo; - deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); - deviceInfo.address = address; - deviceInfo.rawData = data; - - // data[1] is the length of the data, so we can skip it - - // Check if pairing mode is paired (0x01) or pairing (0x00) - if (data[2] == 0x00) - { - 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); - - // Status byte for primary pod and other flags - quint8 status = static_cast(data[5]); - deviceInfo.status = status; - - // Pods battery byte (upper nibble: one pod, lower nibble: other pod) - quint8 podsBatteryByte = static_cast(data[6]); - - // Flags and case battery byte (upper nibble: case battery, lower nibble: flags) - quint8 flagsAndCaseBattery = static_cast(data[7]); - - // Lid open counter and device color - quint8 lidIndicator = static_cast(data[8]); - deviceInfo.deviceColor = static_cast(data[9]); - - deviceInfo.connectionState = static_cast(data[10]); - - // Next: Encrypted Payload: 16 bytes - - // Determine primary pod (bit 5 of status) and value flipping - bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary - bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary - - // Parse battery levels - int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F; - int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F; - deviceInfo.leftPodBattery = (leftNibble == 15) ? -1 : leftNibble * 10; - deviceInfo.rightPodBattery = (rightNibble == 15) ? -1 : rightNibble * 10; - int caseNibble = flagsAndCaseBattery & 0x0F; // Extracts lower nibble - deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; - - // Parse charging statuses from flags (uper 4 bits of data[7]) - quint8 flags = (flagsAndCaseBattery >> 4) & 0x0F; // Extracts lower nibble - deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // Depending on primary, bit 0 or 1 - deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // Depending on primary, bit 1 or 0 - deviceInfo.caseCharging = (flags & 0x04) != 0; // bit 2 - - // Additional status flags from status byte (data[5]) - deviceInfo.isThisPodInTheCase = (status & 0x40) != 0; // Bit 6 - deviceInfo.isOnePodInCase = (status & 0x10) != 0; // Bit 4 - deviceInfo.areBothPodsInCase = (status & 0x04) != 0; // Bit 2 - - // In-ear detection with XOR logic - bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase; - 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 - - // Microphone status - deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase; - deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase; - - 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.deviceType = DeviceInfo::DeviceType::PROXIMITY; + } + // Try to parse as Find My message + else if (data.size() >= 1 && data[0] == 0x12 && ProximityParser::parseAppleFindMyData(data, deviceInfo)) + { + deviceInfo.deviceType = DeviceInfo::DeviceType::FIND_MY; + } + else + { + return; // Not a message we can parse + } - // Update timestamp - deviceInfo.lastSeen = QDateTime::currentDateTime(); + // Update timestamp + deviceInfo.lastSeen = QDateTime::currentDateTime(); - // Store device info in the map - devices[address] = deviceInfo; + // Store device info in the map + devices[address] = deviceInfo; - // Debug output - qDebug() << "Found device:" << deviceInfo.name + // Debug output + if (deviceInfo.deviceType == DeviceInfo::DeviceType::PROXIMITY) + { + qDebug() << "Found proximity 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"); } + else if (deviceInfo.deviceType == DeviceInfo::DeviceType::FIND_MY) + { + qDebug() << "Found Find My device:" << deviceInfo.name + << "Maintained:" << deviceInfo.isMaintained + << "Battery:" << static_cast(deviceInfo.batteryLevel); + } } + // Add other manufacturer checks here } void BleManager::onScanFinished() diff --git a/linux/ble/blemanager.h b/linux/ble/blemanager.h index 380d8147..c4777ae2 100644 --- a/linux/ble/blemanager.h +++ b/linux/ble/blemanager.h @@ -1,6 +1,8 @@ #ifndef BLEMANAGER_H #define BLEMANAGER_H +#include "proximityparser.h" + #include #include #include @@ -9,57 +11,6 @@ class QTimer; -class DeviceInfo -{ -public: - QString name; - QString address; - int leftPodBattery = -1; // -1 indicates not available - int rightPodBattery = -1; - int caseBattery = -1; - bool leftCharging = false; - bool rightCharging = false; - bool caseCharging = false; - quint16 deviceModel = 0; - quint8 lidOpenCounter = 0; - quint8 deviceColor = 0; - quint8 status = 0; - QByteArray rawData; - - // Additional status flags from Kotlin version - bool isLeftPodInEar = false; - bool isRightPodInEar = false; - bool isLeftPodMicrophone = false; - bool isRightPodMicrophone = false; - bool isThisPodInTheCase = false; - bool isOnePodInCase = false; - bool areBothPodsInCase = false; - - // Lid state enumeration - enum class LidState - { - OPEN = 0x0, - CLOSED = 0x1, - UNKNOWN, - }; - LidState lidState = LidState::UNKNOWN; - - // Connection state enumeration - enum class ConnectionState : uint8_t - { - DISCONNECTED = 0x00, - IDLE = 0x04, - MUSIC = 0x05, - CALL = 0x06, - RINGING = 0x07, - HANGING_UP = 0x09, - UNKNOWN = 0xFF // Using 0xFF for representing null in the original - }; - ConnectionState connectionState = ConnectionState::UNKNOWN; - - QDateTime lastSeen; // Timestamp of last detection -}; - class BleManager : public QObject { Q_OBJECT diff --git a/linux/ble/proximityparser.cpp b/linux/ble/proximityparser.cpp new file mode 100644 index 00000000..14d56cf5 --- /dev/null +++ b/linux/ble/proximityparser.cpp @@ -0,0 +1,139 @@ +// proximityparser.cpp +#include "proximityparser.h" +#include + +bool ProximityParser::parseAppleProximityData(const QByteArray &data, DeviceInfo &deviceInfo) +{ + // Ensure data is long enough and starts with prefix 0x07 (indicates Proximity Pairing Message) + if (data.size() < 10 || data[0] != 0x07) + { + return false; + } + + // Set device type + deviceInfo.deviceType = DeviceInfo::DeviceType::PROXIMITY; + + // data[1] is the length of the data, so we can skip it + + // Check if pairing mode is paired (0x01) or pairing (0x00) + if (data[2] == 0x00) + { + return false; // 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); + + // Status byte for primary pod and other flags + quint8 status = static_cast(data[5]); + deviceInfo.status = status; + + // Pods battery byte (upper nibble: one pod, lower nibble: other pod) + quint8 podsBatteryByte = static_cast(data[6]); + + // Flags and case battery byte (upper nibble: case battery, lower nibble: flags) + quint8 flagsAndCaseBattery = static_cast(data[7]); + + // Lid open counter and device color + quint8 lidIndicator = static_cast(data[8]); + deviceInfo.deviceColor = static_cast(data[9]); + + deviceInfo.connectionState = static_cast(data[10]); + + // Determine primary pod (bit 5 of status) and value flipping + bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary + bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary + + // Parse battery levels + int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F; + int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F; + deviceInfo.leftPodBattery = (leftNibble == 15) ? -1 : leftNibble * 10; + deviceInfo.rightPodBattery = (rightNibble == 15) ? -1 : rightNibble * 10; + int caseNibble = flagsAndCaseBattery & 0x0F; // Extracts lower nibble + deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; + + // Parse charging statuses from flags (upper 4 bits of data[7]) + quint8 flags = (flagsAndCaseBattery >> 4) & 0x0F; // Extracts lower nibble + deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // Depending on primary, bit 0 or 1 + deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // Depending on primary, bit 1 or 0 + deviceInfo.caseCharging = (flags & 0x04) != 0; // bit 2 + + // Additional status flags from status byte (data[5]) + deviceInfo.isThisPodInTheCase = (status & 0x40) != 0; // Bit 6 + deviceInfo.isOnePodInCase = (status & 0x10) != 0; // Bit 4 + deviceInfo.areBothPodsInCase = (status & 0x04) != 0; // Bit 2 + + // In-ear detection with XOR logic + bool xorFactor = areValuesFlipped ^ deviceInfo.isThisPodInTheCase; + 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 + + // Microphone status + deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase; + deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase; + + 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); + } + + return true; +} + +bool ProximityParser::parseAppleFindMyData(const QByteArray &data, DeviceInfo &deviceInfo) +{ + // Minimum length for Find My message + if (data.size() < 26 || data[0] != 0x12 || data[1] != 0x19) + { + return false; + } + + // Set device type + deviceInfo.deviceType = DeviceInfo::DeviceType::FIND_MY; + + // Parse status byte + quint8 status = static_cast(data[2]); + deviceInfo.status = status; + + // Check maintained bit (bit 2) + bool isMaintained = (status & 0x04) != 0; + deviceInfo.isMaintained = isMaintained; + + // Parse battery level if maintained + if (isMaintained) + { + int batteryLevel = (status >> 6) & 0x03; + switch (batteryLevel) + { + case 0: + deviceInfo.batteryLevel = DeviceInfo::BatteryLevel::CRITICALLY_LOW; + break; + case 1: + deviceInfo.batteryLevel = DeviceInfo::BatteryLevel::LOW; + break; + case 2: + deviceInfo.batteryLevel = DeviceInfo::BatteryLevel::MEDIUM; + break; + case 3: + deviceInfo.batteryLevel = DeviceInfo::BatteryLevel::FULL; + break; + } + } + else + { + deviceInfo.batteryLevel = DeviceInfo::BatteryLevel::UNKNOWN; + } + + // Extract public key (bytes 3-24) + deviceInfo.publicKey = data.mid(3, 22); + + // Extract public key bits (byte 25) + deviceInfo.publicKeyBits = static_cast(data[25]); + + // Extract hint (byte 26) + deviceInfo.hint = static_cast(data[26]); + + return true; +} \ No newline at end of file diff --git a/linux/ble/proximityparser.h b/linux/ble/proximityparser.h new file mode 100644 index 00000000..63ae2eeb --- /dev/null +++ b/linux/ble/proximityparser.h @@ -0,0 +1,89 @@ +#ifndef PROXIMITYPARSER_H +#define PROXIMITYPARSER_H + +#include +#include +#include + +class DeviceInfo +{ +public: + QString name; + QString address; + int leftPodBattery = -1; // -1 indicates not available + int rightPodBattery = -1; + int caseBattery = -1; + bool leftCharging = false; + bool rightCharging = false; + bool caseCharging = false; + quint16 deviceModel = 0; + quint8 lidOpenCounter = 0; + quint8 deviceColor = 0; + quint8 status = 0; + QByteArray rawData; + + // Additional status flags from Kotlin version + bool isLeftPodInEar = false; + bool isRightPodInEar = false; + bool isLeftPodMicrophone = false; + bool isRightPodMicrophone = false; + bool isThisPodInTheCase = false; + bool isOnePodInCase = false; + bool areBothPodsInCase = false; + + // Lid state enumeration + enum class LidState + { + OPEN = 0x0, + CLOSED = 0x1, + UNKNOWN, + }; + LidState lidState = LidState::UNKNOWN; + + // Connection state enumeration + enum class ConnectionState : uint8_t + { + DISCONNECTED = 0x00, + IDLE = 0x04, + MUSIC = 0x05, + CALL = 0x06, + RINGING = 0x07, + HANGING_UP = 0x09, + UNKNOWN = 0xFF // Using 0xFF for representing null in the original + }; + ConnectionState connectionState = ConnectionState::UNKNOWN; + + QDateTime lastSeen; // Timestamp of last detection + + enum class DeviceType + { + PROXIMITY, + FIND_MY, + UNKNOWN + }; + DeviceType deviceType = DeviceType::UNKNOWN; + + enum class BatteryLevel + { + UNKNOWN, + CRITICALLY_LOW, + LOW, + MEDIUM, + FULL + }; + BatteryLevel batteryLevel = BatteryLevel::UNKNOWN; + + bool isMaintained = false; + QByteArray publicKey; + quint8 publicKeyBits = 0; + quint8 hint = 0; +}; + +class ProximityParser +{ +public: + static bool parseAppleProximityData(const QByteArray &data, DeviceInfo &deviceInfo); + static bool parseAppleFindMyData(const QByteArray &data, DeviceInfo &deviceInfo); +}; + +#endif // PROXIMITYPARSER_H \ No newline at end of file