Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -88,6 +88,24 @@ These are fixed internal board connections; no external display wiring is requir
| Touch reset | GP22 |
| Battery ADC | GP29 |

## Ready-screen battery indicator

The top of the Ready screen contains a standard horizontal battery gauge. Its
black fill is an estimated 0–100% state of charge derived from eight averaged
GP29 readings and the board's 200k/100k `VSYS` divider. A white lightning bolt
appears through the icon whenever the RP2040 USB controller detects external
VBUS power. The 35×16-pixel graphic is centered at the top of the round display
and ends 12 pixels before the `Ready` heading, so it does not obscure the title
or settings summary.

The gauge is an approximate 3.7 V Li-ion voltage estimate; cell temperature,
load, age, and chemistry affect accuracy. The board cannot measure isolated
battery voltage while USB supplies `VSYS`. In that state the bolt is exact, but
the fill retains the last battery-only estimate from the current boot. If the
device starts on USB, a full powered-state fill is shown until a battery-only
measurement becomes available. An unreadable ADC leaves an empty outline rather
than interrupting timer startup.

## Installation

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.
Expand DownExpand Up@@ -115,7 +133,7 @@ The second command should identify an RP2040 MicroPython board.
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.

```sh
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 :
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 :
mpremote connect auto fs cp main.py :
mpremote connect auto reset
```
Expand Down
2 changes: 1 addition & 1 deletion User Guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@
The following describes general operation of both the ``Track Session`` and ``Rest in Pits Session`` timer.

* Upon start up a boot splash will be shown for 2 seconds.
* 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``.
* 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``.
* 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.
* While waiting in ``Launch Mode``, double-tap to cancel and return to the ``Primary Screen``. The wait also cancels automatically after 30 seconds.
* Upon starting, the ``Track Session`` timer count down will be displayed, and immediately commence.
Expand Down
171 changes: 171 additions & 0 deletions battery.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
"""Battery voltage estimation and external USB-power detection."""

BATTERY_ADC_PIN = 29
ADC_MAX_VALUE = 65535
ADC_REFERENCE_VOLTS = 3.3
VOLTAGE_DIVIDER_RATIO = 3.0

# RP2040 USBCTRL_REGS.SIE_STATUS.VBUS_DETECTED. The Waveshare schematic
# connects USB VBUS to the RP2040 USB PHY, while BAT_ADC measures divided VSYS.
USB_SIE_STATUS_ADDRESS = 0x50110050
USB_VBUS_DETECTED_MASK = 0x00000001

DEFAULT_SAMPLE_COUNT = 8
ADC_SETTLE_READS = 1
EXTERNAL_POWER_FALLBACK_PERCENT = 100

# Approximate unloaded 3.7 V Li-ion discharge curve. Voltage under load varies
# with the cell and temperature, so this is intentionally presented as an
# estimate rather than laboratory state-of-charge measurement.
LI_ION_PERCENTAGE_CURVE = (
(3.20, 0),
(3.50, 10),
(3.65, 20),
(3.72, 30),
(3.77, 40),
(3.82, 50),
(3.87, 60),
(3.92, 70),
(3.98, 80),
(4.08, 90),
(4.20, 100),
)


class BatteryStatus:
"""One display-ready power reading."""

def __init__(self, percentage, external_power, voltage=None, estimated=True):
self.percentage = percentage
self.external_power = bool(external_power)
self.voltage = voltage
self.estimated = bool(estimated)


def raw_adc_to_voltage(raw_value):
"""Convert a 16-bit GP29 reading through the board's 200k/100k divider."""
raw_value = max(0, min(ADC_MAX_VALUE, int(raw_value)))
return (
raw_value
* ADC_REFERENCE_VOLTS
* VOLTAGE_DIVIDER_RATIO
/ ADC_MAX_VALUE
)


def voltage_to_percentage(voltage):
"""Map battery voltage to a bounded percentage using linear interpolation."""
voltage = float(voltage)
if voltage <= LI_ION_PERCENTAGE_CURVE[0][0]:
return 0
if voltage >= LI_ION_PERCENTAGE_CURVE[-1][0]:
return 100

