From 4438cdae6fc225b020df9c25496ff4fe154ffd21 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sun, 9 Mar 2025 11:13:21 +0100 Subject: [PATCH 01/12] [Linux] BLE monitor app --- linux/ble/CMakeLists.txt | 25 +++ linux/ble/main.cpp | 469 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 linux/ble/CMakeLists.txt create mode 100644 linux/ble/main.cpp diff --git a/linux/ble/CMakeLists.txt b/linux/ble/CMakeLists.txt new file mode 100644 index 0000000..0075d80 --- /dev/null +++ b/linux/ble/CMakeLists.txt @@ -0,0 +1,25 @@ +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.5 REQUIRED COMPONENTS Core Bluetooth Widgets) + +qt_add_executable(ble_monitor + main.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} +) \ No newline at end of file diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp new file mode 100644 index 0000000..f4504a4 --- /dev/null +++ b/linux/ble/main.cpp @@ -0,0 +1,469 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class DeviceInfo +{ +public: + QString name; + QString address; + int leftPodBattery = 0; + int rightPodBattery = 0; + int caseBattery = 0; + bool leftCharging = false; + bool rightCharging = false; + bool caseCharging = false; + quint16 deviceModel = 0; + quint8 lidOpenCounter = 0; + quint8 deviceColor = 0; + quint8 status = 0; + QByteArray rawData; +}; + +class BleScanner : public QMainWindow +{ + Q_OBJECT + +public: + BleScanner(QWidget *parent = nullptr) : QMainWindow(parent) + { + setWindowTitle("AirPods Battery Monitor"); + resize(600, 400); + + // Create central widget and layout + QWidget *centralWidget = new QWidget(this); + QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget); + setCentralWidget(centralWidget); + + // Create scan control buttons + 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); + + // Create device table + 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); + + // Create detail view for selected device + detailsGroup = new QGroupBox("Device Details", this); + QGridLayout *detailsLayout = new QGridLayout(detailsGroup); + + // Left pod details + 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); + + // Right pod details + 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); + + // Case details + 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); + + // Additional info + detailsLayout->addWidget(new QLabel("Model:"), 3, 0); + modelLabel = new QLabel(this); + detailsLayout->addWidget(modelLabel, 3, 1); + + detailsLayout->addWidget(new QLabel("Status:"), 4, 0); + statusLabel = new QLabel(this); + detailsLayout->addWidget(statusLabel, 4, 1); + + detailsLayout->addWidget(new QLabel("Lid Opens:"), 5, 0); + lidOpenLabel = new QLabel(this); + detailsLayout->addWidget(lidOpenLabel, 5, 1); + + detailsLayout->addWidget(new QLabel("Color:"), 6, 0); + colorLabel = new QLabel(this); + detailsLayout->addWidget(colorLabel, 6, 1); + + // Raw data display + detailsLayout->addWidget(new QLabel("Raw Data:"), 7, 0); + rawDataLabel = new QLabel(this); + rawDataLabel->setWordWrap(true); + detailsLayout->addWidget(rawDataLabel, 7, 1, 1, 2); + + mainLayout->addWidget(detailsGroup); + detailsGroup->setVisible(false); + + // Create system tray icon + trayIcon = new QSystemTrayIcon(QIcon::fromTheme("bluetooth"), this); + QMenu *trayMenu = new QMenu(this); + QAction *showAction = trayMenu->addAction("Show"); + trayMenu->addSeparator(); + QAction *exitAction = trayMenu->addAction("Exit"); + + trayIcon->setContextMenu(trayMenu); + trayIcon->setToolTip("AirPods Battery Monitor"); + trayIcon->show(); + + // Create BLE discovery agent + discoveryAgent = new QBluetoothDeviceDiscoveryAgent(this); + discoveryAgent->setLowEnergyDiscoveryTimeout(0); // Infinite scanning + + // Setup auto-refresh timer + refreshTimer = new QTimer(this); + + // Connect signals and slots + connect(scanButton, &QPushButton::clicked, this, &BleScanner::startScan); + connect(stopButton, &QPushButton::clicked, this, &BleScanner::stopScan); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, + this, &BleScanner::onDeviceDiscovered); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, + this, &BleScanner::onScanFinished); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred, + this, &BleScanner::onErrorOccurred); + connect(deviceTable, &QTableWidget::itemSelectionChanged, + this, &BleScanner::onDeviceSelected); + connect(refreshTimer, &QTimer::timeout, this, &BleScanner::updateDeviceList); + connect(showAction, &QAction::triggered, this, &BleScanner::show); + connect(exitAction, &QAction::triggered, qApp, &QApplication::quit); + } + +private slots: + void startScan() + { + qDebug() << "Starting BLE scan..."; + scanButton->setEnabled(false); + stopButton->setEnabled(true); + + // Clear previous devices + devices.clear(); + deviceTable->setRowCount(0); + detailsGroup->setVisible(false); + + // Start discovery + discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); + + // Start refresh timer (update UI every 2 seconds) + refreshTimer->start(2000); + } + + void stopScan() + { + qDebug() << "Stopping BLE scan..."; + discoveryAgent->stop(); + refreshTimer->stop(); + scanButton->setEnabled(true); + stopButton->setEnabled(false); + } + + void onDeviceDiscovered(const QBluetoothDeviceInfo &info) + { + // Check if this is an Apple device with the manufacturer data we're interested in + if (info.manufacturerData().contains(0x004C)) + { + QByteArray data = info.manufacturerData().value(0x004C); + + // Check for proximity pairing format (byte 0 = 0x07) + if (data.size() >= 10 && data[0] == 0x07) + { + QString address = info.address().toString(); + + // Create or update device info + DeviceInfo deviceInfo; + deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); + deviceInfo.address = address; + deviceInfo.rawData = data; // Store the raw data + + // Parse device model + deviceInfo.deviceModel = static_cast(data[3]) | (static_cast(data[4]) << 8); + + // Store status byte (byte 5) + deviceInfo.status = static_cast(data[5]); + + // Parse pods battery levels (byte 6) + quint8 podsBatteryByte = static_cast(data[6]); + deviceInfo.leftPodBattery = ((podsBatteryByte >> 4) & 0x0F) * 10; // Scale to 0-100 + deviceInfo.rightPodBattery = (podsBatteryByte & 0x0F) * 10; // Scale to 0-100 + + // Parse charging status and case battery level (byte 7) + quint8 statusByte = static_cast(data[7]); + deviceInfo.caseCharging = (statusByte & 0x02) != 0; + deviceInfo.rightCharging = (statusByte & 0x04) != 0; + deviceInfo.leftCharging = (statusByte & 0x08) != 0; + deviceInfo.caseBattery = ((statusByte >> 4) & 0x0F) * 10; // Scale to 0-100 + + // Byte 8 is the lid open counter + deviceInfo.lidOpenCounter = static_cast(data[8]); + + // Byte 9 is the device color + deviceInfo.deviceColor = static_cast(data[9]); + + // Update device list + devices[address] = deviceInfo; + + qDebug() << "Found device:" << deviceInfo.name + << "Left:" << deviceInfo.leftPodBattery << "%" + << "Right:" << deviceInfo.rightPodBattery << "%" + << "Case:" << deviceInfo.caseBattery << "%"; + } + } + } + + void onScanFinished() + { + qDebug() << "Scan finished."; + // In case of a manual stop, we don't restart + if (stopButton->isEnabled()) + { + // Restart scanning to keep it continuous + discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); + } + } + + void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) + { + qDebug() << "Error occurred:" << error; + stopScan(); + } + + void updateDeviceList() + { + // Store currently selected device address + QString selectedAddress; + if (deviceTable->selectionModel()->hasSelection()) + { + int row = deviceTable->selectionModel()->selectedRows().first().row(); + selectedAddress = deviceTable->item(row, 4)->text(); + } + + // Clear and repopulate the table + 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(); + + // Device name + QTableWidgetItem *nameItem = new QTableWidgetItem(device.name); + deviceTable->setItem(row, 0, nameItem); + + // Left pod battery + QString leftStatus = QString::number(device.leftPodBattery) + "%"; + if (device.leftCharging) + leftStatus += " ⚡"; + QTableWidgetItem *leftItem = new QTableWidgetItem(leftStatus); + deviceTable->setItem(row, 1, leftItem); + + // Right pod battery + QString rightStatus = QString::number(device.rightPodBattery) + "%"; + if (device.rightCharging) + rightStatus += " ⚡"; + QTableWidgetItem *rightItem = new QTableWidgetItem(rightStatus); + deviceTable->setItem(row, 2, rightItem); + + // Case battery + QString caseStatus = QString::number(device.caseBattery) + "%"; + if (device.caseCharging) + caseStatus += " ⚡"; + QTableWidgetItem *caseItem = new QTableWidgetItem(caseStatus); + deviceTable->setItem(row, 3, caseItem); + + // Address + QTableWidgetItem *addressItem = new QTableWidgetItem(device.address); + deviceTable->setItem(row, 4, addressItem); + + // Reselect the previously selected device + if (device.address == selectedAddress) + { + deviceTable->selectRow(row); + } + } + + // Update system tray tooltip with device info if available + if (!devices.isEmpty()) + { + auto it = devices.begin(); + QString tooltip = QString("%1\nLeft: %2% | Right: %3% | Case: %4%") + .arg(it.value().name) + .arg(it.value().leftPodBattery) + .arg(it.value().rightPodBattery) + .arg(it.value().caseBattery); + trayIcon->setToolTip(tooltip); + } + } + + void onDeviceSelected() + { + if (!deviceTable->selectionModel()->hasSelection()) + { + detailsGroup->setVisible(false); + return; + } + + int row = deviceTable->selectionModel()->selectedRows().first().row(); + QString address = deviceTable->item(row, 4)->text(); + + if (!devices.contains(address)) + { + detailsGroup->setVisible(false); + return; + } + + const DeviceInfo &device = devices[address]; + + // Update details view + leftBatteryBar->setValue(device.leftPodBattery); + rightBatteryBar->setValue(device.rightPodBattery); + caseBatteryBar->setValue(device.caseBattery); + + leftChargingLabel->setText(device.leftCharging ? "Charging" : "Not Charging"); + rightChargingLabel->setText(device.rightCharging ? "Charging" : "Not Charging"); + caseChargingLabel->setText(device.caseCharging ? "Charging" : "Not Charging"); + + // Set model name based on model ID + QString modelName = getModelName(device.deviceModel); + modelLabel->setText(modelName + " (0x" + QString::number(device.deviceModel, 16).toUpper() + ")"); + + // Display status byte with binary representation + 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)); + + lidOpenLabel->setText(QString::number(device.lidOpenCounter)); + + // Set color name based on color ID + QString colorName = getColorName(device.deviceColor); + colorLabel->setText(colorName + " (" + QString::number(device.deviceColor) + ")"); + + // Display raw data bytes + 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); + + detailsGroup->setVisible(true); + } + +private: + QString getModelName(quint16 modelId) + { + switch (modelId) + { + case 0x0220: + return "AirPods 1st Gen"; + case 0x0F20: + return "AirPods 2nd Gen"; + case 0x1320: + return "AirPods Pro"; + case 0x1420: + return "AirPods Max"; + case 0x1520: + return "AirPods 3rd Gen"; + case 0x1820: + return "AirPods Pro 2nd Gen"; + default: + return "Unknown Apple Device"; + } + } + + QString getColorName(quint8 colorId) + { + switch (colorId) + { + case 0: + return "White"; + case 1: + return "Black"; + case 2: + return "Red"; + case 3: + return "Blue"; + case 4: + return "Pink"; + case 5: + return "Gray"; + case 6: + return "Silver"; + case 7: + return "Gold"; + case 8: + return "Rose Gold"; + default: + return "Unknown"; + } + } + + QBluetoothDeviceDiscoveryAgent *discoveryAgent; + 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 *lidOpenLabel; + QLabel *colorLabel; + QLabel *rawDataLabel; + QSystemTrayIcon *trayIcon; + + // Map of discovered devices (address -> device info) + QMap devices; +}; + +int main(int argc, char *argv[]) +{ + QApplication app(argc, argv); + + BleScanner scanner; + scanner.show(); + + return app.exec(); +} + +#include "main.moc" \ No newline at end of file From 6fa8b5d611c9db7d4267f40c008368e8d6f1d430 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sun, 9 Mar 2025 20:38:44 +0100 Subject: [PATCH 02/12] [Linux] ble - fix charging state was inverted --- linux/ble/main.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp index f4504a4..b505ec6 100644 --- a/linux/ble/main.cpp +++ b/linux/ble/main.cpp @@ -220,9 +220,9 @@ private slots: // Parse charging status and case battery level (byte 7) quint8 statusByte = static_cast(data[7]); - deviceInfo.caseCharging = (statusByte & 0x02) != 0; - deviceInfo.rightCharging = (statusByte & 0x04) != 0; - deviceInfo.leftCharging = (statusByte & 0x08) != 0; + deviceInfo.caseCharging = (statusByte & 0x02) == 0; + deviceInfo.rightCharging = (statusByte & 0x04) == 0; + deviceInfo.leftCharging = (statusByte & 0x08) == 0; deviceInfo.caseBattery = ((statusByte >> 4) & 0x0F) * 10; // Scale to 0-100 // Byte 8 is the lid open counter From 2e52eb3d7d2d29aae96de804319aecc67fe0c72c Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Fri, 14 Mar 2025 20:22:57 +0100 Subject: [PATCH 03/12] Fix AirPods model id --- linux/ble/main.cpp | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp index b505ec6..77bdc31 100644 --- a/linux/ble/main.cpp +++ b/linux/ble/main.cpp @@ -220,9 +220,9 @@ private slots: // Parse charging status and case battery level (byte 7) quint8 statusByte = static_cast(data[7]); - deviceInfo.caseCharging = (statusByte & 0x02) == 0; - deviceInfo.rightCharging = (statusByte & 0x04) == 0; - deviceInfo.leftCharging = (statusByte & 0x08) == 0; + deviceInfo.caseCharging = (statusByte & 0x02) != 0; + deviceInfo.rightCharging = (statusByte & 0x04) != 0; + deviceInfo.leftCharging = (statusByte & 0x08) != 0; deviceInfo.caseBattery = ((statusByte >> 4) & 0x0F) * 10; // Scale to 0-100 // Byte 8 is the lid open counter @@ -389,18 +389,31 @@ private: { switch (modelId) { + // AirPods 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)"; + + // AirPods Max + case 0x0A20: + return "AirPods Max"; + case 0x1F20: + return "AirPods Max (USB-C)"; + + // Airpods Pro + case 0x0e20: return "AirPods Pro"; case 0x1420: - return "AirPods Max"; - case 0x1520: - return "AirPods 3rd Gen"; - case 0x1820: return "AirPods Pro 2nd Gen"; + case 0x2420: + return "AirPods Pro 2nd Gen (USB-C)"; default: return "Unknown Apple Device"; } From 4bb19a87c5b22585e1e42a32a9dbe29fee2a00d0 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Fri, 14 Mar 2025 20:31:21 +0100 Subject: [PATCH 04/12] Fix device model bytes switched --- linux/ble/main.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp index 77bdc31..2998575 100644 --- a/linux/ble/main.cpp +++ b/linux/ble/main.cpp @@ -208,7 +208,7 @@ private slots: deviceInfo.rawData = data; // Store the raw data // Parse device model - deviceInfo.deviceModel = static_cast(data[3]) | (static_cast(data[4]) << 8); + deviceInfo.deviceModel = static_cast(data[4]) | (static_cast(data[3]) << 8); // Store status byte (byte 5) deviceInfo.status = static_cast(data[5]); From a6eb62bb7798f8fcee09da0621a79e47eb53925b Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Fri, 14 Mar 2025 21:43:56 +0100 Subject: [PATCH 05/12] Add more colors --- linux/ble/main.cpp | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp index 2998575..1bc2fca 100644 --- a/linux/ble/main.cpp +++ b/linux/ble/main.cpp @@ -201,7 +201,7 @@ private slots: { QString address = info.address().toString(); - // Create or update device info + // Create or up date device info DeviceInfo deviceInfo; deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); deviceInfo.address = address; @@ -423,24 +423,32 @@ private: { switch (colorId) { - case 0: + case 0x00: return "White"; - case 1: + case 0x01: return "Black"; - case 2: + case 0x02: return "Red"; - case 3: + case 0x03: return "Blue"; - case 4: + case 0x04: return "Pink"; - case 5: + case 0x05: return "Gray"; - case 6: + case 0x06: return "Silver"; - case 7: + case 0x07: return "Gold"; - case 8: + 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"; } From 5b5a62f1569e9fbcfcd43f9b06f744945e30063b Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Fri, 14 Mar 2025 22:39:21 +0100 Subject: [PATCH 06/12] Update --- .gitignore | 1 + linux/ble/CMakeLists.txt | 4 + linux/ble/blemanager.cpp | 139 +++++++++++ linux/ble/blemanager.h | 67 ++++++ linux/ble/blescanner.cpp | 388 +++++++++++++++++++++++++++++++ linux/ble/blescanner.h | 60 +++++ linux/ble/main.cpp | 484 +-------------------------------------- 7 files changed, 661 insertions(+), 482 deletions(-) create mode 100644 linux/ble/blemanager.cpp create mode 100644 linux/ble/blemanager.h create mode 100644 linux/ble/blescanner.cpp create mode 100644 linux/ble/blescanner.h diff --git a/.gitignore b/.gitignore index 384a986..2d8d9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -657,3 +657,4 @@ obj/ !/gradle/wrapper/gradle-wrapper.jar # End of https://www.toptal.com/developers/gitignore/api/qt,c++,clion,kotlin,python,android,pycharm,androidstudio,visualstudiocode,linux +linux/.qmlls.ini diff --git a/linux/ble/CMakeLists.txt b/linux/ble/CMakeLists.txt index 0075d80..71f1998 100644 --- a/linux/ble/CMakeLists.txt +++ b/linux/ble/CMakeLists.txt @@ -12,6 +12,10 @@ find_package(Qt6 6.5 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 diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp new file mode 100644 index 0000000..25d6b92 --- /dev/null +++ b/linux/ble/blemanager.cpp @@ -0,0 +1,139 @@ +#include "blemanager.h" +#include + +BleManager::BleManager(QObject *parent) : QObject(parent) +{ + discoveryAgent = new QBluetoothDeviceDiscoveryAgent(this); + discoveryAgent->setLowEnergyDiscoveryTimeout(0); // Continuous scanning + + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, + this, &BleManager::onDeviceDiscovered); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, + this, &BleManager::onScanFinished); + connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred, + this, &BleManager::onErrorOccurred); +} + +BleManager::~BleManager() +{ + delete discoveryAgent; +} + +void BleManager::startScan() +{ + qDebug() << "Starting BLE scan..."; + devices.clear(); + discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); +} + +void BleManager::stopScan() +{ + qDebug() << "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) + if (info.manufacturerData().contains(0x004C)) + { + QByteArray data = info.manufacturerData().value(0x004C); + // Ensure data is long enough and starts with prefix 0x07 + if (data.size() >= 10 && data[0] == 0x07) + { + QString address = info.address().toString(); + DeviceInfo deviceInfo; + deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); + deviceInfo.address = address; + deviceInfo.rawData = data; + + // 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 + deviceInfo.lidOpenCounter = static_cast(data[8]); + deviceInfo.deviceColor = static_cast(data[9]); + + // 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 >> 4) & 0x0F; + deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; + + // Parse charging statuses from flags (lower 4 bits of data[7]) + quint8 flags = flagsAndCaseBattery & 0x0F; + deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // Bit 1 or 0 + deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // Bit 0 or 1 + deviceInfo.caseCharging = (flags & 0x04) != 0; // Keeping original bit 1 for consistency + + // 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; + + // Determine lid state based on lid open counter + if (deviceInfo.lidOpenCounter >= 0x30 && deviceInfo.lidOpenCounter <= 0x37) + deviceInfo.lidState = DeviceInfo::LidState::OPEN; + else if (deviceInfo.lidOpenCounter >= 0x38 && deviceInfo.lidOpenCounter <= 0x3F) + deviceInfo.lidState = DeviceInfo::LidState::CLOSED; + else if (deviceInfo.lidOpenCounter <= 0x03) + deviceInfo.lidState = DeviceInfo::LidState::NOT_IN_CASE; + else + deviceInfo.lidState = DeviceInfo::LidState::UNKNOWN; + + // 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"); + } + } +} + +void BleManager::onScanFinished() +{ + qDebug() << "Scan finished."; + if (discoveryAgent->isActive()) + { + discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); + } +} + +void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) +{ + qDebug() << "Error occurred:" << error; + stopScan(); +} \ No newline at end of file diff --git a/linux/ble/blemanager.h b/linux/ble/blemanager.h new file mode 100644 index 0000000..0562592 --- /dev/null +++ b/linux/ble/blemanager.h @@ -0,0 +1,67 @@ +#ifndef BLEMANAGER_H +#define BLEMANAGER_H + +#include +#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, + CLOSED, + NOT_IN_CASE, + UNKNOWN + }; + LidState lidState = LidState::UNKNOWN; +}; + +class BleManager : public QObject +{ + Q_OBJECT +public: + explicit BleManager(QObject *parent = nullptr); + ~BleManager(); + + void startScan(); + void stopScan(); + const QMap &getDevices() const; + +private slots: + void onDeviceDiscovered(const QBluetoothDeviceInfo &info); + void onScanFinished(); + void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error); + +private: + QBluetoothDeviceDiscoveryAgent *discoveryAgent; + QMap devices; +}; + +#endif // BLEMANAGER_H \ No newline at end of file diff --git a/linux/ble/blescanner.cpp b/linux/ble/blescanner.cpp new file mode 100644 index 0000000..5139ea9 --- /dev/null +++ b/linux/ble/blescanner.cpp @@ -0,0 +1,388 @@ +#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); + 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); + + mainLayout->addWidget(detailsGroup); + detailsGroup->setVisible(false); + + trayIcon = new QSystemTrayIcon(QIcon::fromTheme("bluetooth"), this); + QMenu *trayMenu = new QMenu(this); + QAction *showAction = trayMenu->addAction("Show"); + trayMenu->addSeparator(); + QAction *exitAction = trayMenu->addAction("Exit"); + trayIcon->setContextMenu(trayMenu); + trayIcon->setToolTip("AirPods Battery Monitor"); + trayIcon->show(); + + 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); + connect(showAction, &QAction::triggered, this, &BleScanner::show); + connect(exitAction, &QAction::triggered, qApp, &QApplication::quit); +} + +void BleScanner::startScan() +{ + scanButton->setEnabled(false); + stopButton->setEnabled(true); + deviceTable->setRowCount(0); + detailsGroup->setVisible(false); + bleManager->startScan(); + refreshTimer->start(2000); +} + +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 (!devices.isEmpty()) + { + auto it = devices.begin(); + QString leftBat = (it.value().leftPodBattery >= 0 ? QString::number(it.value().leftPodBattery) + "%" : "N/A"); + QString rightBat = (it.value().rightPodBattery >= 0 ? QString::number(it.value().rightPodBattery) + "%" : "N/A"); + QString caseBat = (it.value().caseBattery >= 0 ? QString::number(it.value().caseBattery) + "%" : "N/A"); + QString tooltip = QString("%1\nLeft: %2 | Right: %3 | Case: %4") + .arg(it.value().name) + .arg(leftBat) + .arg(rightBat) + .arg(caseBat); + trayIcon->setToolTip(tooltip); + } +} + +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 + switch (device.lidState) + { + case DeviceInfo::LidState::OPEN: + lidStateLabel->setText("Open"); + break; + case DeviceInfo::LidState::CLOSED: + lidStateLabel->setText("Closed"); + break; + case DeviceInfo::LidState::NOT_IN_CASE: + lidStateLabel->setText("Not in Case"); + break; + case DeviceInfo::LidState::UNKNOWN: + lidStateLabel->setText("Unknown"); + break; + } + + 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"); + + 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"; + } +} \ No newline at end of file diff --git a/linux/ble/blescanner.h b/linux/ble/blescanner.h new file mode 100644 index 0000000..1c0280e --- /dev/null +++ b/linux/ble/blescanner.h @@ -0,0 +1,60 @@ +#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); + + 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; + QSystemTrayIcon *trayIcon; + + // New labels for additional DeviceInfo fields + QLabel *leftInEarLabel; + QLabel *rightInEarLabel; + QLabel *leftMicLabel; + QLabel *rightMicLabel; + QLabel *thisPodInCaseLabel; + QLabel *onePodInCaseLabel; + QLabel *bothPodsInCaseLabel; +}; + +#endif // BLESCANNER_H \ No newline at end of file diff --git a/linux/ble/main.cpp b/linux/ble/main.cpp index 1bc2fca..02dd425 100644 --- a/linux/ble/main.cpp +++ b/linux/ble/main.cpp @@ -1,490 +1,10 @@ +#include "blescanner.h" #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class DeviceInfo -{ -public: - QString name; - QString address; - int leftPodBattery = 0; - int rightPodBattery = 0; - int caseBattery = 0; - bool leftCharging = false; - bool rightCharging = false; - bool caseCharging = false; - quint16 deviceModel = 0; - quint8 lidOpenCounter = 0; - quint8 deviceColor = 0; - quint8 status = 0; - QByteArray rawData; -}; - -class BleScanner : public QMainWindow -{ - Q_OBJECT - -public: - BleScanner(QWidget *parent = nullptr) : QMainWindow(parent) - { - setWindowTitle("AirPods Battery Monitor"); - resize(600, 400); - - // Create central widget and layout - QWidget *centralWidget = new QWidget(this); - QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget); - setCentralWidget(centralWidget); - - // Create scan control buttons - 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); - - // Create device table - 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); - - // Create detail view for selected device - detailsGroup = new QGroupBox("Device Details", this); - QGridLayout *detailsLayout = new QGridLayout(detailsGroup); - - // Left pod details - 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); - - // Right pod details - 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); - - // Case details - 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); - - // Additional info - detailsLayout->addWidget(new QLabel("Model:"), 3, 0); - modelLabel = new QLabel(this); - detailsLayout->addWidget(modelLabel, 3, 1); - - detailsLayout->addWidget(new QLabel("Status:"), 4, 0); - statusLabel = new QLabel(this); - detailsLayout->addWidget(statusLabel, 4, 1); - - detailsLayout->addWidget(new QLabel("Lid Opens:"), 5, 0); - lidOpenLabel = new QLabel(this); - detailsLayout->addWidget(lidOpenLabel, 5, 1); - - detailsLayout->addWidget(new QLabel("Color:"), 6, 0); - colorLabel = new QLabel(this); - detailsLayout->addWidget(colorLabel, 6, 1); - - // Raw data display - detailsLayout->addWidget(new QLabel("Raw Data:"), 7, 0); - rawDataLabel = new QLabel(this); - rawDataLabel->setWordWrap(true); - detailsLayout->addWidget(rawDataLabel, 7, 1, 1, 2); - - mainLayout->addWidget(detailsGroup); - detailsGroup->setVisible(false); - - // Create system tray icon - trayIcon = new QSystemTrayIcon(QIcon::fromTheme("bluetooth"), this); - QMenu *trayMenu = new QMenu(this); - QAction *showAction = trayMenu->addAction("Show"); - trayMenu->addSeparator(); - QAction *exitAction = trayMenu->addAction("Exit"); - - trayIcon->setContextMenu(trayMenu); - trayIcon->setToolTip("AirPods Battery Monitor"); - trayIcon->show(); - - // Create BLE discovery agent - discoveryAgent = new QBluetoothDeviceDiscoveryAgent(this); - discoveryAgent->setLowEnergyDiscoveryTimeout(0); // Infinite scanning - - // Setup auto-refresh timer - refreshTimer = new QTimer(this); - - // Connect signals and slots - connect(scanButton, &QPushButton::clicked, this, &BleScanner::startScan); - connect(stopButton, &QPushButton::clicked, this, &BleScanner::stopScan); - connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, - this, &BleScanner::onDeviceDiscovered); - connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, - this, &BleScanner::onScanFinished); - connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred, - this, &BleScanner::onErrorOccurred); - connect(deviceTable, &QTableWidget::itemSelectionChanged, - this, &BleScanner::onDeviceSelected); - connect(refreshTimer, &QTimer::timeout, this, &BleScanner::updateDeviceList); - connect(showAction, &QAction::triggered, this, &BleScanner::show); - connect(exitAction, &QAction::triggered, qApp, &QApplication::quit); - } - -private slots: - void startScan() - { - qDebug() << "Starting BLE scan..."; - scanButton->setEnabled(false); - stopButton->setEnabled(true); - - // Clear previous devices - devices.clear(); - deviceTable->setRowCount(0); - detailsGroup->setVisible(false); - - // Start discovery - discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); - - // Start refresh timer (update UI every 2 seconds) - refreshTimer->start(2000); - } - - void stopScan() - { - qDebug() << "Stopping BLE scan..."; - discoveryAgent->stop(); - refreshTimer->stop(); - scanButton->setEnabled(true); - stopButton->setEnabled(false); - } - - void onDeviceDiscovered(const QBluetoothDeviceInfo &info) - { - // Check if this is an Apple device with the manufacturer data we're interested in - if (info.manufacturerData().contains(0x004C)) - { - QByteArray data = info.manufacturerData().value(0x004C); - - // Check for proximity pairing format (byte 0 = 0x07) - if (data.size() >= 10 && data[0] == 0x07) - { - QString address = info.address().toString(); - - // Create or up date device info - DeviceInfo deviceInfo; - deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); - deviceInfo.address = address; - deviceInfo.rawData = data; // Store the raw data - - // Parse device model - deviceInfo.deviceModel = static_cast(data[4]) | (static_cast(data[3]) << 8); - - // Store status byte (byte 5) - deviceInfo.status = static_cast(data[5]); - - // Parse pods battery levels (byte 6) - quint8 podsBatteryByte = static_cast(data[6]); - deviceInfo.leftPodBattery = ((podsBatteryByte >> 4) & 0x0F) * 10; // Scale to 0-100 - deviceInfo.rightPodBattery = (podsBatteryByte & 0x0F) * 10; // Scale to 0-100 - - // Parse charging status and case battery level (byte 7) - quint8 statusByte = static_cast(data[7]); - deviceInfo.caseCharging = (statusByte & 0x02) != 0; - deviceInfo.rightCharging = (statusByte & 0x04) != 0; - deviceInfo.leftCharging = (statusByte & 0x08) != 0; - deviceInfo.caseBattery = ((statusByte >> 4) & 0x0F) * 10; // Scale to 0-100 - - // Byte 8 is the lid open counter - deviceInfo.lidOpenCounter = static_cast(data[8]); - - // Byte 9 is the device color - deviceInfo.deviceColor = static_cast(data[9]); - - // Update device list - devices[address] = deviceInfo; - - qDebug() << "Found device:" << deviceInfo.name - << "Left:" << deviceInfo.leftPodBattery << "%" - << "Right:" << deviceInfo.rightPodBattery << "%" - << "Case:" << deviceInfo.caseBattery << "%"; - } - } - } - - void onScanFinished() - { - qDebug() << "Scan finished."; - // In case of a manual stop, we don't restart - if (stopButton->isEnabled()) - { - // Restart scanning to keep it continuous - discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); - } - } - - void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) - { - qDebug() << "Error occurred:" << error; - stopScan(); - } - - void updateDeviceList() - { - // Store currently selected device address - QString selectedAddress; - if (deviceTable->selectionModel()->hasSelection()) - { - int row = deviceTable->selectionModel()->selectedRows().first().row(); - selectedAddress = deviceTable->item(row, 4)->text(); - } - - // Clear and repopulate the table - 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(); - - // Device name - QTableWidgetItem *nameItem = new QTableWidgetItem(device.name); - deviceTable->setItem(row, 0, nameItem); - - // Left pod battery - QString leftStatus = QString::number(device.leftPodBattery) + "%"; - if (device.leftCharging) - leftStatus += " ⚡"; - QTableWidgetItem *leftItem = new QTableWidgetItem(leftStatus); - deviceTable->setItem(row, 1, leftItem); - - // Right pod battery - QString rightStatus = QString::number(device.rightPodBattery) + "%"; - if (device.rightCharging) - rightStatus += " ⚡"; - QTableWidgetItem *rightItem = new QTableWidgetItem(rightStatus); - deviceTable->setItem(row, 2, rightItem); - - // Case battery - QString caseStatus = QString::number(device.caseBattery) + "%"; - if (device.caseCharging) - caseStatus += " ⚡"; - QTableWidgetItem *caseItem = new QTableWidgetItem(caseStatus); - deviceTable->setItem(row, 3, caseItem); - - // Address - QTableWidgetItem *addressItem = new QTableWidgetItem(device.address); - deviceTable->setItem(row, 4, addressItem); - - // Reselect the previously selected device - if (device.address == selectedAddress) - { - deviceTable->selectRow(row); - } - } - - // Update system tray tooltip with device info if available - if (!devices.isEmpty()) - { - auto it = devices.begin(); - QString tooltip = QString("%1\nLeft: %2% | Right: %3% | Case: %4%") - .arg(it.value().name) - .arg(it.value().leftPodBattery) - .arg(it.value().rightPodBattery) - .arg(it.value().caseBattery); - trayIcon->setToolTip(tooltip); - } - } - - void onDeviceSelected() - { - if (!deviceTable->selectionModel()->hasSelection()) - { - detailsGroup->setVisible(false); - return; - } - - int row = deviceTable->selectionModel()->selectedRows().first().row(); - QString address = deviceTable->item(row, 4)->text(); - - if (!devices.contains(address)) - { - detailsGroup->setVisible(false); - return; - } - - const DeviceInfo &device = devices[address]; - - // Update details view - leftBatteryBar->setValue(device.leftPodBattery); - rightBatteryBar->setValue(device.rightPodBattery); - caseBatteryBar->setValue(device.caseBattery); - - leftChargingLabel->setText(device.leftCharging ? "Charging" : "Not Charging"); - rightChargingLabel->setText(device.rightCharging ? "Charging" : "Not Charging"); - caseChargingLabel->setText(device.caseCharging ? "Charging" : "Not Charging"); - - // Set model name based on model ID - QString modelName = getModelName(device.deviceModel); - modelLabel->setText(modelName + " (0x" + QString::number(device.deviceModel, 16).toUpper() + ")"); - - // Display status byte with binary representation - 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)); - - lidOpenLabel->setText(QString::number(device.lidOpenCounter)); - - // Set color name based on color ID - QString colorName = getColorName(device.deviceColor); - colorLabel->setText(colorName + " (" + QString::number(device.deviceColor) + ")"); - - // Display raw data bytes - 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); - - detailsGroup->setVisible(true); - } - -private: - QString getModelName(quint16 modelId) - { - switch (modelId) - { - // AirPods - 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)"; - - // AirPods Max - case 0x0A20: - return "AirPods Max"; - case 0x1F20: - return "AirPods Max (USB-C)"; - - // Airpods Pro - 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 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"; - } - } - - QBluetoothDeviceDiscoveryAgent *discoveryAgent; - 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 *lidOpenLabel; - QLabel *colorLabel; - QLabel *rawDataLabel; - QSystemTrayIcon *trayIcon; - - // Map of discovered devices (address -> device info) - QMap devices; -}; int main(int argc, char *argv[]) { QApplication app(argc, argv); - BleScanner scanner; scanner.show(); - return app.exec(); -} - -#include "main.moc" \ No newline at end of file +} \ No newline at end of file From 9e40e6e3fd0e16a2ec1f1a3014b504115c85f1f9 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sat, 15 Mar 2025 11:21:11 +0100 Subject: [PATCH 07/12] Show more info --- linux/ble/blemanager.cpp | 8 ++++---- linux/ble/blescanner.cpp | 12 ++++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 25d6b92..9331f12 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -78,13 +78,13 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) 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 >> 4) & 0x0F; + int caseNibble = (flagsAndCaseBattery >> 4) & 0x0F; // Extracts upper nibble deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; // Parse charging statuses from flags (lower 4 bits of data[7]) - quint8 flags = flagsAndCaseBattery & 0x0F; - deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // Bit 1 or 0 - deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // Bit 0 or 1 + quint8 flags = flagsAndCaseBattery & 0x0F; // Extracts lower nibble + deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // + deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // deviceInfo.caseCharging = (flags & 0x04) != 0; // Keeping original bit 1 for consistency // Additional status flags from status byte (data[5]) diff --git a/linux/ble/blescanner.cpp b/linux/ble/blescanner.cpp index 5139ea9..5928ae3 100644 --- a/linux/ble/blescanner.cpp +++ b/linux/ble/blescanner.cpp @@ -285,21 +285,25 @@ void BleScanner::onDeviceSelected() .arg(statusBinary)); // Lid State enum handling + QString lidStateStr; + switch (device.lidState) { case DeviceInfo::LidState::OPEN: - lidStateLabel->setText("Open"); + lidStateStr.append("Open"); break; case DeviceInfo::LidState::CLOSED: - lidStateLabel->setText("Closed"); + lidStateStr.append("Closed"); break; case DeviceInfo::LidState::NOT_IN_CASE: - lidStateLabel->setText("Not in Case"); + lidStateStr.append("Not in Case"); break; case DeviceInfo::LidState::UNKNOWN: - lidStateLabel->setText("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) + ")"); From cb625d08896bb6e2c3b23d9b602e0d8016cf14d9 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sat, 15 Mar 2025 11:46:50 +0100 Subject: [PATCH 08/12] Remove old devices --- linux/ble/blemanager.cpp | 29 +++++++++++++++++++++++++++++ linux/ble/blemanager.h | 10 ++++++++++ linux/ble/blescanner.cpp | 27 +++------------------------ linux/ble/blescanner.h | 1 - 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 9331f12..158f3b3 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -1,5 +1,6 @@ #include "blemanager.h" #include +#include BleManager::BleManager(QObject *parent) : QObject(parent) { @@ -12,11 +13,17 @@ 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() @@ -24,6 +31,7 @@ void BleManager::startScan() qDebug() << "Starting BLE scan..."; devices.clear(); discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); + pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running } void BleManager::stopScan() @@ -111,6 +119,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) else deviceInfo.lidState = DeviceInfo::LidState::UNKNOWN; + // Update timestamp + deviceInfo.lastSeen = QDateTime::currentDateTime(); + // Store device info in the map devices[address] = deviceInfo; @@ -136,4 +147,22 @@ void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) { qDebug() << "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 0562592..004778d 100644 --- a/linux/ble/blemanager.h +++ b/linux/ble/blemanager.h @@ -5,6 +5,9 @@ #include #include #include +#include + +class QTimer; class DeviceInfo { @@ -41,6 +44,8 @@ public: UNKNOWN }; LidState lidState = LidState::UNKNOWN; + + QDateTime lastSeen; // Timestamp of last detection }; class BleManager : public QObject @@ -58,10 +63,15 @@ private slots: void onDeviceDiscovered(const QBluetoothDeviceInfo &info); void onScanFinished(); void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error); + void pruneOldDevices(); 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 index 5928ae3..416e9a8 100644 --- a/linux/ble/blescanner.cpp +++ b/linux/ble/blescanner.cpp @@ -131,15 +131,6 @@ BleScanner::BleScanner(QWidget *parent) : QMainWindow(parent) mainLayout->addWidget(detailsGroup); detailsGroup->setVisible(false); - trayIcon = new QSystemTrayIcon(QIcon::fromTheme("bluetooth"), this); - QMenu *trayMenu = new QMenu(this); - QAction *showAction = trayMenu->addAction("Show"); - trayMenu->addSeparator(); - QAction *exitAction = trayMenu->addAction("Exit"); - trayIcon->setContextMenu(trayMenu); - trayIcon->setToolTip("AirPods Battery Monitor"); - trayIcon->show(); - bleManager = new BleManager(this); refreshTimer = new QTimer(this); @@ -147,8 +138,6 @@ BleScanner::BleScanner(QWidget *parent) : QMainWindow(parent) connect(stopButton, &QPushButton::clicked, this, &BleScanner::stopScan); connect(deviceTable, &QTableWidget::itemSelectionChanged, this, &BleScanner::onDeviceSelected); connect(refreshTimer, &QTimer::timeout, this, &BleScanner::updateDeviceList); - connect(showAction, &QAction::triggered, this, &BleScanner::show); - connect(exitAction, &QAction::triggered, qApp, &QApplication::quit); } void BleScanner::startScan() @@ -158,7 +147,7 @@ void BleScanner::startScan() deviceTable->setRowCount(0); detailsGroup->setVisible(false); bleManager->startScan(); - refreshTimer->start(2000); + refreshTimer->start(500); } void BleScanner::stopScan() @@ -202,18 +191,8 @@ void BleScanner::updateDeviceList() } } - if (!devices.isEmpty()) - { - auto it = devices.begin(); - QString leftBat = (it.value().leftPodBattery >= 0 ? QString::number(it.value().leftPodBattery) + "%" : "N/A"); - QString rightBat = (it.value().rightPodBattery >= 0 ? QString::number(it.value().rightPodBattery) + "%" : "N/A"); - QString caseBat = (it.value().caseBattery >= 0 ? QString::number(it.value().caseBattery) + "%" : "N/A"); - QString tooltip = QString("%1\nLeft: %2 | Right: %3 | Case: %4") - .arg(it.value().name) - .arg(leftBat) - .arg(rightBat) - .arg(caseBat); - trayIcon->setToolTip(tooltip); + if (deviceTable->selectedItems().isEmpty()) { + deviceTable->selectRow(0); } } diff --git a/linux/ble/blescanner.h b/linux/ble/blescanner.h index 1c0280e..ae10ccd 100644 --- a/linux/ble/blescanner.h +++ b/linux/ble/blescanner.h @@ -45,7 +45,6 @@ private: QLabel *lidStateLabel; // Renamed from lidOpenLabel QLabel *colorLabel; QLabel *rawDataLabel; - QSystemTrayIcon *trayIcon; // New labels for additional DeviceInfo fields QLabel *leftInEarLabel; From 06f7b6bdb8386d50ff2ea2609bb3015b6dc6d12d Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sun, 23 Mar 2025 21:49:27 +0100 Subject: [PATCH 09/12] Add connection state --- linux/ble/blemanager.cpp | 5 +++++ linux/ble/blemanager.h | 13 +++++++++++++ linux/ble/blescanner.cpp | 29 +++++++++++++++++++++++++++++ linux/ble/blescanner.h | 2 ++ 4 files changed, 49 insertions(+) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 158f3b3..bf8171b 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -77,6 +77,8 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) deviceInfo.lidOpenCounter = 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 @@ -119,6 +121,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) else deviceInfo.lidState = DeviceInfo::LidState::UNKNOWN; + + + // Update timestamp deviceInfo.lastSeen = QDateTime::currentDateTime(); diff --git a/linux/ble/blemanager.h b/linux/ble/blemanager.h index 004778d..aed7323 100644 --- a/linux/ble/blemanager.h +++ b/linux/ble/blemanager.h @@ -45,6 +45,19 @@ public: }; 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 }; diff --git a/linux/ble/blescanner.cpp b/linux/ble/blescanner.cpp index 416e9a8..9c38b88 100644 --- a/linux/ble/blescanner.cpp +++ b/linux/ble/blescanner.cpp @@ -128,6 +128,11 @@ BleScanner::BleScanner(QWidget *parent) : QMainWindow(parent) 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); @@ -302,6 +307,7 @@ void BleScanner::onDeviceSelected() 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); } @@ -368,4 +374,27 @@ QString BleScanner::getColorName(quint8 colorId) 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 index ae10ccd..a3958f6 100644 --- a/linux/ble/blescanner.h +++ b/linux/ble/blescanner.h @@ -27,6 +27,7 @@ private slots: private: QString getModelName(quint16 modelId); QString getColorName(quint8 colorId); + QString getConnectionStateName(DeviceInfo::ConnectionState state); BleManager *bleManager; QTimer *refreshTimer; @@ -54,6 +55,7 @@ private: QLabel *thisPodInCaseLabel; QLabel *onePodInCaseLabel; QLabel *bothPodsInCaseLabel; + QLabel *connectionStateLabel; }; #endif // BLESCANNER_H \ No newline at end of file From 033e0be08d7549282673fa733d15d1385993aaf3 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sun, 23 Mar 2025 22:15:40 +0100 Subject: [PATCH 10/12] Fix case battery level --- linux/ble/blemanager.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index bf8171b..3f3376c 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -88,7 +88,7 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) 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 >> 4) & 0x0F; // Extracts upper nibble + int caseNibble = flagsAndCaseBattery & 0x0F; // Extracts lower nibble deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; // Parse charging statuses from flags (lower 4 bits of data[7]) From c74054cc98b833ebf4daf59bdb47f49c255df889 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Sun, 23 Mar 2025 22:15:52 +0100 Subject: [PATCH 11/12] Fix charging states --- linux/ble/blemanager.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 3f3376c..3d4c70e 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -91,10 +91,10 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) int caseNibble = flagsAndCaseBattery & 0x0F; // Extracts lower nibble deviceInfo.caseBattery = (caseNibble == 15) ? -1 : caseNibble * 10; - // Parse charging statuses from flags (lower 4 bits of data[7]) - quint8 flags = flagsAndCaseBattery & 0x0F; // Extracts lower nibble - deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x01) != 0 : (flags & 0x02) != 0; // - deviceInfo.rightCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // + // 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; // + deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // deviceInfo.caseCharging = (flags & 0x04) != 0; // Keeping original bit 1 for consistency // Additional status flags from status byte (data[5]) From 4d07cf4c16673ff8d216fb2da01fd2cdd8a2a76b Mon Sep 17 00:00:00 2001 From: Tim Gromeyer Date: Mon, 24 Mar 2025 08:29:18 +0100 Subject: [PATCH 12/12] Improved lid state detection --- linux/ble/blemanager.cpp | 31 +++++++++++++++---------------- linux/ble/blemanager.h | 7 +++---- linux/ble/blescanner.cpp | 3 --- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/linux/ble/blemanager.cpp b/linux/ble/blemanager.cpp index 3d4c70e..1b5d0bd 100644 --- a/linux/ble/blemanager.cpp +++ b/linux/ble/blemanager.cpp @@ -60,6 +60,12 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) deviceInfo.address = address; deviceInfo.rawData = data; + // 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); @@ -74,7 +80,7 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) quint8 flagsAndCaseBattery = static_cast(data[7]); // Lid open counter and device color - deviceInfo.lidOpenCounter = static_cast(data[8]); + quint8 lidIndicator = static_cast(data[8]); deviceInfo.deviceColor = static_cast(data[9]); deviceInfo.connectionState = static_cast(data[10]); @@ -93,9 +99,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) // 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; // - deviceInfo.leftCharging = areValuesFlipped ? (flags & 0x02) != 0 : (flags & 0x01) != 0; // - deviceInfo.caseCharging = (flags & 0x04) != 0; // Keeping original bit 1 for consistency + 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 @@ -111,18 +117,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase; deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase; - // Determine lid state based on lid open counter - if (deviceInfo.lidOpenCounter >= 0x30 && deviceInfo.lidOpenCounter <= 0x37) - deviceInfo.lidState = DeviceInfo::LidState::OPEN; - else if (deviceInfo.lidOpenCounter >= 0x38 && deviceInfo.lidOpenCounter <= 0x3F) - deviceInfo.lidState = DeviceInfo::LidState::CLOSED; - else if (deviceInfo.lidOpenCounter <= 0x03) - deviceInfo.lidState = DeviceInfo::LidState::NOT_IN_CASE; - else - deviceInfo.lidState = DeviceInfo::LidState::UNKNOWN; - - - + 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); + } // Update timestamp deviceInfo.lastSeen = QDateTime::currentDateTime(); diff --git a/linux/ble/blemanager.h b/linux/ble/blemanager.h index aed7323..380d814 100644 --- a/linux/ble/blemanager.h +++ b/linux/ble/blemanager.h @@ -38,10 +38,9 @@ public: // Lid state enumeration enum class LidState { - OPEN, - CLOSED, - NOT_IN_CASE, - UNKNOWN + OPEN = 0x0, + CLOSED = 0x1, + UNKNOWN, }; LidState lidState = LidState::UNKNOWN; diff --git a/linux/ble/blescanner.cpp b/linux/ble/blescanner.cpp index 9c38b88..2ce149d 100644 --- a/linux/ble/blescanner.cpp +++ b/linux/ble/blescanner.cpp @@ -279,9 +279,6 @@ void BleScanner::onDeviceSelected() case DeviceInfo::LidState::CLOSED: lidStateStr.append("Closed"); break; - case DeviceInfo::LidState::NOT_IN_CASE: - lidStateStr.append("Not in Case"); - break; case DeviceInfo::LidState::UNKNOWN: lidStateStr.append("Unknown"); break;