feat: battery charging indicator (mirroring PR #537) (#1427)

## Summary

* **What is the goal of this PR?** All praise goes to @didacta for his
PR #537. Just picked up the reviewer comments to contain the changes as
suggested (there was no response for more than 6 weeks, so I wanted to
reanimate this feature).

Just one addition: should recognize usb cable plug ins / retractions and
update the icon immediately

* **What changes are included?**

## Additional Context

see #537 

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**< NO >**_
This commit is contained in:
jpirnay
2026-03-20 04:33:29 +01:00
committed by GitHub
parent d6951f81b7
commit 71719e1d94
5 changed files with 100 additions and 58 deletions

View File

@@ -7,7 +7,14 @@ void HalGPIO::begin() {
pinMode(UART0_RXD, INPUT);
}
void HalGPIO::update() { inputMgr.update(); }
void HalGPIO::update() {
inputMgr.update();
const bool connected = isUsbConnected();
usbStateChanged = (connected != lastUsbConnected);
lastUsbConnected = connected;
}
bool HalGPIO::wasUsbStateChanged() const { return usbStateChanged; }
bool HalGPIO::isPressed(uint8_t buttonIndex) const { return inputMgr.isPressed(buttonIndex); }

View File

@@ -23,6 +23,9 @@ class HalGPIO {
InputManager inputMgr;
#endif
bool lastUsbConnected = false;
bool usbStateChanged = false;
public:
HalGPIO() = default;
@@ -41,6 +44,9 @@ class HalGPIO {
// Check if USB is connected
bool isUsbConnected() const;
// Returns true once per edge (plug or unplug) since the last update()
bool wasUsbStateChanged() const;
enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
WakeupReason getWakeupReason() const;
@@ -54,3 +60,5 @@ class HalGPIO {
static constexpr uint8_t BTN_DOWN = 5;
static constexpr uint8_t BTN_POWER = 6;
};
extern HalGPIO gpio; // Singleton

View File

@@ -5,6 +5,7 @@
#include <HalStorage.h>
#include <Logging.h>
#include <algorithm>
#include <cstdint>
#include <string>
@@ -34,13 +35,40 @@ void drawBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, i
renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5);
const bool charging = gpio.isUsbConnected();
// The +1 is to round up, so that we always fill at least one pixel
int filledWidth = percentage * (battWidth - 5) / 100 + 1;
if (filledWidth > battWidth - 5) {
filledWidth = battWidth - 5; // Ensure we don't overflow
const int maxFillWidth = battWidth - 5;
const int fillHeight = rectHeight - 4;
if (maxFillWidth <= 0 || fillHeight <= 0) {
return;
}
int filledWidth = percentage * maxFillWidth / 100 + 1;
if (filledWidth > maxFillWidth) {
filledWidth = maxFillWidth;
}
renderer.fillRect(x + 2, y + 2, filledWidth, rectHeight - 4);
// When charging, ensure minimum fill so lightning bolt is fully visible
constexpr int minFillForBolt = 8;
if (charging && filledWidth < minFillForBolt) {
filledWidth = std::min(minFillForBolt, maxFillWidth);
}
renderer.fillRect(x + 2, y + 2, filledWidth, fillHeight);
// Draw lightning bolt when charging (white/inverted on black fill for visibility)
if (charging) {
const int boltX = x + 4;
const int boltY = y + 2;
renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false);
renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false);
renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false);
renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false);
renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false);
renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false);
renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false);
renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false);
}
}
} // namespace

View File

