Skip to content

Commit e224aee

Browse files
committed
Add Ready-screen battery indicator
1 parent 9f1e54a commit e224aee

8 files changed

Lines changed: 543 additions & 17 deletions

File tree

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ These are fixed internal board connections; no external display wiring is requir
8888
| Touch reset | GP22 |
8989
| Battery ADC | GP29 |
9090

91+
## Ready-screen battery indicator
92+
93+
The top of the Ready screen contains a standard horizontal battery gauge. Its
94+
black fill is an estimated 0–100% state of charge derived from eight averaged
95+
GP29 readings and the board's 200k/100k `VSYS` divider. A white lightning bolt
96+
appears through the icon whenever the RP2040 USB controller detects external
97+
VBUS power. The 35×16-pixel graphic is centered at the top of the round display
98+
and ends 12 pixels before the `Ready` heading, so it does not obscure the title
99+
or settings summary.
100+
101+
The gauge is an approximate 3.7 V Li-ion voltage estimate; cell temperature,
102+
load, age, and chemistry affect accuracy. The board cannot measure isolated
103+
battery voltage while USB supplies `VSYS`. In that state the bolt is exact, but
104+
the fill retains the last battery-only estimate from the current boot. If the
105+
device starts on USB, a full powered-state fill is shown until a battery-only
106+
measurement becomes available. An unreadable ADC leaves an empty outline rather
107+
than interrupting timer startup.
108+
91109
## Installation
92110

93111
The application runs on MicroPython. BOOT mode is used only to flash the MicroPython UF2; application files are transferred afterward through the MicroPython serial connection.
@@ -115,7 +133,7 @@ The second command should identify an RP2040 MicroPython board.
115133
Run these commands from the repository root. Supporting files and font assets are copied first; `main.py` is installed last as the automatic entry point.
116134

117135
```sh
118-
mpremote connect auto fs cp configuration.py font_data.py font_renderer.py hardware.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py ready_screen.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 :
136+
mpremote connect auto fs cp battery.py configuration.py font_data.py font_renderer.py hardware.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.py ready_screen.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 :
119137
mpremote connect auto fs cp main.py :
120138
mpremote connect auto reset
121139
```

User Guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer.
55

66
* Upon start up a boot splash will be shown for 2 seconds.
7-
* After which the ``Primary Screen`` will show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``.
7+
* After which the ``Primary Screen`` will show ``Ready`` together with the saved track duration, rest duration, and effective Launch Mode state. A battery icon above `Ready` fills from left to right with estimated remaining charge. A lightning bolt through the battery means USB/external power is present. When the timer starts while connected to USB, the initial full fill represents powered status because this board cannot read the isolated battery cell until it runs from battery. ``Launch unavailable`` means the saved non-zero sensitivity could not be used because the IMU is unavailable; normal swipe-down timing still works. To start the ``Track Session`` or race, ``Swipe Down``.
88
* After swiping down, ``Go`` will display briefly. If ``Launch Mode`` has been activated, ``Lights`` will be displayed while the timer measures a stationary baseline and waits for sufficient acceleration.
99
* While waiting in ``Launch Mode``, double-tap to cancel and return to the ``Primary Screen``. The wait also cancels automatically after 30 seconds.
1010
* Upon starting, the ``Track Session`` timer count down will be displayed, and immediately commence.