for index in range(1, len(LI_ION_PERCENTAGE_CURVE)):
upper_voltage, upper_percentage = LI_ION_PERCENTAGE_CURVE[index]
if voltage <= upper_voltage:
lower_voltage, lower_percentage = LI_ION_PERCENTAGE_CURVE[index - 1]
position = (voltage - lower_voltage) / (
upper_voltage - lower_voltage
)
percentage = lower_percentage + position * (
upper_percentage - lower_percentage
)
return int(round(percentage))

return 100


class BatteryMonitor:
"""Read a stable Ready-screen battery status without making boot fragile."""

def __init__(
self,
adc=None,
register_reader=None,
sample_count=DEFAULT_SAMPLE_COUNT,
external_fallback=EXTERNAL_POWER_FALLBACK_PERCENT,
):
if int(sample_count) <= 0:
raise ValueError("sample_count must be positive")

self.sample_count = int(sample_count)
self.external_fallback = max(0, min(100, int(external_fallback)))
self.last_battery_percentage = None

if adc is None:
try:
from machine import ADC, Pin

adc = ADC(Pin(BATTERY_ADC_PIN))
except Exception:
adc = None
self.adc = adc

if register_reader is None:
try:
from machine import mem32

register_reader = lambda address: mem32[address]
except Exception:
register_reader = None
self.register_reader = register_reader

def _external_power(self):
if self.register_reader is None:
return None
try:
status = self.register_reader(USB_SIE_STATUS_ADDRESS)
return bool(status & USB_VBUS_DETECTED_MASK)
except Exception:
return None

def _battery_voltage(self):
if self.adc is None:
return None
try:
# The RP2040 ADC mux can return a stale first conversion after the
# channel is opened. Discard it before averaging the visible value.
for _ in range(ADC_SETTLE_READS):
self.adc.read_u16()
total = 0
for _ in range(self.sample_count):
total += self.adc.read_u16()
return raw_adc_to_voltage(total / self.sample_count)
except Exception:
return None

def read_status(self):
"""Return the best honest status available for the current power path."""
external_power = self._external_power()

if external_power is True:
percentage = self.last_battery_percentage
if percentage is None:
percentage = self.external_fallback
return BatteryStatus(
percentage,
external_power=True,
voltage=None,
estimated=True,
)

voltage = self._battery_voltage()
if voltage is None:
return BatteryStatus(
self.last_battery_percentage,
external_power=False,
voltage=None,
estimated=True,
)

percentage = voltage_to_percentage(voltage)
if external_power is False:
self.last_battery_percentage = percentage

return BatteryStatus(
percentage,
external_power=False,
voltage=voltage,
estimated=True,
)
21 changes: 11 additions & 10 deletions main.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,7 @@

import time

