Skip to content

Commit 4e72f65

Browse files
authored
[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
1 parent 543362d commit 4e72f65

File tree

5 files changed

+254
-21
lines changed

5 files changed

+254
-21
lines changed

linux/BatteryIndicator.qml

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import QtQuick 2.15
2+
import QtQuick.Controls 2.15
3+
import QtQuick.Layouts 1.15
4+
5+
// BatteryIndicator.qml
6+
Rectangle {
7+
id: root
8+
9+
// Public properties
10+
property int batteryLevel: 50 // 0-100
11+
property bool isCharging: false
12+
property bool darkMode: false
13+
14+
// Private properties
15+
readonly property color darkModeBackground: "#1C1C1E"
16+
readonly property color lightModeBackground: "#FFFFFF"
17+
readonly property color darkModeText: "#FFFFFF"
18+
readonly property color lightModeText: "#000000"
19+
20+
readonly property color batteryLowColor: "#FF453A"
21+
readonly property color batteryMediumColor: "#FFD60A"
22+
readonly property color batteryHighColor: "#30D158"
23+
readonly property color chargingColor: "#30D158"
24+
25+
// Size parameters
26+
width: 85
27+
height: 40
28+
color: "transparent"
29+
30+
// Dynamic colors based on dark/light mode
31+
readonly property color backgroundColor: darkMode ? darkModeBackground : lightModeBackground
32+
readonly property color textColor: darkMode ? darkModeText : lightModeText
33+
readonly property color borderColor: darkMode ? Qt.rgba(1, 1, 1, 0.3) : Qt.rgba(0, 0, 0, 0.3)
34+
35+
// Battery level color based on percentage
36+
readonly property color levelColor: {
37+
if (isCharging) return chargingColor;
38+
if (batteryLevel <= 20) return batteryLowColor;
39+
if (batteryLevel <= 50) return batteryMediumColor;
40+
return batteryHighColor;
41+
}
42+
43+
RowLayout {
44+
anchors.fill: parent
45+
spacing: 5
46+
47+
// Battery percentage text
48+
Text {
49+
id: percentageText
50+
text: root.batteryLevel + "%"
51+
color: root.textColor
52+
font.pixelSize: 14
53+
font.family: "SF Pro Text" // Apple system font
54+
Layout.alignment: Qt.AlignVCenter
55+
}
56+
57+
// Battery icon
58+
Item {
59+
id: batteryIcon
60+
Layout.preferredWidth: 32
61+
Layout.preferredHeight: 16
62+
Layout.alignment: Qt.AlignVCenter
63+
64+
// Main battery body
65+
Rectangle {
66+
id: batteryBody
67+
width: parent.width - 2
68+
height: parent.height
69+
radius: 3
70+
color: "transparent"
71+
border.width: 1.5
72+
border.color: root.borderColor
73+
74+
// Battery level fill
75+
Rectangle {
76+
id: batteryFill
77+
width: Math.max(2, (batteryBody.width - 4) * (root.batteryLevel / 100))
78+
height: batteryBody.height - 4
79+
anchors.left: parent.left
80+
anchors.leftMargin: 2
81+
anchors.verticalCenter: parent.verticalCenter
82+
radius: 1.5
83+
color: root.levelColor
84+
85+
// Animation for smooth transitions
86+
Behavior on width {
87+
NumberAnimation { duration: 300; easing.type: Easing.OutCubic }
88+
}
89+
90+
// Flash effect when charging
91+
SequentialAnimation {
92+
running: root.isCharging
93+
loops: Animation.Infinite
94+
alwaysRunToEnd: true
95+
NumberAnimation { target: batteryFill; property: "opacity"; to: 0.7; duration: 3000 }
96+
NumberAnimation { target: batteryFill; property: "opacity"; to: 1.0; duration: 3000 }
97+
}
98+
}
99+
}
100+
101+
// Battery positive terminal
102+
Rectangle {
103+
width: 2
104+
height: 8
105+
radius: 1
106+
color: root.borderColor
107+
anchors.left: batteryBody.right
108+
anchors.verticalCenter: batteryBody.verticalCenter
109+
}
110+
111+
// Alternative charging bolt using Canvas
112+
Canvas {
113+
id: chargingBolt
114+
visible: root.isCharging
115+
width: 14
116+
height: 14
117+
anchors.centerIn: batteryBody
118+
119+
onPaint: {
120+
var ctx = getContext("2d");
121+
ctx.reset();
122+
123+
// Draw a lightning bolt
124+
ctx.fillStyle = root.darkMode ? "#000000" : "#FFFFFF";
125+
ctx.beginPath();
126+
ctx.moveTo(7, 2); // Top point
127+
ctx.lineTo(3, 8); // Middle left
128+
ctx.lineTo(6, 8); // Middle center
129+
ctx.lineTo(5, 12); // Bottom point
130+
ctx.lineTo(11, 6); // Middle right
131+
ctx.lineTo(8, 6); // Middle center
132+
ctx.lineTo(9, 2); // Back to top
133+
ctx.closePath();
134+
ctx.fill();
135+
}
136+
}
137+
}
138+
}
139+
}

linux/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ qt_add_qml_module(applinux
2626
VERSION 1.0
2727
QML_FILES
2828
Main.qml
29+
BatteryIndicator.qml
2930
)
3031

3132
# Add the resource file

linux/Main.qml

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import QtQuick 2.15
22
import QtQuick.Controls 2.15
3+
import me.kavishdevar.Battery 1.0
34

45
ApplicationWindow {
56
visible: true
@@ -11,10 +12,61 @@ ApplicationWindow {
1112
spacing: 20
1213
padding: 20
1314

14-
Text {
15-
id: batteryStatus
16-
text: "Battery Status: " + airPodsTrayApp.batteryStatus
17-
color: "#ffffff"
15+
// Battery Indicator
16+
Row {
17+
// center the content
18+
anchors.horizontalCenter: parent.horizontalCenter
19+
spacing: 15
20+
21+
Column {
22+
spacing: 5
23+
24+
Text {
25+
text: "Left"
26+
color: "#ffffff"
27+
font.pixelSize: 12
28+
}
29+
30+
BatteryIndicator {
31+
batteryLevel: airPodsTrayApp.battery.leftPodLevel
32+
isCharging: airPodsTrayApp.battery.leftPodCharging
33+
darkMode: true
34+
}
35+
}
36+
37+
Column {
38+
spacing: 5
39+
40+
Text {
41+
text: "Right"
42+
color: "#ffffff"
43+
font.pixelSize: 12
44+
}
45+
46+
BatteryIndicator {
47+
batteryLevel: airPodsTrayApp.battery.rightPodLevel
48+
isCharging: airPodsTrayApp.battery.rightPodCharging
49+
darkMode: true
50+
}
51+
}
52+
53+
Column {
54+
spacing: 5
55+
// hide the case status if battery level is 0 and no pod is in case
56+
visible: airPodsTrayApp.battery.caseLevel > 0 || airPodsTrayApp.oneOrMorePodsInCase
57+
58+
Text {
59+
text: "Case"
60+
color: "#ffffff"
61+
font.pixelSize: 12
62+
}
63+
64+
BatteryIndicator {
65+
batteryLevel: airPodsTrayApp.battery.caseLevel
66+
isCharging: airPodsTrayApp.battery.caseCharging
67+
darkMode: true
68+
}
69+
}
1870
}
1971

2072
Text {
@@ -91,4 +143,4 @@ ApplicationWindow {
91143
}
92144
}
93145
}
94-
}
146+
}

linux/battery.hpp

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,38 @@
11
#include <QByteArray>
22
#include <QMap>
33
#include <QString>
4+
#include <QObject>
45

56
#include "airpods_packets.h"
67

7-
class Battery
8+
class Battery : public QObject
89
{
10+
Q_OBJECT
11+
12+
Q_PROPERTY(quint8 leftPodLevel READ getLeftPodLevel NOTIFY batteryStatusChanged)
13+
Q_PROPERTY(bool leftPodCharging READ isLeftPodCharging NOTIFY batteryStatusChanged)
14+
Q_PROPERTY(quint8 rightPodLevel READ getRightPodLevel NOTIFY batteryStatusChanged)
15+
Q_PROPERTY(bool rightPodCharging READ isRightPodCharging NOTIFY batteryStatusChanged)
16+
Q_PROPERTY(quint8 caseLevel READ getCaseLevel NOTIFY batteryStatusChanged)
17+
Q_PROPERTY(bool caseCharging READ isCaseCharging NOTIFY batteryStatusChanged)
18+
919
public:
20+
explicit Battery(QObject *parent = nullptr) : QObject(parent)
21+
{
22+
// Initialize all components to unknown state
23+
states[Component::Left] = {};
24+
states[Component::Right] = {};
25+
states[Component::Case] = {};
26+
}
27+
1028
// Enum for AirPods components
1129
enum class Component
1230
{
1331
Right = 0x02,
1432
Left = 0x04,
1533
Case = 0x08,
1634
};
35+
Q_ENUM(Component)
1736

1837
enum class BatteryStatus
1938
{
@@ -22,6 +41,7 @@ class Battery
2241
Discharging = 0x02,
2342
Disconnected = 0x04,
2443
};
44+
Q_ENUM(BatteryStatus)
2545

2646
// Struct to hold battery level and status
2747
struct BatteryState
@@ -30,14 +50,6 @@ class Battery
3050
BatteryStatus status = BatteryStatus::Unknown;
3151
};
3252

33-
// Constructor: Initialize all components to unknown state
34-
Battery()
35-
{
36-
states[Component::Left] = {};
37-
states[Component::Right] = {};
38-
states[Component::Case] = {};
39-
}
40-
4153
// Parse the battery status packet and detect primary/secondary pods
4254
bool parsePacket(const QByteArray &packet)
4355
{
@@ -97,6 +109,9 @@ class Battery
97109
secondaryPod = podsInPacket[1]; // Second pod is secondary
98110
}
99111

112+
// Emit signal to notify about battery status change
113+
emit batteryStatusChanged();
114+
100115
return true;
101116
}
102117

