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