battery.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Battery voltage estimation and external USB-power detection."""
2+
3+
BATTERY_ADC_PIN = 29
4+
ADC_MAX_VALUE = 65535
5+
ADC_REFERENCE_VOLTS = 3.3
6+
VOLTAGE_DIVIDER_RATIO = 3.0
7+
8+
# RP2040 USBCTRL_REGS.SIE_STATUS.VBUS_DETECTED. The Waveshare schematic
9+
# connects USB VBUS to the RP2040 USB PHY, while BAT_ADC measures divided VSYS.
10+
USB_SIE_STATUS_ADDRESS = 0x50110050
11+
USB_VBUS_DETECTED_MASK = 0x00000001
12+
13+
DEFAULT_SAMPLE_COUNT = 8
14+
ADC_SETTLE_READS = 1
15+
EXTERNAL_POWER_FALLBACK_PERCENT = 100
16+
17+
# Approximate unloaded 3.7 V Li-ion discharge curve. Voltage under load varies
18+
# with the cell and temperature, so this is intentionally presented as an
19+
# estimate rather than laboratory state-of-charge measurement.
20+
LI_ION_PERCENTAGE_CURVE = (
21+
(3.20, 0),
22+
(3.50, 10),
23+
(3.65, 20),
24+
(3.72, 30),
25+
(3.77, 40),
26+
(3.82, 50),
27+
(3.87, 60),
28+
(3.92, 70),
29+
(3.98, 80),
30+
(4.08, 90),
31+
(4.20, 100),
32+
)
33+
34+
35+
class BatteryStatus:
36+
"""One display-ready power reading."""
37+
38+
def __init__(self, percentage, external_power, voltage=None, estimated=True):
39+
self.percentage = percentage
40+
self.external_power = bool(external_power)
41+
self.voltage = voltage
42+
self.estimated = bool(estimated)
43+
44+
45+
def raw_adc_to_voltage(raw_value):
46+
"""Convert a 16-bit GP29 reading through the board's 200k/100k divider."""
47+
raw_value = max(0, min(ADC_MAX_VALUE, int(raw_value)))
48+
return (
49+
raw_value
50+
* ADC_REFERENCE_VOLTS
51+
* VOLTAGE_DIVIDER_RATIO
52+
/ ADC_MAX_VALUE
53+
)
54+
55+
56+
def voltage_to_percentage(voltage):
57+
"""Map battery voltage to a bounded percentage using linear interpolation."""
58+
voltage = float(voltage)
59+
if voltage <= LI_ION_PERCENTAGE_CURVE[0][0]:
60+
return 0
61+
if voltage >= LI_ION_PERCENTAGE_CURVE[-1][0]:
62+
return 100
63+
64+
for index in range(1, len(LI_ION_PERCENTAGE_CURVE)):
65+
upper_voltage, upper_percentage = LI_ION_PERCENTAGE_CURVE[index]
66+
if voltage <= upper_voltage:
67+
lower_voltage, lower_percentage = LI_ION_PERCENTAGE_CURVE[index - 1]
68+
position = (voltage - lower_voltage) / (
69+
upper_voltage - lower_voltage
70+
)
71+
percentage = lower_percentage + position * (
72+
upper_percentage - lower_percentage
73+
)
74+
return int(round(percentage))
75+
76+
return 100
77+
78+
79+
class BatteryMonitor:
80+
"""Read a stable Ready-screen battery status without making boot fragile."""
81+
82+
def __init__(
83+
self,
84+
adc=None,
85+
register_reader=None,
86+
sample_count=DEFAULT_SAMPLE_COUNT,
87+
external_fallback=EXTERNAL_POWER_FALLBACK_PERCENT,
88+
):
89+
if int(sample_count) <= 0:
90+
raise ValueError("sample_count must be positive")
91+
92+
self.sample_count = int(sample_count)
93+
self.external_fallback = max(0, min(100, int(external_fallback)))
94+
self.last_battery_percentage = None
95+
96+
if adc is None:
97+
try:
98+
from machine import ADC, Pin
99+
100+
adc = ADC(Pin(BATTERY_ADC_PIN))
101+
except Exception:
102+
adc = None
103+
self.adc = adc
104+
105+
if register_reader is None:
106+
try:
107+
from machine import mem32
108+
109+
register_reader = lambda address: mem32[address]
110+
except Exception:
111+
register_reader = None
112+
self.register_reader = register_reader
113+
114+
def _external_power(self):
115+
if self.register_reader is None:
116+
return None
117+
try:
118+
status = self.register_reader(USB_SIE_STATUS_ADDRESS)
119+
return bool(status & USB_VBUS_DETECTED_MASK)
120+
except Exception:
121+
return None
122+
123+
def _battery_voltage(self):
124+
if self.adc is None:
125+
return None
126+
try:
127+
# The RP2040 ADC mux can return a stale first conversion after the
128+
# channel is opened. Discard it before averaging the visible value.
129+
for _ in range(ADC_SETTLE_READS):
130+
self.adc.read_u16()
131+
total = 0
132+
for _ in range(self.sample_count):
133+
total += self.adc.read_u16()
134+
return raw_adc_to_voltage(total / self.sample_count)
135+
except Exception:
136+
return None
137+
138+
def read_status(self):
139+
"""Return the best honest status available for the current power path."""
140+
external_power = self._external_power()
141+
142+
if external_power is True:
143+
percentage = self.last_battery_percentage
144+
if percentage is None:
145+
percentage = self.external_fallback
146+
return BatteryStatus(
147+
percentage,
148+
external_power=True,
149+
voltage=None,
150+
estimated=True,
151+
)
152+
153+
voltage = self._battery_voltage()
154+
if voltage is None:
155+
return BatteryStatus(
156+
self.last_battery_percentage,
157+
external_power=False,
158+
voltage=None,
159+
estimated=True,
160+
)
161+
162+
percentage = voltage_to_percentage(voltage)
163+
if external_power is False:
164+
self.last_battery_percentage = percentage
165+
166+
return BatteryStatus(
167+
percentage,
168+
external_power=False,
169+
voltage=voltage,
170+
estimated=True,
171+
)

