From 4e72f6573e4ef105073f0b051a7fbe2144543ba7 Mon Sep 17 00:00:00 2001 From: Tim Gromeyer <58736434+tim-gromeyer@users.noreply.github.com> Date: Sun, 30 Mar 2025 12:00:13 +0200 Subject: [PATCH] [Linux] Add battery indicator (#89) * [Linux] Expose battery info to QML * [Linux] Add battery indicator * [Linux] Dynamically hide case battery level if we have no data for it * Reduce animation speed --- linux/BatteryIndicator.qml | 139 +++++++++++++++++++++++++++++++++++++ linux/CMakeLists.txt | 1 + linux/Main.qml | 62 +++++++++++++++-- linux/battery.hpp | 54 +++++++++++--- linux/main.cpp | 19 +++-- 5 files changed, 254 insertions(+), 21 deletions(-) create mode 100644 linux/BatteryIndicator.qml diff --git a/linux/BatteryIndicator.qml b/linux/BatteryIndicator.qml new file mode 100644 index 0000000..a968073 --- /dev/null +++ b/linux/BatteryIndicator.qml @@ -0,0 +1,139 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +// BatteryIndicator.qml +Rectangle { + id: root + + // Public properties + property int batteryLevel: 50 // 0-100 + property bool isCharging: false + property bool darkMode: false + + // Private properties + readonly property color darkModeBackground: "#1C1C1E" + readonly property color lightModeBackground: "#FFFFFF" + readonly property color darkModeText: "#FFFFFF" + readonly property color lightModeText: "#000000" + + readonly property color batteryLowColor: "#FF453A" + readonly property color batteryMediumColor: "#FFD60A" + readonly property color batteryHighColor: "#30D158" + readonly property color chargingColor: "#30D158" + + // Size parameters + width: 85 + height: 40 + color: "transparent" + + // Dynamic colors based on dark/light mode + readonly property color backgroundColor: darkMode ? darkModeBackground : lightModeBackground + readonly property color textColor: darkMode ? darkModeText : lightModeText + readonly property color borderColor: darkMode ? Qt.rgba(1, 1, 1, 0.3) : Qt.rgba(0, 0, 0, 0.3) + + // Battery level color based on percentage + readonly property color levelColor: { + if (isCharging) return chargingColor; + if (batteryLevel <= 20) return batteryLowColor; + if (batteryLevel <= 50) return batteryMediumColor; + return batteryHighColor; + } + + RowLayout { + anchors.fill: parent + spacing: 5 + + // Battery percentage text + Text { + id: percentageText + text: root.batteryLevel + "%" + color: root.textColor + font.pixelSize: 14 + font.family: "SF Pro Text" // Apple system font + Layout.alignment: Qt.AlignVCenter + } + + // Battery icon + Item { + id: batteryIcon + Layout.preferredWidth: 32 + Layout.preferredHeight: 16 + Layout.alignment: Qt.AlignVCenter + + // Main battery body + Rectangle { + id: batteryBody + width: parent.width - 2 + height: parent.height + radius: 3 + color: "transparent" + border.width: 1.5 + border.color: root.borderColor + + // Battery level fill + Rectangle { + id: batteryFill + width: Math.max(2, (batteryBody.width - 4) * (root.batteryLevel / 100)) + height: batteryBody.height - 4 + anchors.left: parent.left + anchors.leftMargin: 2 + anchors.verticalCenter: parent.verticalCenter + radius: 1.5 + color: root.levelColor + + // Animation for smooth transitions + Behavior on width { + NumberAnimation { duration: 300; easing.type: Easing.OutCubic } + } + + // Flash effect when charging + SequentialAnimation { + running: root.isCharging + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { target: batteryFill; property: "opacity"; to: 0.7; duration: 3000 } + NumberAnimation { target: batteryFill; property: "opacity"; to: 1.0; duration: 3000 } + } + } + } + + // Battery positive terminal + Rectangle { + width: 2 + height: 8 + radius: 1 + color: root.borderColor + anchors.left: batteryBody.right + anchors.verticalCenter: batteryBody.verticalCenter + } + + // Alternative charging bolt using Canvas + Canvas { + id: chargingBolt + visible: root.isCharging + width: 14 + height: 14 + anchors.centerIn: batteryBody + + onPaint: { + var ctx = getContext("2d"); + ctx.reset(); + + // Draw a lightning bolt + ctx.fillStyle = root.darkMode ? "#000000" : "#FFFFFF"; + ctx.beginPath(); + ctx.moveTo(7, 2); // Top point + ctx.lineTo(3, 8); // Middle left + ctx.lineTo(6, 8); // Middle center + ctx.lineTo(5, 12); // Bottom point + ctx.lineTo(11, 6); // Middle right + ctx.lineTo(8, 6); // Middle center + ctx.lineTo(9, 2); // Back to top + ctx.closePath(); + ctx.fill(); + } + } + } + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index aea0d09..7dcb4dc 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -26,6 +26,7 @@ qt_add_qml_module(applinux VERSION 1.0 QML_FILES Main.qml + BatteryIndicator.qml ) # Add the resource file diff --git a/linux/Main.qml b/linux/Main.qml index faeb9fa..a2da2c5 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -1,5 +1,6 @@ import QtQuick 2.15 import QtQuick.Controls 2.15 +import me.kavishdevar.Battery 1.0 ApplicationWindow { visible: true @@ -11,10 +12,61 @@ ApplicationWindow { spacing: 20 padding: 20 - Text { - id: batteryStatus - text: "Battery Status: " + airPodsTrayApp.batteryStatus - color: "#ffffff" + // Battery Indicator + Row { + // center the content + anchors.horizontalCenter: parent.horizontalCenter + spacing: 15 + + Column { + spacing: 5 + + Text { + text: "Left" + color: "#ffffff" + font.pixelSize: 12 + } + + BatteryIndicator { + batteryLevel: airPodsTrayApp.battery.leftPodLevel + isCharging: airPodsTrayApp.battery.leftPodCharging + darkMode: true + } + } + + Column { + spacing: 5 + + Text { + text: "Right" + color: "#ffffff" + font.pixelSize: 12 + } + + BatteryIndicator { + batteryLevel: airPodsTrayApp.battery.rightPodLevel + isCharging: airPodsTrayApp.battery.rightPodCharging + darkMode: true + } + } + + Column { + spacing: 5 + // hide the case status if battery level is 0 and no pod is in case + visible: airPodsTrayApp.battery.caseLevel > 0 || airPodsTrayApp.oneOrMorePodsInCase + + Text { + text: "Case" + color: "#ffffff" + font.pixelSize: 12 + } + + BatteryIndicator { + batteryLevel: airPodsTrayApp.battery.caseLevel + isCharging: airPodsTrayApp.battery.caseCharging + darkMode: true + } + } } Text { @@ -91,4 +143,4 @@ ApplicationWindow { } } } -} \ No newline at end of file +} diff --git a/linux/battery.hpp b/linux/battery.hpp index 6e90de6..e7e9c5e 100644 --- a/linux/battery.hpp +++ b/linux/battery.hpp @@ -1,12 +1,30 @@ #include #include #include +#include #include "airpods_packets.h" -class Battery +class Battery : public QObject { + Q_OBJECT + + Q_PROPERTY(quint8 leftPodLevel READ getLeftPodLevel NOTIFY batteryStatusChanged) + Q_PROPERTY(bool leftPodCharging READ isLeftPodCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged) + Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged) + Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged) + Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged) + public: + explicit Battery(QObject *parent = nullptr) : QObject(parent) + { + // Initialize all components to unknown state + states[Component::Left] = {}; + states[Component::Right] = {}; + states[Component::Case] = {}; + } + // Enum for AirPods components enum class Component { @@ -14,6 +32,7 @@ public: Left = 0x04, Case = 0x08, }; + Q_ENUM(Component) enum class BatteryStatus { @@ -22,6 +41,7 @@ public: Discharging = 0x02, Disconnected = 0x04, }; + Q_ENUM(BatteryStatus) // Struct to hold battery level and status struct BatteryState @@ -30,14 +50,6 @@ public: BatteryStatus status = BatteryStatus::Unknown; }; - // Constructor: Initialize all components to unknown state - Battery() - { - states[Component::Left] = {}; - states[Component::Right] = {}; - states[Component::Case] = {}; - } - // Parse the battery status packet and detect primary/secondary pods bool parsePacket(const QByteArray &packet) { @@ -97,6 +109,9 @@ public: secondaryPod = podsInPacket[1]; // Second pod is secondary } + // Emit signal to notify about battery status change + emit batteryStatusChanged(); + return true; } @@ -140,8 +155,27 @@ public: Component getPrimaryPod() const { return primaryPod; } Component getSecondaryPod() const { return secondaryPod; } + quint8 getLeftPodLevel() const { return states.value(Component::Left).level; } + bool isLeftPodCharging() const + { + return states.value(Component::Left).status == BatteryStatus::Charging; + } + quint8 getRightPodLevel() const { return states.value(Component::Right).level; } + bool isRightPodCharging() const + { + return states.value(Component::Right).status == BatteryStatus::Charging; + } + quint8 getCaseLevel() const { return states.value(Component::Case).level; } + bool isCaseCharging() const + { + return states.value(Component::Case).status == BatteryStatus::Charging; + } + +signals: + void batteryStatusChanged(); + private: QMap states; Component primaryPod; Component secondaryPod; -}; \ No newline at end of file +}; diff --git a/linux/main.cpp b/linux/main.cpp index bde2a51..eee2122 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -21,9 +21,13 @@ class AirPodsTrayApp : public QObject { Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged) Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged) Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged) + Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged) + Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged) public: - AirPodsTrayApp(bool debugMode) : debugMode(debugMode) { + AirPodsTrayApp(bool debugMode) + : debugMode(debugMode) + , m_battery(new Battery(this)) { if (debugMode) { QLoggingCategory::setFilterRules("airpodsApp.debug=true"); } else { @@ -102,6 +106,8 @@ public: bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; } int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; } QString deviceName() const { return m_deviceName; } + Battery *getBattery() const { return m_battery; } + bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); } private: bool debugMode; @@ -575,12 +581,11 @@ private slots: // Battery Status else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS)) { - Battery battery; - battery.parsePacket(data); + m_battery->parsePacket(data); - int leftLevel = battery.getState(Battery::Component::Left).level; - int rightLevel = battery.getState(Battery::Component::Right).level; - int caseLevel = battery.getState(Battery::Component::Case).level; + int leftLevel = m_battery->getState(Battery::Component::Left).level; + int rightLevel = m_battery->getState(Battery::Component::Right).level; + int caseLevel = m_battery->getState(Battery::Component::Case).level; m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%") .arg(leftLevel) .arg(rightLevel) @@ -843,6 +848,7 @@ private: bool m_conversationalAwareness = false; int m_adaptiveNoiseLevel = 50; QString m_deviceName; + Battery *m_battery; }; int main(int argc, char *argv[]) { @@ -857,6 +863,7 @@ int main(int argc, char *argv[]) { } QQmlApplicationEngine engine; + qmlRegisterType("me.kavishdevar.Battery", 1, 0, "Battery"); AirPodsTrayApp trayApp(debugMode); engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp); engine.loadFromModule("linux", "Main");