diff --git a/README.md b/README.md index 7649ee2..db311e9 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,12 @@ python tools/generate_font.py /path/to/Montserrat-SemiBold.otf font_data.py The generated font data is distributed under the SIL Open Font License 1.1 in `FONT_LICENSE.txt`. +## Live display refresh + +Track and rest sessions poll stop gestures every 50 ms while comparing the complete visible frame (remaining time, elapsed time, font size, background, and text colour) with the previous frame. The 115,200-byte framebuffer is transferred only when a displayed second or warning state changes, normally reducing continuous redraws to one per second. Touch-controller mode changes are also cached, so an unchanged gesture mode does not generate repeated I2C writes. + +On the supported Waveshare board running MicroPython 1.21.0, five full live-screen redraws measured 56.2–65.4 ms. Input is therefore checked within 50 ms between redraws and within approximately 115 ms in the worst case when a gesture arrives immediately before a redraw. Five consecutive frames produced only the two register writes needed for the initial gesture-mode configuration and no rewrites on later frames. + ## Startup splash At startup, the timer displays the supplied Caterham artwork on a black background sized for the 240x240 round display. The image is stored as a native `startup_splash.rgb565` framebuffer and loaded directly into the LCD's existing buffer, avoiding a second full-screen allocation on the RP2040. If the asset is absent or has the wrong size, the original text splash is shown instead. @@ -107,7 +113,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 launch.py lcd_1inch28.py params.json qmi8658.py settings.py splash.py timing.py touch_drive.py font_data*.bin startup_splash.rgb565 : +mpremote connect auto fs cp configuration.py font_data.py font_renderer.py launch.py lcd_1inch28.py live_display.py params.json qmi8658.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/live_display.py b/live_display.py new file mode 100644 index 0000000..e25ef9a --- /dev/null +++ b/live_display.py @@ -0,0 +1,94 @@ +"""Throttled live-session display updates with responsive input polling.""" + +import time + +from timing import secs_to_mins_secs + + +LIVE_LOOP_DELAY_SEC = 0.05 + + +def _visible_times(session, now): + elapsed_seconds = max(0, int(now - session.start_time)) + remaining_seconds = max(0, int(session.duration_secs) - elapsed_seconds) + return ( + secs_to_mins_secs(remaining_seconds), + secs_to_mins_secs(elapsed_seconds), + ) + + +def track_live_frame(session, now, lcd): + """Return all values visible on the track-session screen.""" + remaining, elapsed = _visible_times(session, now) + if now >= session.end_time: + return ("00:00", elapsed, 6, lcd.red, lcd.black) + + if now < session.last_15: + return (remaining, elapsed, 6, None, None) + if now < session.last_5: + return (remaining, elapsed, 6, lcd.salmon, lcd.black) + return (remaining, elapsed, 6, lcd.lilac, None) + + +def rest_live_frame(session, now, lcd): + """Return visible rest-session values, or ``None`` when it is complete.""" + if now >= session.end_time: + return None + remaining, elapsed = _visible_times(session, now) + return ( + remaining, + elapsed, + 6, + lcd.blue, + None, + ) + + +def draw_live_frame(touch, lcd, frame): + """Render one frame tuple produced by a live-frame builder.""" + remaining, elapsed, text_size, background, text_colour = frame + touch.LiveScreen( + lcd, + textsize_rem=text_size, + backColour=background, + textColour=text_colour, + elapsed=elapsed, + remaining=remaining, + ) + + +def run_live_display( + session, + frame_builder, + draw_frame, + stop_check, + clock=time, + loop_delay_sec=LIVE_LOOP_DELAY_SEC, +): + """Poll input at a bounded rate and redraw only when visible state changes.""" + if loop_delay_sec <= 0: + raise ValueError("loop_delay_sec must be positive") + + previous_frame = None + first_frame = True + redraw_count = 0 + + while session.live is True: + if stop_check(): + session.live = False + break + + frame = frame_builder(clock.time()) + if frame is None: + session.live = False + break + + if first_frame or frame != previous_frame: + draw_frame(frame) + previous_frame = frame + first_frame = False + redraw_count += 1 + + clock.sleep(loop_delay_sec) + + return redraw_count diff --git a/main.py b/main.py index 2815d6a..835c8da 100644 --- a/main.py +++ b/main.py @@ -7,9 +7,15 @@ from configuration import set_sensitivity, set_session from launch import accel_launch from lcd_1inch28 import LCD_1inch28 +from live_display import ( + draw_live_frame, + rest_live_frame, + run_live_display, + track_live_frame, +) from qmi8658 import QMI8658 from settings import load_configuration, persist_setting -from timing import SessionTracker, secs_to_mins_secs +from timing import SessionTracker from touch_drive import Touch_CST816T @@ -117,35 +123,12 @@ def main(): track_session.start_session() - while track_session.live is True: - now = time.time() - remaining = secs_to_mins_secs(track_session.end_time - now) - elapsed = secs_to_mins_secs(now - track_session.start_time) - - if touch.StopGesture(lcd): - track_session.live = False - - if now < track_session.end_time: - if now < track_session.last_15: - touch.LiveScreen( - lcd, textsize_rem=6, backColour=None, textColour=None, - elapsed=elapsed, remaining=remaining, - ) - elif now < track_session.last_5: - touch.LiveScreen( - lcd, textsize_rem=6, backColour=lcd.salmon, textColour=lcd.black, - elapsed=elapsed, remaining=remaining, - ) - else: - touch.LiveScreen( - lcd, textsize_rem=6, backColour=lcd.lilac, textColour=None, - elapsed=elapsed, remaining=remaining, - ) - else: - touch.LiveScreen( - lcd, textsize_rem=6, backColour=lcd.red, textColour=lcd.black, - elapsed=elapsed, remaining="00:00", - ) + run_live_display( + track_session, + frame_builder=lambda now: track_live_frame(track_session, now, lcd), + draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), + stop_check=lambda: touch.StopGesture(lcd), + ) touch.ControlScreen( lcd, @@ -155,20 +138,12 @@ def main(): time.sleep(display_delay_rest) rest_session.start_session(debug=True) - while rest_session.live is True: - now = time.time() - remaining = secs_to_mins_secs(rest_session.end_time - now) - elapsed = secs_to_mins_secs(now - rest_session.start_time) - - if touch.ClearGesture(lcd): - rest_session.live = False - if now < rest_session.end_time: - touch.LiveScreen( - lcd, textsize_rem=6, backColour=lcd.blue, textColour=None, - elapsed=elapsed, remaining=remaining, - ) - else: - rest_session.live = False + run_live_display( + rest_session, + frame_builder=lambda now: rest_live_frame(rest_session, now, lcd), + draw_frame=lambda frame: draw_live_frame(touch, lcd, frame), + stop_check=lambda: touch.ClearGesture(lcd), + ) if __name__ == "__main__": diff --git a/tests/test_live_display.py b/tests/test_live_display.py new file mode 100644 index 0000000..7c1342f --- /dev/null +++ b/tests/test_live_display.py @@ -0,0 +1,133 @@ +import unittest + +from live_display import rest_live_frame, run_live_display, track_live_frame +from timing import SessionTracker + + +class FakeClock: + def __init__(self, now=0): + self.now = now + self.sleep_calls = [] + + def time(self): + return self.now + + def sleep(self, delay): + self.sleep_calls.append(delay) + self.now += delay + + +class FakeLCD: + red = 1 + black = 2 + salmon = 3 + lilac = 4 + blue = 5 + + +class LiveDisplayTests(unittest.TestCase): + def test_simulated_session_redraws_at_most_once_per_visible_second(self): + clock = FakeClock() + lcd = FakeLCD() + session = SessionTracker(duration_mins=0.1, clock=clock.time) + session.start_session() + frames = [] + input_checks = {"count": 0} + + def stop_check(): + input_checks["count"] += 1 + return False + + redraws = run_live_display( + session, + frame_builder=lambda now: rest_live_frame(session, now, lcd), + draw_frame=frames.append, + stop_check=stop_check, + clock=clock, + ) + + self.assertEqual(int(session.duration_secs), redraws) + self.assertEqual(redraws, len(frames)) + self.assertGreaterEqual(input_checks["count"], 100) + self.assertTrue(all(delay == 0.05 for delay in clock.sleep_calls)) + self.assertFalse(session.live) + + def test_input_is_checked_between_unchanged_frames(self): + clock = FakeClock() + session = SessionTracker(duration_mins=1, clock=clock.time) + session.start_session() + frames = [] + input_checks = {"count": 0} + + def stop_check(): + input_checks["count"] += 1 + return input_checks["count"] == 4 + + redraws = run_live_display( + session, + frame_builder=lambda now: ("same", "same", 1, 1, 1), + draw_frame=frames.append, + stop_check=stop_check, + clock=clock, + ) + + self.assertEqual(1, redraws) + self.assertEqual(4, input_checks["count"]) + self.assertAlmostEqual(0.15, clock.now) + self.assertFalse(session.live) + + def test_warning_state_change_redraws_even_when_text_is_unchanged(self): + clock = FakeClock() + session = SessionTracker(duration_mins=1, clock=clock.time) + session.start_session() + frames = [] + + def frame_builder(now): + colour = "green" if now < 0.1 else "warning" + return ("00:59", "00:00", 6, colour, "white") + + def stop_check(): + return clock.now >= 0.2 + + redraws = run_live_display( + session, + frame_builder=frame_builder, + draw_frame=frames.append, + stop_check=stop_check, + clock=clock, + ) + + self.assertEqual(2, redraws) + self.assertEqual("green", frames[0][3]) + self.assertEqual("warning", frames[1][3]) + + def test_track_frames_include_warning_and_overrun_state(self): + lcd = FakeLCD() + session = SessionTracker(duration_mins=10, clock=lambda: 100) + session.start_session() + + running = track_live_frame(session, 609, lcd) + last_15 = track_live_frame(session, 610, lcd) + last_5 = track_live_frame(session, 670, lcd) + overrun = track_live_frame(session, 700, lcd) + + self.assertEqual((None, None), running[3:]) + self.assertEqual((lcd.salmon, lcd.black), last_15[3:]) + self.assertEqual((lcd.lilac, None), last_5[3:]) + self.assertEqual("00:00", overrun[0]) + self.assertEqual((lcd.red, lcd.black), overrun[3:]) + + def test_loop_delay_must_be_bounded(self): + session = SessionTracker(duration_mins=1, live=True) + with self.assertRaises(ValueError): + run_live_display( + session, + frame_builder=lambda now: None, + draw_frame=lambda frame: None, + stop_check=lambda: False, + loop_delay_sec=0, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_touch_mode.py b/tests/test_touch_mode.py new file mode 100644 index 0000000..a22e014 --- /dev/null +++ b/tests/test_touch_mode.py @@ -0,0 +1,66 @@ +import importlib +import sys +import types +import unittest + + +def import_touch_driver(): + machine = types.ModuleType("machine") + for name in ("Pin", "I2C", "SPI", "PWM", "Timer", "ADC"): + setattr(machine, name, object) + framebuf = types.ModuleType("framebuf") + + original_machine = sys.modules.get("machine") + original_framebuf = sys.modules.get("framebuf") + sys.modules["machine"] = machine + sys.modules["framebuf"] = framebuf + try: + return importlib.import_module("touch_drive") + finally: + if original_machine is None: + del sys.modules["machine"] + else: + sys.modules["machine"] = original_machine + if original_framebuf is None: + del sys.modules["framebuf"] + else: + sys.modules["framebuf"] = original_framebuf + + +touch_drive = import_touch_driver() + + +class TouchModeTests(unittest.TestCase): + def make_touch(self): + touch = touch_drive.Touch_CST816T.__new__(touch_drive.Touch_CST816T) + touch.Mode = 1 + touch._configured_mode = None + touch.writes = [] + touch._write_byte = lambda command, value: touch.writes.append( + (command, value) + ) + return touch + + def test_unchanged_gesture_mode_is_not_rewritten(self): + touch = self.make_touch() + + self.assertTrue(touch.Set_Mode(0)) + self.assertFalse(touch.Set_Mode(0)) + + self.assertEqual(0, touch.Mode) + self.assertEqual([(0xFA, 0x11), (0xEC, 0x01)], touch.writes) + + def test_changed_mode_is_written_once(self): + touch = self.make_touch() + touch.Set_Mode(0) + + self.assertTrue(touch.Set_Mode(1)) + self.assertFalse(touch.Set_Mode(1)) + + self.assertEqual(1, touch.Mode) + self.assertEqual((0xFA, 0x41), touch.writes[-1]) + self.assertEqual(3, len(touch.writes)) + + +if __name__ == "__main__": + unittest.main() diff --git a/touch_drive.py b/touch_drive.py index 0ce419b..4083ea3 100644 --- a/touch_drive.py +++ b/touch_drive.py @@ -22,6 +22,7 @@ def __init__(self,address=0x15,mode=0,i2c_num=1,i2c_sda=6,i2c_scl=7,int_pin=21,r self.int=Pin(int_pin,Pin.IN, Pin.PULL_UP) self.tim = Timer() self.rst=Pin(rst_pin,Pin.OUT) + self._configured_mode = None self.Reset() bRet=self.WhoAmI() if bRet : @@ -67,12 +68,17 @@ def Reset(self): time.sleep_ms(1) self.rst(1) time.sleep_ms(50) + self._configured_mode = None #Set mode def Set_Mode(self,mode,callback_time=10,rest_time=5): # mode = 0 gestures mode # mode = 1 point mode # mode = 2 mixed mode + self.Mode = mode + if self._configured_mode == mode: + return False + if (mode == 1): self._write_byte(0xFA,0X41) @@ -82,6 +88,8 @@ def Set_Mode(self,mode,callback_time=10,rest_time=5): else: self._write_byte(0xFA,0X11) self._write_byte(0xEC,0X01) + self._configured_mode = mode + return True #Get the coordinates of the touch def get_point(self): @@ -107,7 +115,6 @@ def Timer_callback(self,t): self.l = 50 def BootScreen(self, LCD, sleep=4, version_number="0.0"): - self.mode = 0 self.Set_Mode(self.Mode) splash_loaded = False @@ -221,8 +228,7 @@ def LiveScreen(self, LCD, textsize_rem=None, backColour=None, textColour=None, e backColour = LCD.green if textsize_rem is None: textsize_rem = 5 - self.mode = 0 - self.Set_Mode(self.Mode) + self.Set_Mode(0) LCD.fill(backColour) LCD.write_centered(remaining,82,textsize_rem,textColour) LCD.write_centered(elapsed,180,3,textColour) @@ -252,8 +258,7 @@ def StopGesture(self, LCD): #Gesture def GetGesture(self, LCD, debounce_time=0.2): return_type = None - self.Mode = 0 - self.Set_Mode(self.Mode) + self.Set_Mode(0) if self.Gestures == G_UP: return_type = "up"