main.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import time
66

7+
from battery import BatteryMonitor
78
from configuration import set_sensitivity, set_session
89
from hardware import (
910
PeripheralError,
@@ -20,7 +21,7 @@
2021
track_live_frame,
2122
)
2223
from qmi8658 import QMI8658
23-
from ready_screen import ready_screen_lines
24+
from ready_screen import draw_ready_screen
2425
from settings import load_configuration, persist_setting
2526
from timing import SessionTracker
2627
from touch_drive import Touch_CST816T
@@ -71,6 +72,7 @@ def main():
7172
# Display and touchscreen
7273
lcd = LCD_1inch28()
7374
lcd.set_bl_pwm(65535)
75+
battery_monitor = BatteryMonitor()
7476
try:
7577
touch = initialize_with_retry(
7678
lambda: Touch_CST816T(mode=1, LCD=lcd),
@@ -99,15 +101,14 @@ def main():
99101
rest_session = SessionTracker(duration_mins=rest_length, stype="rest", debug=True)
100102

101103
while not launch:
102-
touch.ControlScreen(
103-
lcd,
104-
text_array=ready_screen_lines(
105-
track_minutes=track_session.duration_mins,
106-
rest_minutes=rest_session.duration_mins,
107-
sensitivity=configured_sensitivity,
108-
imu_available=qmi8658 is not None,
109-
),
110-
back_colour="green",
104+
draw_ready_screen(
105+
touch=touch,
106+
lcd=lcd,
107+
track_minutes=track_session.duration_mins,
108+
rest_minutes=rest_session.duration_mins,
109+
sensitivity=configured_sensitivity,
110+
imu_available=qmi8658 is not None,
111+
battery_status=battery_monitor.read_status(),
111112
)
112113
gesture = touch.GetGesture(lcd)
113114

ready_screen.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
"""Ready-screen settings summary independent of display hardware."""
22

33

4+
DISPLAY_SIZE = 240
5+
BATTERY_BODY_WIDTH = 31
6+
BATTERY_BODY_HEIGHT = 16
7+
BATTERY_TERMINAL_WIDTH = 4
8+
BATTERY_TERMINAL_HEIGHT = 6
9+
BATTERY_ICON_WIDTH = BATTERY_BODY_WIDTH + BATTERY_TERMINAL_WIDTH
10+
BATTERY_ICON_X = (DISPLAY_SIZE - BATTERY_ICON_WIDTH) // 2
11+
BATTERY_ICON_Y = 10
12+
BATTERY_INNER_WIDTH = BATTERY_BODY_WIDTH - 4
13+
READY_TITLE_Y = 38
14+
15+
416
def _format_sensitivity(sensitivity):
517
numeric = float(sensitivity)
618
if numeric == int(numeric):
@@ -25,9 +37,94 @@ def ready_screen_lines(
2537
):
2638
"""Build the complete Ready screen text layout."""
2739
return [
28-
["Ready", None, 38, 4, "white"],
40+
["Ready", None, READY_TITLE_Y, 4, "white"],
2941
["Track {}m".format(track_minutes), None, 100, 2, "black"],
3042
["Rest {}m".format(rest_minutes), None, 130, 2, "black"],
3143
[launch_status(sensitivity, imu_available), None, 160, 2, "black"],
3244
["Swipe DOWN to start", None, 205, 1, "black"],
3345
]
46+
47+
48+
def battery_icon_bounds():
49+
"""Return the complete icon bounds, including the positive terminal."""
50+
return (
51+
BATTERY_ICON_X,
52+
BATTERY_ICON_Y,
53+
BATTERY_ICON_WIDTH,
54+
BATTERY_BODY_HEIGHT,
55+
)
56+
57+
58+
def _bounded_percentage(value):
59+
if value is None:
60+
return None
61+
return max(0, min(100, int(value)))
62+
63+
64+
def _draw_lightning_bolt(lcd, x, y, color):
65+
"""Draw a compact, high-contrast lightning bolt through the battery."""
66+
points = (
67+
(x + 19, y + 2, x + 14, y + 7),
68+
(x + 14, y + 7, x + 18, y + 7),
69+
(x + 18, y + 7, x + 14, y + 14),
70+
)
71+
for x1, y1, x2, y2 in points:
72+
lcd.line(x1, y1, x2, y2, color)
73+
lcd.line(x1 + 1, y1, x2 + 1, y2, color)
74+
75+
76+
def draw_battery_icon(lcd, status):
77+
"""Draw a standard battery gauge without refreshing the framebuffer."""
78+
x = BATTERY_ICON_X
79+
y = BATTERY_ICON_Y
80+
percentage = _bounded_percentage(getattr(status, "percentage", None))
81+
external_power = bool(getattr(status, "external_power", False))
82+
83+
lcd.rect(x, y, BATTERY_BODY_WIDTH, BATTERY_BODY_HEIGHT, lcd.black)
84+
terminal_y = y + ((BATTERY_BODY_HEIGHT - BATTERY_TERMINAL_HEIGHT) // 2)
85+
lcd.fill_rect(
86+
x + BATTERY_BODY_WIDTH,
87+
terminal_y,
88+
BATTERY_TERMINAL_WIDTH,
89+
BATTERY_TERMINAL_HEIGHT,
90+
lcd.black,
91+
)
92+
93+
if percentage is not None:
94+
fill_width = int(round(BATTERY_INNER_WIDTH * percentage / 100))
95+
if fill_width > 0:
96+
lcd.fill_rect(
97+
x + 2,
98+
y + 2,
99+
fill_width,
100+
BATTERY_BODY_HEIGHT - 4,
101+
lcd.black,
102+
)
103+
104+
if external_power:
105+
_draw_lightning_bolt(lcd, x, y, lcd.white)
106+
107+
108+
def draw_ready_screen(
109+
touch,
110+
lcd,
111+
track_minutes,
112+
rest_minutes,
113+
sensitivity,
114+
imu_available,
115+
battery_status,
116+
):
117+
"""Render the Ready text and battery graphic in one framebuffer update."""
118+
touch.ControlScreen(
119+
lcd,
120+
text_array=ready_screen_lines(
121+
track_minutes,
122+
rest_minutes,
123+
sensitivity,
124+
imu_available,
125+
),
126+
back_colour="green",
127+
refresh=False,
128+
)
129+
draw_battery_icon(lcd, battery_status)
130+
lcd.show()

0 commit comments

Comments
 (0)