From b75fecf5449a76cf611aba1deca21b7c49826c0c Mon Sep 17 00:00:00 2001 From: Bitcoin3us <115934595+bitcoin3us@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:49:30 +0100 Subject: [PATCH 1/2] task_handler: advance LVGL time from a single elapsed-based source LVGL time ran ~1.8x faster than wall clock on ESP32 builds: _timer_cb added the nominal period on every machine.Timer tick while _task_handler also added the elapsed time on every scheduled pass (plus a third increment covering the FINISHED callbacks). Every lv timer, animation, scroll throw and long-press threshold therefore fired early; a 12 fps lv.timer produced 14 frames per second. Measured on a Waveshare ESP32-S3-Touch-LCD-3.5 (MicroPythonOS 0.18): lv.tick_get() advanced 1.834 ms per wall-clock ms when idle. With TaskHandler.disable() (timer path only) the ratio was exactly 1.000, and under load the timer path alone lost ~20% of ticks because scheduled callbacks coalesce. Make _timer_cb the only place ticks are added, and have it add the real elapsed milliseconds since its previous run instead of the nominal period, so LVGL time equals wall time regardless of load. Verified 1.000 idle and under flash-read load; a stalled scheduler now catches up instead of losing time. Co-Authored-By: Claude Fable 5.1 --- .../frozen/other/task_handler.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/api_drivers/common_api_drivers/frozen/other/task_handler.py b/api_drivers/common_api_drivers/frozen/other/task_handler.py index 8a19877e..7a6966b6 100644 --- a/api_drivers/common_api_drivers/frozen/other/task_handler.py +++ b/api_drivers/common_api_drivers/frozen/other/task_handler.py @@ -57,7 +57,7 @@ def __init__( self._task_handler_ref = self._task_handler self.max_scheduled = max_scheduled - self._start_time = time.ticks_ms() # NOQA + self._last_tick = time.ticks_ms() # NOQA self._timer.init( mode=Timer.PERIODIC, period=self.duration, @@ -128,12 +128,6 @@ def _task_handler(self, _): print(f"TaskHandler callback {cb} threw exception, disabling it") self.remove_event_cb(cb) - stop_time = time.ticks_ms() # NOQA - - ticks_diff = time.ticks_diff(stop_time, self._start_time) # NOQA - self._start_time = stop_time - lv.tick_inc(ticks_diff) - if run_update: try: lv.task_handler() @@ -142,8 +136,6 @@ def _task_handler(self, _): sys.print_exception(e) # LVGL UI still hangs - start_time = time.ticks_ms() # NOQA - for cb, evt, data in self._callbacks: if not evt & TASK_HANDLER_FINISHED: continue @@ -159,10 +151,6 @@ def _task_handler(self, _): else: sys.print_exception(err) # NOQA - stop_time = time.ticks_ms() # NOQA - ticks_diff = time.ticks_diff(stop_time, start_time) # NOQA - lv.tick_inc(ticks_diff) - self._running = False except Exception as e: @@ -172,7 +160,13 @@ def _task_handler(self, _): self.exception_hook(e) def _timer_cb(self, _): - lv.tick_inc(self.duration) + # The only place LVGL time advances. Feed it the real elapsed time + # rather than the nominal period: timer callbacks are delivered via the + # scheduler and coalesce or drop under load, and _task_handler must not + # add ticks of its own or LVGL time runs ~2x faster than wall clock. + now = time.ticks_ms() # NOQA + lv.tick_inc(time.ticks_diff(now, self._last_tick)) # NOQA + self._last_tick = now if self._running: return From 6f5cff6dfbaf68a176a4b0c2d01b4863f8b08b45 Mon Sep 17 00:00:00 2001 From: Bitcoin3us <115934595+bitcoin3us@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:56:06 +0100 Subject: [PATCH 2/2] task_handler: leave the main thread a share of the CPU under heavy UI load When a handler pass (lv.task_handler plus the STARTED/FINISHED callbacks) outlasts the timer period, the timer callback rescheduled the next pass immediately, so under sustained LVGL load (video playback, heavy animations) the main thread ran only between back-to-back passes: the REPL stopped draining serial input, host tools timed out mid-protocol, touch and app tasks starved. Record how long each pass took and when it ended, and have the timer callback wait until at least a quarter of that duration has elapsed before scheduling the next pass. Passes shorter than the timer period are unaffected; long ones now leave the main thread at least ~20% of the time. Measured on a Waveshare ESP32-S3-Touch-LCD-3.5 during 160x120 MJPEG playback at 30 fps: serial input drain went from 228 B/s with host writes blocking to 365 B/s without blocking, a clip that reliably left the console unreachable now completes with the console responsive, and playback rates are unchanged within noise (14.1 vs 13.9 fps at 160x120/15 fps; 7.3 vs 7.8 at 320x240/8 fps). Co-Authored-By: Claude Fable 5.1 --- .../frozen/other/task_handler.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/api_drivers/common_api_drivers/frozen/other/task_handler.py b/api_drivers/common_api_drivers/frozen/other/task_handler.py index 7a6966b6..d160cd89 100644 --- a/api_drivers/common_api_drivers/frozen/other/task_handler.py +++ b/api_drivers/common_api_drivers/frozen/other/task_handler.py @@ -58,6 +58,10 @@ def __init__( self.max_scheduled = max_scheduled self._last_tick = time.ticks_ms() # NOQA + # Fairness: how long the last handler pass took and when it ended, + # so the timer can leave the main thread a share of the CPU. + self._last_run_ms = 0 + self._last_end = self._last_tick self._timer.init( mode=Timer.PERIODIC, period=self.duration, @@ -102,6 +106,7 @@ def is_running(cls): return cls._current_instance is not None def _task_handler(self, _): + run_start = time.ticks_ms() # NOQA try: self._scheduled -= 1 @@ -159,6 +164,11 @@ def _task_handler(self, _): if self.exception_hook: self.exception_hook(e) + finally: + end = time.ticks_ms() # NOQA + self._last_run_ms = time.ticks_diff(end, run_start) # NOQA + self._last_end = end + def _timer_cb(self, _): # The only place LVGL time advances. Feed it the real elapsed time # rather than the nominal period: timer callbacks are delivered via the @@ -170,6 +180,15 @@ def _timer_cb(self, _): if self._running: return + # Fairness: a handler pass that outlasts the timer period would + # otherwise be rescheduled back-to-back, starving everything else on + # the main thread (the REPL, touch and app tasks). Wait until at + # least a quarter of the last pass's duration has elapsed since it + # ended, so the main thread always keeps ~20% of the CPU. + gap = self._last_run_ms >> 2 + if gap > self.duration and time.ticks_diff(now, self._last_end) < gap: # NOQA + return + if self._scheduled < self.max_scheduled: try: micropython.schedule(self._task_handler_ref, 0)