from battery import BatteryMonitor
from configuration import set_sensitivity, set_session
from hardware import (
PeripheralError,
Expand All@@ -20,7 +21,7 @@
track_live_frame,
)
from qmi8658 import QMI8658
from ready_screen import ready_screen_lines
from ready_screen import draw_ready_screen
from settings import load_configuration, persist_setting
from timing import SessionTracker
from touch_drive import Touch_CST816T
Expand DownExpand Up@@ -71,6 +72,7 @@ def main():
# Display and touchscreen
lcd = LCD_1inch28()
lcd.set_bl_pwm(65535)
battery_monitor = BatteryMonitor()
try:
touch = initialize_with_retry(
lambda: Touch_CST816T(mode=1, LCD=lcd),
Expand DownExpand Up@@ -99,15 +101,14 @@ def main():
rest_session = SessionTracker(duration_mins=rest_length, stype="rest", debug=True)

while not launch:
touch.ControlScreen(
lcd,
text_array=ready_screen_lines(
track_minutes=track_session.duration_mins,
rest_minutes=rest_session.duration_mins,
sensitivity=configured_sensitivity,
imu_available=qmi8658 is not None,
),
back_colour="green",
draw_ready_screen(
touch=touch,
lcd=lcd,
track_minutes=track_session.duration_mins,
rest_minutes=rest_session.duration_mins,
sensitivity=configured_sensitivity,
imu_available=qmi8658 is not None,
battery_status=battery_monitor.read_status(),
)
gesture = touch.GetGesture(lcd)

Expand Down
99 changes: 98 additions & 1 deletion ready_screen.py
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
"""Ready-screen settings summary independent of display hardware."""


DISPLAY_SIZE = 240
BATTERY_BODY_WIDTH = 31
BATTERY_BODY_HEIGHT = 16
BATTERY_TERMINAL_WIDTH = 4
BATTERY_TERMINAL_HEIGHT = 6
BATTERY_ICON_WIDTH = BATTERY_BODY_WIDTH + BATTERY_TERMINAL_WIDTH
BATTERY_ICON_X = (DISPLAY_SIZE - BATTERY_ICON_WIDTH) // 2
BATTERY_ICON_Y = 10
BATTERY_INNER_WIDTH = BATTERY_BODY_WIDTH - 4
READY_TITLE_Y = 38


def _format_sensitivity(sensitivity):
numeric = float(sensitivity)
if numeric == int(numeric):
Expand All@@ -25,9 +37,94 @@ def ready_screen_lines(
):
"""Build the complete Ready screen text layout."""
return [
["Ready", None, 38, 4, "white"],
["Ready", None, READY_TITLE_Y, 4, "white"],
["Track {}m".format(track_minutes), None, 100, 2, "black"],
["Rest {}m".format(rest_minutes), None, 130, 2, "black"],
[launch_status(sensitivity, imu_available), None, 160, 2, "black"],
["Swipe DOWN to start", None, 205, 1, "black"],
]


def battery_icon_bounds():
"""Return the complete icon bounds, including the positive terminal."""
return (
BATTERY_ICON_X,
BATTERY_ICON_Y,
BATTERY_ICON_WIDTH,
BATTERY_BODY_HEIGHT,
)


def _bounded_percentage(value):
if value is None:
return None
return max(0, min(100, int(value)))


def _draw_lightning_bolt(lcd, x, y, color):
"""Draw a compact, high-contrast lightning bolt through the battery."""
points = (
(x + 19, y + 2, x + 14, y + 7),
(x + 14, y + 7, x + 18, y + 7),
(x + 18, y + 7, x + 14, y + 14),
)
for x1, y1, x2, y2 in points:
lcd.line(x1, y1, x2, y2, color)
lcd.line(x1 + 1, y1, x2 + 1, y2, color)


def draw_battery_icon(lcd, status):
"""Draw a standard battery gauge without refreshing the framebuffer."""
x = BATTERY_ICON_X
y = BATTERY_ICON_Y
percentage = _bounded_percentage(getattr(status, "percentage", None))
external_power = bool(getattr(status, "external_power", False))

lcd.rect(x, y, BATTERY_BODY_WIDTH, BATTERY_BODY_HEIGHT, lcd.black)
terminal_y = y + ((BATTERY_BODY_HEIGHT - BATTERY_TERMINAL_HEIGHT) // 2)
lcd.fill_rect(
x + BATTERY_BODY_WIDTH,
terminal_y,
BATTERY_TERMINAL_WIDTH,
BATTERY_TERMINAL_HEIGHT,
lcd.black,
)

if percentage is not None:
fill_width = int(round(BATTERY_INNER_WIDTH * percentage / 100))
if fill_width > 0:
lcd.fill_rect(
x + 2,
y + 2,
fill_width,
BATTERY_BODY_HEIGHT - 4,
lcd.black,
)

if external_power:
_draw_lightning_bolt(lcd, x, y, lcd.white)


def draw_ready_screen(
touch,
lcd,
track_minutes,
rest_minutes,
sensitivity,
imu_available,
battery_status,
):
"""Render the Ready text and battery graphic in one framebuffer update."""
touch.ControlScreen(
lcd,
text_array=ready_screen_lines(
track_minutes,
rest_minutes,
sensitivity,
imu_available,
),
back_colour="green",
refresh=False,
)
draw_battery_icon(lcd, battery_status)
lcd.show()
Loading
Loading