diff --git a/README.md b/README.md index f99bfdf..9f52ffe 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 ``` diff --git a/User Guide.md b/User Guide.md index 443268a..43d2fda 100644 --- a/User Guide.md +++ b/User Guide.md @@ -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. diff --git a/battery.py b/battery.py new file mode 100644 index 0000000..759b053 --- /dev/null +++ b/battery.py @@ -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, + ) diff --git a/main.py b/main.py index 95893f6..ba12859 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import time +from battery import BatteryMonitor from configuration import set_sensitivity, set_session from hardware import ( PeripheralError, @@ -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 @@ -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), @@ -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) diff --git a/ready_screen.py b/ready_screen.py index 65fff23..dee3550 100644 --- a/ready_screen.py +++ b/ready_screen.py @@ -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): @@ -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() diff --git a/tests/test_battery.py b/tests/test_battery.py new file mode 100644 index 0000000..30ed2a0 --- /dev/null +++ b/tests/test_battery.py @@ -0,0 +1,133 @@ +import unittest + +from battery import ( + ADC_MAX_VALUE, + BatteryMonitor, + raw_adc_to_voltage, + voltage_to_percentage, +) + + +def raw_for_voltage(voltage): + return int(round(voltage * ADC_MAX_VALUE / (3.3 * 3.0))) + + +class FakeADC: + def __init__(self, values=None, error=None): + self.values = list(values or []) + self.error = error + self.index = 0 + self.read_count = 0 + + def read_u16(self): + self.read_count += 1 + if self.error is not None: + raise self.error + value = self.values[self.index % len(self.values)] + self.index += 1 + return value + + +class MutableRegister: + def __init__(self, value=0, error=None): + self.value = value + self.error = error + self.addresses = [] + + def __call__(self, address): + self.addresses.append(address) + if self.error is not None: + raise self.error + return self.value + + +class BatteryTests(unittest.TestCase): + def test_adc_conversion_uses_board_voltage_divider(self): + self.assertEqual(0, raw_adc_to_voltage(0)) + self.assertAlmostEqual(9.9, raw_adc_to_voltage(ADC_MAX_VALUE)) + self.assertAlmostEqual( + 3.8, + raw_adc_to_voltage(raw_for_voltage(3.8)), + places=3, + ) + + def test_voltage_curve_is_bounded_and_interpolated(self): + self.assertEqual(0, voltage_to_percentage(2.5)) + self.assertEqual(0, voltage_to_percentage(3.2)) + self.assertEqual(50, voltage_to_percentage(3.82)) + self.assertEqual(100, voltage_to_percentage(4.2)) + self.assertEqual(100, voltage_to_percentage(5.0)) + self.assertLess( + voltage_to_percentage(3.90), + voltage_to_percentage(4.00), + ) + + def test_battery_samples_are_averaged_before_estimating_percentage(self): + adc = FakeADC( + [raw_for_voltage(3.72), raw_for_voltage(3.92)] + ) + monitor = BatteryMonitor( + adc=adc, + register_reader=MutableRegister(0), + sample_count=2, + ) + + status = monitor.read_status() + + self.assertFalse(status.external_power) + self.assertAlmostEqual(3.82, status.voltage, places=2) + self.assertEqual(50, status.percentage) + self.assertEqual(3, adc.read_count) + + def test_external_power_uses_fallback_without_sampling_vsys(self): + adc = FakeADC(error=OSError("ADC should not be sampled")) + monitor = BatteryMonitor( + adc=adc, + register_reader=MutableRegister(1), + ) + + status = monitor.read_status() + + self.assertTrue(status.external_power) + self.assertEqual(100, status.percentage) + self.assertIsNone(status.voltage) + self.assertEqual(0, adc.read_count) + + def test_external_power_preserves_last_battery_only_estimate(self): + power_register = MutableRegister(0) + adc = FakeADC([raw_for_voltage(3.87)]) + monitor = BatteryMonitor( + adc=adc, + register_reader=power_register, + sample_count=1, + ) + + battery_status = monitor.read_status() + power_register.value = 1 + powered_status = monitor.read_status() + + self.assertEqual(60, battery_status.percentage) + self.assertEqual(60, powered_status.percentage) + self.assertTrue(powered_status.external_power) + self.assertEqual(2, adc.read_count) + + def test_read_failures_return_an_unknown_gauge_without_raising(self): + monitor = BatteryMonitor( + adc=FakeADC(error=OSError("unavailable")), + register_reader=MutableRegister(error=OSError("unavailable")), + sample_count=1, + ) + + status = monitor.read_status() + + self.assertIsNone(status.percentage) + self.assertFalse(status.external_power) + self.assertIsNone(status.voltage) + + def test_sample_count_must_be_positive(self): + with self.assertRaises(ValueError): + BatteryMonitor(adc=FakeADC([0]), register_reader=lambda _: 0, sample_count=0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ready_screen.py b/tests/test_ready_screen.py index e756b88..370d5c4 100644 --- a/tests/test_ready_screen.py +++ b/tests/test_ready_screen.py @@ -1,8 +1,54 @@ import math import unittest +from battery import BatteryStatus from font_renderer import measure_text, pixel_height -from ready_screen import launch_status, ready_screen_lines +from ready_screen import ( + BATTERY_BODY_HEIGHT, + BATTERY_INNER_WIDTH, + READY_TITLE_Y, + battery_icon_bounds, + draw_battery_icon, + draw_ready_screen, + launch_status, + ready_screen_lines, +) + + +class FakeLCD: + black = 1 + white = 2 + + def __init__(self): + self.calls = [] + + def rect(self, *args): + self.calls.append(("rect",) + args) + + def fill_rect(self, *args): + self.calls.append(("fill_rect",) + args) + + def line(self, *args): + self.calls.append(("line",) + args) + + def show(self): + self.calls.append(("show",)) + + +class FakeTouch: + def __init__(self): + self.calls = [] + + def ControlScreen( + self, + lcd, + text_array=None, + back_colour=None, + refresh=True, + ): + self.calls.append((text_array, back_colour, refresh)) + if refresh: + lcd.show() class ReadyScreenTests(unittest.TestCase): @@ -37,6 +83,64 @@ def test_every_line_fits_inside_the_round_display(self): ) self.assertLessEqual(measure_text(text, size), visible_width) + def test_battery_icon_fits_circle_and_clears_ready_heading(self): + display_radius = 120 + x, y, width, height = battery_icon_bounds() + + self.assertLess(y + height, READY_TITLE_Y) + for edge_y in (y, y + height - 1): + distance_from_center = edge_y - display_radius + visible_width = 2 * math.sqrt( + (display_radius ** 2) - (distance_from_center ** 2) + ) + self.assertLessEqual(width, visible_width) + self.assertEqual((240 - width) // 2, x) + + def test_battery_fill_tracks_percentage(self): + lcd = FakeLCD() + + draw_battery_icon(lcd, BatteryStatus(50, False)) + + fills = [call for call in lcd.calls if call[0] == "fill_rect"] + terminal, charge_fill = fills + self.assertEqual(4, terminal[3]) + self.assertEqual( + int(round(BATTERY_INNER_WIDTH * 0.5)), + charge_fill[3], + ) + self.assertEqual(BATTERY_BODY_HEIGHT - 4, charge_fill[4]) + self.assertFalse(any(call[0] == "line" for call in lcd.calls)) + + def test_external_power_draws_lightning_bolt(self): + lcd = FakeLCD() + + draw_battery_icon(lcd, BatteryStatus(100, True)) + + bolt_lines = [call for call in lcd.calls if call[0] == "line"] + self.assertEqual(6, len(bolt_lines)) + self.assertTrue(all(call[-1] == lcd.white for call in bolt_lines)) + + def test_ready_screen_adds_icon_before_single_refresh(self): + lcd = FakeLCD() + touch = FakeTouch() + + draw_ready_screen( + touch, + lcd, + track_minutes=20, + rest_minutes=15, + sensitivity=0.5, + imu_available=True, + battery_status=BatteryStatus(75, True), + ) + + lines, background, refresh = touch.calls[0] + self.assertEqual("Ready", lines[0][0]) + self.assertEqual("green", background) + self.assertFalse(refresh) + self.assertEqual(("show",), lcd.calls[-1]) + self.assertEqual(1, sum(call == ("show",) for call in lcd.calls)) + if __name__ == "__main__": unittest.main() diff --git a/touch_drive.py b/touch_drive.py index baa14ac..92c0bba 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -233,7 +233,7 @@ def SetTextColour(self, LCD, TextColour): return LCD.black - def ControlScreen(self, LCD, text_array=None, back_colour=None): + def ControlScreen(self, LCD, text_array=None, back_colour=None, refresh=True): """ Outputs text to screen using an array of arrays, where each inner array contains the following structure: [string_val, x, y, size, color]. @@ -242,6 +242,7 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None): - LCD: The LCD object responsible for displaying the text. - text_array: A list of lists, each containing [string_val, x, y, size, color]. - back_colour: Optional background color for the screen. + - refresh: Show immediately, or allow the caller to add graphics first. """ # Set the background color if provided if back_colour is not None: @@ -259,8 +260,9 @@ def ControlScreen(self, LCD, text_array=None, back_colour=None): LCD.write_centered(string_val, y, size, text_color) else: LCD.write_text(string_val, x, y, size, text_color) - # Refresh the LCD to display the changes - LCD.show() + # Refresh the LCD unless the caller still needs to add graphics. + if refresh: + LCD.show() def GoScreen(self, LCD, text='..GO!', subtitle=None):