[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
This commit is contained in:
Tim Gromeyer
2025-03-30 12:00:13 +02:00
committed by GitHub
parent 543362da69
commit 4e72f6573e
5 changed files with 254 additions and 21 deletions

139
linux/BatteryIndicator.qml Normal file
View File

@@ -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();
}
}
}
}
}

View File

@@ -26,6 +26,7 @@ qt_add_qml_module(applinux
VERSION 1.0
QML_FILES
Main.qml
BatteryIndicator.qml
)
# Add the resource file

View File

@@ -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 {
}
}
}
}
}

View File

@@ -1,12 +1,30 @@
#include <QByteArray>
#include <QMap>
#include <QString>
#include <QObject>
#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<Component, BatteryState> states;
Component primaryPod;
Component secondaryPod;
};
};

View File

@@ -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<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
AirPodsTrayApp trayApp(debugMode);
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
engine.loadFromModule("linux", "Main");