@@ -1,6 +1,7 @@
#include "LyraTheme.h"
#include <GfxRenderer.h>
#include <HalGPIO.h>
#include <HalPowerManager.h>
#include <HalStorage.h>
#include <I18n.h>
@@ -42,6 +43,47 @@ constexpr int listIconSize = 24;
constexpr int mainMenuColumns = 2;
int coverWidth = 0;
void drawLyraBatteryIcon(const GfxRenderer& renderer, int x, int y, int battWidth, int rectHeight,
uint16_t percentage) {
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rectHeight - 1, x + battWidth - 3, y + rectHeight - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rectHeight - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rectHeight - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rectHeight - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rectHeight - 5);
const bool charging = gpio.isUsbConnected();
// Draw bars
if (percentage > 10 || charging) {
renderer.fillRect(x + 2, y + 2, 3, rectHeight - 4);
}
if (percentage > 40 || charging) {
renderer.fillRect(x + 6, y + 2, 3, rectHeight - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rectHeight - 4);
}
if (charging) {
const int boltX = x + 4;
const int boltY = y + 2;
renderer.drawLine(boltX + 4, boltY + 0, boltX + 5, boltY + 0, false);
renderer.drawLine(boltX + 3, boltY + 1, boltX + 4, boltY + 1, false);
renderer.drawLine(boltX + 2, boltY + 2, boltX + 5, boltY + 2, false);
renderer.drawLine(boltX + 3, boltY + 3, boltX + 4, boltY + 3, false);
renderer.drawLine(boltX + 2, boltY + 4, boltX + 3, boltY + 4, false);
renderer.drawLine(boltX + 1, boltY + 5, boltX + 4, boltY + 5, false);
renderer.drawLine(boltX + 2, boltY + 6, boltX + 3, boltY + 6, false);
renderer.drawLine(boltX + 1, boltY + 7, boltX + 2, boltY + 7, false);
}
}
const uint8_t* iconForName(UIIcon icon, int size) {
if (size == 24) {
switch (icon) {
@@ -87,45 +129,19 @@ const uint8_t* iconForName(UIIcon icon, int size) {
void LyraTheme::drawBatteryLeft(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Left aligned: icon on left, percentage on right (reader mode)
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + battWidth, rect.y, percentageText.c_str());
renderer.drawText(SMALL_FONT_ID, rect.x + batteryPercentSpacing + LyraMetrics::values.batteryWidth, rect.y,
percentageText.c_str());
}
// Draw icon
const int x = rect.x;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// Draw bars
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
}
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
}
void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const bool showPercentage) const {
// Right aligned: percentage on left, icon on right (UI headers)
const uint16_t percentage = powerManager.getBatteryPercentage();
const int y = rect.y + 6;
const int battWidth = LyraMetrics::values.batteryWidth;
if (showPercentage) {
const auto percentageText = std::to_string(percentage) + "%";
@@ -137,30 +153,7 @@ void LyraTheme::drawBatteryRight(const GfxRenderer& renderer, Rect rect, const b
renderer.drawText(SMALL_FONT_ID, rect.x - textWidth - batteryPercentSpacing, rect.y, percentageText.c_str());
}
// Draw icon at rect.x
const int x = rect.x;
// Top line
renderer.drawLine(x + 1, y, x + battWidth - 3, y);
// Bottom line
renderer.drawLine(x + 1, y + rect.height - 1, x + battWidth - 3, y + rect.height - 1);
// Left line
renderer.drawLine(x, y + 1, x, y + rect.height - 2);
// Battery end
renderer.drawLine(x + battWidth - 2, y + 1, x + battWidth - 2, y + rect.height - 2);
renderer.drawPixel(x + battWidth - 1, y + 3);
renderer.drawPixel(x + battWidth - 1, y + rect.height - 4);
renderer.drawLine(x + battWidth - 0, y + 4, x + battWidth - 0, y + rect.height - 5);
// Draw bars
if (percentage > 10) {
renderer.fillRect(x + 2, y + 2, 3, rect.height - 4);
}
if (percentage > 40) {
renderer.fillRect(x + 6, y + 2, 3, rect.height - 4);
}
if (percentage > 70) {
renderer.fillRect(x + 10, y + 2, 3, rect.height - 4);
}
drawLyraBatteryIcon(renderer, rect.x, rect.y + 6, LyraMetrics::values.batteryWidth, rect.height, percentage);
}
void LyraTheme::drawHeader(const GfxRenderer& renderer, Rect rect, const char* title, const char* subtitle) const {

View File

@@ -379,6 +379,12 @@ void loop() {
return;
}
// Refresh the battery icon when USB is plugged or unplugged.
// Placed after sleep guards so we never queue a render that won't be processed.
if (gpio.wasUsbStateChanged()) {
activityManager.requestUpdate();
}
const unsigned long activityStartTime = millis();
activityManager.loop();
const unsigned long activityDuration = millis() - activityStartTime;