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