@@ -140,8 +155,27 @@ class Battery
140155
Component getPrimaryPod() const { return primaryPod; }
141156
Component getSecondaryPod() const { return secondaryPod; }
142157

158+
quint8 getLeftPodLevel() const { return states.value(Component::Left).level; }
159+
bool isLeftPodCharging() const
160+
{
161+
return states.value(Component::Left).status == BatteryStatus::Charging;
162+
}
163+
quint8 getRightPodLevel() const { return states.value(Component::Right).level; }
164+
bool isRightPodCharging() const
165+
{
166+
return states.value(Component::Right).status == BatteryStatus::Charging;
167+
}
168+
quint8 getCaseLevel() const { return states.value(Component::Case).level; }
169+
bool isCaseCharging() const
170+
{
171+
return states.value(Component::Case).status == BatteryStatus::Charging;
172+
}
173+
174+
signals:
175+
void batteryStatusChanged();
176+
143177
private:
144178
QMap<Component, BatteryState> states;
145179
Component primaryPod;
146180
Component secondaryPod;
147-
};
181+
};

linux/main.cpp

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ class AirPodsTrayApp : public QObject {
2121
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
2222
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
2323
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
24+
Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged)
25+
Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged)
2426

2527
public:
26-
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
28+
AirPodsTrayApp(bool debugMode)
29+
: debugMode(debugMode)
30+
, m_battery(new Battery(this)) {
2731
if (debugMode) {
2832
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
2933
} else {
@@ -102,6 +106,8 @@ class AirPodsTrayApp : public QObject {
102106
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
103107
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
104108
QString deviceName() const { return m_deviceName; }
109+
Battery *getBattery() const { return m_battery; }
110+
bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); }
105111

106112
private:
107113
bool debugMode;
@@ -575,12 +581,11 @@ private slots:
575581
// Battery Status
576582
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
577583
{
578-
Battery battery;
579-
battery.parsePacket(data);
584+
m_battery->parsePacket(data);
580585

581-
int leftLevel = battery.getState(Battery::Component::Left).level;
582-
int rightLevel = battery.getState(Battery::Component::Right).level;
583-
int caseLevel = battery.getState(Battery::Component::Case).level;
586+
int leftLevel = m_battery->getState(Battery::Component::Left).level;
587+
int rightLevel = m_battery->getState(Battery::Component::Right).level;
588+
int caseLevel = m_battery->getState(Battery::Component::Case).level;
584589
m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
585590
.arg(leftLevel)
586591
.arg(rightLevel)
@@ -843,6 +848,7 @@ private slots:
843848
bool m_conversationalAwareness = false;
844849
int m_adaptiveNoiseLevel = 50;
845850
QString m_deviceName;
851+
Battery *m_battery;
846852
};
847853

848854
int main(int argc, char *argv[]) {
@@ -857,6 +863,7 @@ int main(int argc, char *argv[]) {
857863
}
858864

859865
QQmlApplicationEngine engine;
866+
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
860867
AirPodsTrayApp trayApp(debugMode);
861868
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
862869
engine.loadFromModule("linux", "Main");

0 commit comments

Comments
 (0)