From fe04349bcc3ec4be1c29b0d607becf25e5fffd2f Mon Sep 17 00:00:00 2001 From: Brad Barnett Date: Sat, 26 Sep 2026 05:55:12 +0000 Subject: [PATCH 1/7] Examples on the new multimer: no async flag, one timer model app.every() loses async_=; from multimer import auto as timer becomes import multimer; lv_test_timer reports the wake source instead of a mode. tools/prove_repl proves the REPL goal on a pty for CPython, MicroPython and (with multimer.repl()) CircuitPython, plus a Jupyter kernel and the direct wasm build under node. The kit's PYDEVICES_TIMER_ASYNC split is retired. --- README.md | 2 +- lib/examples/alien/alien.py | 2 +- lib/examples/apollo/apollo.py | 2 +- lib/examples/appdev_simpletest.py | 2 +- lib/examples/appdev_touch_coords.py | 6 +- lib/examples/audiolive_rack.py | 6 +- lib/examples/bmp565_scroll.py | 2 +- lib/examples/bmp565_scroll_sprite.py | 2 +- lib/examples/bmp565_sprite.py | 2 +- lib/examples/bmp565_sprite_transparent.py | 2 +- lib/examples/bouncing_balls.py | 2 +- lib/examples/boxlines.py | 2 +- lib/examples/calc_graphics.py | 6 +- lib/examples/car_cluster/car_cluster.py | 2 +- lib/examples/color_test.py | 2 +- lib/examples/dino.py | 2 +- lib/examples/displaysys_block_test.py | 2 +- lib/examples/displaysys_fill_rect_test.py | 2 +- lib/examples/feathers.py | 2 +- lib/examples/font_simpletest.py | 2 +- lib/examples/fonts.py | 2 +- lib/examples/google_photos/google_photos.py | 17 +- lib/examples/hello.py | 2 +- lib/examples/lv_test_timer.py | 33 ++-- lib/examples/palettes_demo.py | 2 +- lib/examples/pixel_sim_demos.py | 2 +- lib/examples/proverbs/proverbs.py | 2 +- lib/examples/pydevices_demo.py | 2 +- lib/examples/roku_remote/roku_graphics.py | 6 +- lib/examples/roku_remote/roku_widgets.py | 1 - lib/examples/rotations.py | 2 +- lib/examples/scroll.py | 2 +- lib/examples/scroll_touch_test.py | 2 +- lib/examples/simon.py | 2 +- lib/examples/testris.py | 2 +- lib/examples/tiny_hello.py | 2 +- lib/examples/tiny_toasters/tiny_toasters.py | 2 +- lib/examples/tower_climb/tower_climb.py | 2 +- lib/examples/widgets_energy_panel.py | 2 +- lib/utils/console.py | 2 +- lib/utils/tft_config.py | 6 +- tools/README.md | 87 ++------- tools/example_test_kit.py | 2 + tools/example_test_manifest.toml | 5 +- tools/example_test_wrapper.py | 4 +- tools/lv_timer_test_kit.py | 9 +- tools/prove_repl/demo_crash.py | 7 + tools/prove_repl/demo_keepalive.py | 24 +++ tools/prove_repl/demo_repl.py | 13 ++ tools/prove_repl/demo_timers.py | 25 +++ tools/prove_repl/prove.py | 186 ++++++++++++++++++++ tools/prove_repl/prove_jupyter.py | 111 ++++++++++++ tools/prove_repl/wasm_host.mjs | 53 ++++++ 53 files changed, 513 insertions(+), 160 deletions(-) create mode 100644 tools/prove_repl/demo_crash.py create mode 100644 tools/prove_repl/demo_keepalive.py create mode 100644 tools/prove_repl/demo_repl.py create mode 100644 tools/prove_repl/demo_timers.py create mode 100644 tools/prove_repl/prove.py create mode 100644 tools/prove_repl/prove_jupyter.py create mode 100644 tools/prove_repl/wasm_host.mjs diff --git a/README.md b/README.md index 4a6171d9..735c64b9 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ for complete device setup. ## App ownership Board configs describe hardware. They export neutral pieces such as -`display_drv`, `touch_read`, `host_read`, and `timer_async`; they do not create +`display_drv`, `touch_read`, and `host_read`; they do not create an application-level `appdev.App`. Non-LVGL examples in this repo opt into the optional `appdev` coordinator: diff --git a/lib/examples/alien/alien.py b/lib/examples/alien/alien.py index 8b002476..72160076 100644 --- a/lib/examples/alien/alien.py +++ b/lib/examples/alien/alien.py @@ -90,5 +90,5 @@ def _tick(_=None): tft_bitmap.bitmap(tft, alien, col, row) st["last_col"], st["old_row"] = col, row - app.every(_tick, period=10, async_=app.timer_async) + app.every(_tick, period=10) main() \ No newline at end of file diff --git a/lib/examples/apollo/apollo.py b/lib/examples/apollo/apollo.py index e5ae96a6..ef2eb12c 100644 --- a/lib/examples/apollo/apollo.py +++ b/lib/examples/apollo/apollo.py @@ -143,4 +143,4 @@ def _tick(_=None): _init_apollo() -app.every(_tick, period=20, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=20) \ No newline at end of file diff --git a/lib/examples/appdev_simpletest.py b/lib/examples/appdev_simpletest.py index 3e1747e2..adef02f7 100644 --- a/lib/examples/appdev_simpletest.py +++ b/lib/examples/appdev_simpletest.py @@ -14,7 +14,7 @@ def _on_event(e): print("appdev_simpletest: started — click the canvas to see pointer events") -app.every(_heartbeat, period=2000, async_=app.timer_async) +app.every(_heartbeat, period=2000) # Subscribe broadly via device type so any pointer/key event prints. for et in ( diff --git a/lib/examples/appdev_touch_coords.py b/lib/examples/appdev_touch_coords.py index f893974b..93a6a5ce 100644 --- a/lib/examples/appdev_touch_coords.py +++ b/lib/examples/appdev_touch_coords.py @@ -11,7 +11,7 @@ import appdev import events -from multimer import auto as timer +import multimer FG, BG, ARM = 0xFFFF, 0x0000, 12 W, H = display_drv.width, display_drv.height @@ -66,13 +66,13 @@ def _on_up(e): % (gx, gy, tx, ty, gx - tx, gy - ty) ) idx += 1 - timer.sleep_ms(400) + multimer.sleep_ms(400) if idx >= len(TARGETS): _out("done %s" % (results,)) display_drv.fill_rect(0, 0, W, H, BG) else: _show() - timer.sleep_ms(400) + multimer.sleep_ms(400) finally: _busy = False diff --git a/lib/examples/audiolive_rack.py b/lib/examples/audiolive_rack.py index ffaa12f0..99a80a4e 100644 --- a/lib/examples/audiolive_rack.py +++ b/lib/examples/audiolive_rack.py @@ -379,6 +379,6 @@ def _on_quit(_e=None): app.on(app.events.QUIT, _on_quit) _draw_tick() -app.every(_audio_tick, period=AUDIO_MS, async_=app.timer_async) -app.every(_draw_tick, period=DRAW_MS, async_=app.timer_async) -app.every(_next_patch, period=PATCH_MS, async_=app.timer_async) +app.every(_audio_tick, period=AUDIO_MS) +app.every(_draw_tick, period=DRAW_MS) +app.every(_next_patch, period=PATCH_MS) diff --git a/lib/examples/bmp565_scroll.py b/lib/examples/bmp565_scroll.py index bd34750d..3e42d65e 100644 --- a/lib/examples/bmp565_scroll.py +++ b/lib/examples/bmp565_scroll.py @@ -43,7 +43,7 @@ def _tick(_=None): draw_bg(0, i % display_drv.height, 0, i % image.height) st["i"] = i + 1 - app.every(_tick, period=1, async_=app.timer_async) + app.every(_tick, period=1) main() \ No newline at end of file diff --git a/lib/examples/bmp565_scroll_sprite.py b/lib/examples/bmp565_scroll_sprite.py index 8247da40..7fbc0052 100644 --- a/lib/examples/bmp565_scroll_sprite.py +++ b/lib/examples/bmp565_scroll_sprite.py @@ -118,7 +118,7 @@ def _tick(_=None): app.on(app.events.MOUSEMOTION, _on_motion) # ~20 fps once scrolling; first columns also tick so quit is always serviced. - app.every(_tick, period=50, async_=app.timer_async) + app.every(_tick, period=50) main() \ No newline at end of file diff --git a/lib/examples/bmp565_sprite.py b/lib/examples/bmp565_sprite.py index 95eefa83..3f8adfa0 100644 --- a/lib/examples/bmp565_sprite.py +++ b/lib/examples/bmp565_sprite.py @@ -103,4 +103,4 @@ def _tick(_=None): st["pos_i"] = (st["pos_i"] + 1) % len(positions) -app.every(_tick, period=100, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=100) \ No newline at end of file diff --git a/lib/examples/bmp565_sprite_transparent.py b/lib/examples/bmp565_sprite_transparent.py index 44d4c204..5f4e8416 100644 --- a/lib/examples/bmp565_sprite_transparent.py +++ b/lib/examples/bmp565_sprite_transparent.py @@ -102,4 +102,4 @@ def _tick(_=None): st["pos_i"] = (st["pos_i"] + 1) % len(positions) -app.every(_tick, period=100, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=100) \ No newline at end of file diff --git a/lib/examples/bouncing_balls.py b/lib/examples/bouncing_balls.py index 31fa5397..835c2e16 100644 --- a/lib/examples/bouncing_balls.py +++ b/lib/examples/bouncing_balls.py @@ -103,4 +103,4 @@ def _tick(_=None): pygraphics.circle(display_drv, int(ball.x), int(ball.y), ball.r, ball.color, True) -app.every(_tick, period=10, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=10) \ No newline at end of file diff --git a/lib/examples/boxlines.py b/lib/examples/boxlines.py index 7bfdeb7f..92722078 100644 --- a/lib/examples/boxlines.py +++ b/lib/examples/boxlines.py @@ -82,4 +82,4 @@ def _tick(_=None): poll() -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/calc_graphics.py b/lib/examples/calc_graphics.py index d12a690d..a4ad3ec8 100644 --- a/lib/examples/calc_graphics.py +++ b/lib/examples/calc_graphics.py @@ -25,7 +25,7 @@ from calc_engine import CalcEngine import keys from pygraphics import RGB565, FrameBuffer -from multimer import auto as timer +import multimer from palettes import get_palette from appdev import TouchGrid @@ -122,7 +122,7 @@ def __init__(self): # have no grid cell so the touch mapping never indexes them. keypad_keys = [None] * self.COLS + list(_CODES) + list(_KEY_ALIASES.keys()) self._pending_release = None - self._release_timer = timer.Timer(-1) + self._release_timer = multimer.Timer(-1) self.keypad = TouchGrid( app, 0, @@ -264,7 +264,7 @@ def _on_press(self, code): self._handle_press(col, row, label) self._pending_release = (col, row, label) self._release_timer.init( - mode=timer.Timer.ONE_SHOT, period=150, callback=self._release + mode=multimer.Timer.ONE_SHOT, period=150, callback=self._release ) return # Keyboard alias / digit without a grid cell — feed the engine only. diff --git a/lib/examples/car_cluster/car_cluster.py b/lib/examples/car_cluster/car_cluster.py index d6e9e179..85d4501d 100644 --- a/lib/examples/car_cluster/car_cluster.py +++ b/lib/examples/car_cluster/car_cluster.py @@ -143,7 +143,7 @@ def _drain_rails(_t): if _ui is not None and _ui.rails is not None: _ui.rails.drain_pending() - app.on_tick(_drain_rails, period=30, async_=False) + app.on_tick(_drain_rails, period=30) _ui.rails.drain_pending() if _FREEZE: freeze(True) diff --git a/lib/examples/color_test.py b/lib/examples/color_test.py index 761d13cc..a2d0f44f 100644 --- a/lib/examples/color_test.py +++ b/lib/examples/color_test.py @@ -99,5 +99,5 @@ def poll(): def _tick(_=None): poll() - app.every(_tick, period=1000, async_=app.timer_async) + app.every(_tick, period=1000) main() \ No newline at end of file diff --git a/lib/examples/dino.py b/lib/examples/dino.py index fc97720d..bad781f2 100644 --- a/lib/examples/dino.py +++ b/lib/examples/dino.py @@ -545,5 +545,5 @@ def _tick(timer=None): game.tick(timer) -_tick_subscription = app.every(_tick, period=FRAME_MS, async_=app.timer_async) +_tick_subscription = app.every(_tick, period=FRAME_MS) game.draw() \ No newline at end of file diff --git a/lib/examples/displaysys_block_test.py b/lib/examples/displaysys_block_test.py index 49207932..0ae5cbb1 100644 --- a/lib/examples/displaysys_block_test.py +++ b/lib/examples/displaysys_block_test.py @@ -80,4 +80,4 @@ def _tick(_=None): app.request_quit() -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/displaysys_fill_rect_test.py b/lib/examples/displaysys_fill_rect_test.py index 0ea0f814..fb124864 100644 --- a/lib/examples/displaysys_fill_rect_test.py +++ b/lib/examples/displaysys_fill_rect_test.py @@ -65,4 +65,4 @@ def _tick(_=None): app.request_quit() -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/feathers.py b/lib/examples/feathers.py index 6851b297..9494b6fd 100644 --- a/lib/examples/feathers.py +++ b/lib/examples/feathers.py @@ -123,4 +123,4 @@ def _tick(_=None): poll() -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/font_simpletest.py b/lib/examples/font_simpletest.py index 4e0a5f01..d9612b12 100644 --- a/lib/examples/font_simpletest.py +++ b/lib/examples/font_simpletest.py @@ -139,7 +139,7 @@ def main(): def _tick(_=None): poll() - app.every(_tick, period=1, async_=app.timer_async) + app.every(_tick, period=1) main() \ No newline at end of file diff --git a/lib/examples/fonts.py b/lib/examples/fonts.py index ff4f21a7..16e227e0 100644 --- a/lib/examples/fonts.py +++ b/lib/examples/fonts.py @@ -105,5 +105,5 @@ def main(): def _tick(_=None): poll() - app.every(_tick, period=1, async_=app.timer_async) + app.every(_tick, period=1) main() \ No newline at end of file diff --git a/lib/examples/google_photos/google_photos.py b/lib/examples/google_photos/google_photos.py index 1cf6da75..9d2152c6 100644 --- a/lib/examples/google_photos/google_photos.py +++ b/lib/examples/google_photos/google_photos.py @@ -115,7 +115,7 @@ def _block_if_batch(): path, MCU REPL, PyScript, the example kit) keeps the app alive by itself. """ try: - from appdev import _hostloop + from multimer import _hostloop from display_driver import app if not _launched_with_m(_hostloop) or _hostloop.ambient(): @@ -124,19 +124,16 @@ def _block_if_batch(): return except Exception: return - if app.strategy != _hostloop.AMBIENT or app.timer_async: - app.run() # "none": blocking service loop (async timers: asyncio.run) + if app.strategy != _hostloop.AMBIENT: + app.run() # "none": blocking service loop return # "ambient" was chosen at import time; the timer already drives the app, - # so just hold the main thread until the window closes. Hold it with the - # timer backend's own sleep, never time.sleep: on Windows the timer fires - # only while this thread is in an alertable wait (SleepEx), and a plain - # time.sleep starves it -- the window then never repaints and Windows - # marks it "Not Responding". - from multimer import auto as timer + # so just hold the main thread until the window closes, delivering on + # the way (multimer.sleep_ms serves the timers on every host). + import multimer while not app.quit_requested: - timer.sleep_ms(50) + multimer.sleep_ms(50) def _launched_with_m(_hostloop): diff --git a/lib/examples/hello.py b/lib/examples/hello.py index 8308286c..5c90da97 100644 --- a/lib/examples/hello.py +++ b/lib/examples/hello.py @@ -99,5 +99,5 @@ def main(): def _tick(_=None): poll() - app.every(_tick, period=1, async_=app.timer_async) + app.every(_tick, period=1) main() \ No newline at end of file diff --git a/lib/examples/lv_test_timer.py b/lib/examples/lv_test_timer.py index 4ec67a80..a5dd8405 100644 --- a/lib/examples/lv_test_timer.py +++ b/lib/examples/lv_test_timer.py @@ -38,7 +38,7 @@ from board_config import display_drv from displaydev import env_get -from multimer import auto as timer +import multimer # Optional logical orientation for LVGL (hw MADCTL/SDL/PG or software rotate). _lv_rot = env_get("PYDEVICES_LV_ROTATION") @@ -61,7 +61,7 @@ def _mode_label(): - return "async" if getattr(app, "timer_async", False) else "sync" + return multimer.info().get("source") or "sync" def get_state(): @@ -124,18 +124,9 @@ def _lvgl_label(): def _timer_type(): - # Deliberately not named ``timer``: that would shadow the module-level - # ``multimer.auto`` import this function falls back to, and the fallback - # then reads None on any provider that has not armed yet. - armed = getattr(app, "_timer", None) - if armed is not None: - return _format_timer_type(type(armed)) - try: - from multimer import AsyncTimer - - return _format_timer_type(AsyncTimer if app.timer_async else timer.Timer) - except ImportError: - return "?" + # multimer has one Timer class; what differs per host is the wake source. + info = multimer.info() + return "%s/%s" % (info.get("source"), info.get("delivery")) def get_platform_info(): @@ -285,9 +276,8 @@ def mock_read(): try: deadline = time.time() + 1.5 while (pending or get_state()["taps"] < 1) and time.time() < deadline: - # Pump: the host queue is drained from the app tick, which - # pump-based backends only deliver while the main thread sleeps here. - timer.sleep_ms(10) + # multimer.sleep_ms delivers on every host while this thread waits. + multimer.sleep_ms(10) finally: queue_dev._read = orig_read return get_state()["taps"] @@ -364,10 +354,9 @@ def _run_kit_sync(): deadline = time.time() + _DURATION_S clicked_taps = None while time.time() < deadline: - # timer.sleep_ms, not time.sleep: pump-based backends (threading on - # CircuitPython / Windows CPython, SDL2) deliver callbacks only - # while the main thread pumps. For librt this resolves to a plain sleep. - timer.sleep_ms(10) + # multimer.sleep_ms, not time.sleep: a host with no wake source + # (CircuitPython) delivers only while this thread waits here. + multimer.sleep_ms(10) if clicked_taps is None and get_state()["seconds"] >= 2: cx, cy = _button_center(btn) clicked_taps = _inject_click(cx, cy) @@ -405,7 +394,7 @@ def run_kit(): ``app.poll()`` while LVGL owns the host queue. """ try: - if app.timer_async: + if multimer.loop_running(): payload = app.run_async(_run_kit_async) if payload is not None and hasattr(payload, "done"): _quit_and_exit(1) diff --git a/lib/examples/palettes_demo.py b/lib/examples/palettes_demo.py index 3c6628d0..da0af2c2 100644 --- a/lib/examples/palettes_demo.py +++ b/lib/examples/palettes_demo.py @@ -149,4 +149,4 @@ def _tick(_=None): poll() -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/pixel_sim_demos.py b/lib/examples/pixel_sim_demos.py index 89ae26df..e4795885 100644 --- a/lib/examples/pixel_sim_demos.py +++ b/lib/examples/pixel_sim_demos.py @@ -443,4 +443,4 @@ def _tick(_=None): _next_at = now -app.every(_tick, period=1, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=1) \ No newline at end of file diff --git a/lib/examples/proverbs/proverbs.py b/lib/examples/proverbs/proverbs.py index 0bf8eb0c..adcdbdc7 100644 --- a/lib/examples/proverbs/proverbs.py +++ b/lib/examples/proverbs/proverbs.py @@ -108,7 +108,7 @@ def _tick(_=None): st["index"] = (st["index"] + 1) % len(proverbs) - app.every(_tick, period=5000, async_=app.timer_async) + app.every(_tick, period=5000) main() \ No newline at end of file diff --git a/lib/examples/pydevices_demo.py b/lib/examples/pydevices_demo.py index a37a9853..3cf35d23 100644 --- a/lib/examples/pydevices_demo.py +++ b/lib/examples/pydevices_demo.py @@ -152,5 +152,5 @@ def _on_click(e): setup_scroll() redraw() -app.every(_scroll_tick, period=40, async_=app.timer_async) +app.every(_scroll_tick, period=40) app.on(app.events.MOUSEBUTTONDOWN, _on_click) \ No newline at end of file diff --git a/lib/examples/roku_remote/roku_graphics.py b/lib/examples/roku_remote/roku_graphics.py index 531c3b59..74f9f23a 100644 --- a/lib/examples/roku_remote/roku_graphics.py +++ b/lib/examples/roku_remote/roku_graphics.py @@ -43,7 +43,7 @@ app = appdev.App(board_config) import keys from pygraphics import RGB565, Area, FrameBuffer -from multimer import auto as timer +import multimer from roku_engine import ( FRONTEND_BUTTONS, app_label, @@ -223,7 +223,7 @@ def __init__(self, engine=None, start_page="devices"): self.buttons = [] self._by_id = {} - self._status_timer = timer.Timer(-1) + self._status_timer = multimer.Timer(-1) self._pending_status = None self._pending_state = None self._playback_busy = False @@ -311,7 +311,7 @@ def _soft(): # Must not call Timer.deinit from this callback (librt deadlock). try: self._status_timer.init( - mode=timer.Timer.PERIODIC, + mode=multimer.Timer.PERIODIC, period=250, callback=self._status_pump, hard=False, diff --git a/lib/examples/roku_remote/roku_widgets.py b/lib/examples/roku_remote/roku_widgets.py index 2de16c39..035907fb 100644 --- a/lib/examples/roku_remote/roku_widgets.py +++ b/lib/examples/roku_remote/roku_widgets.py @@ -297,7 +297,6 @@ def __init__(self, engine=None, start_page="devices"): self._pump_sub = self.app.every( self._pump, period=250, - async_=getattr(self.app, "timer_async", False), ) except Exception: self._pump_sub = None diff --git a/lib/examples/rotations.py b/lib/examples/rotations.py index c3b326e0..5be70617 100644 --- a/lib/examples/rotations.py +++ b/lib/examples/rotations.py @@ -77,7 +77,7 @@ def poll(): def _tick(_=None): poll() - app.every(_tick, period=2000, async_=app.timer_async) + app.every(_tick, period=2000) main() \ No newline at end of file diff --git a/lib/examples/scroll.py b/lib/examples/scroll.py index a0556782..840bb2e7 100644 --- a/lib/examples/scroll.py +++ b/lib/examples/scroll.py @@ -80,4 +80,4 @@ def _tick(_=None): poll() -app.every(_tick, period=10, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=10) \ No newline at end of file diff --git a/lib/examples/scroll_touch_test.py b/lib/examples/scroll_touch_test.py index 073be470..e0df4430 100644 --- a/lib/examples/scroll_touch_test.py +++ b/lib/examples/scroll_touch_test.py @@ -104,4 +104,4 @@ def _on_mode_tick(_=None): _rebuild() app.on(app.events.MOUSEBUTTONDOWN, _on_click) -app.every(_on_mode_tick, period=100, async_=app.timer_async) \ No newline at end of file +app.every(_on_mode_tick, period=100) \ No newline at end of file diff --git a/lib/examples/simon.py b/lib/examples/simon.py index 29d8fc57..23b18e02 100644 --- a/lib/examples/simon.py +++ b/lib/examples/simon.py @@ -314,4 +314,4 @@ def _on_tick(_=None): draw_board() app.on(events.MOUSEBUTTONUP, _on_up) -app.every(_on_tick, period=20, async_=getattr(app, "timer_async", False)) \ No newline at end of file +app.every(_on_tick, period=20) \ No newline at end of file diff --git a/lib/examples/testris.py b/lib/examples/testris.py index ca92e9b6..7ad2ba4f 100644 --- a/lib/examples/testris.py +++ b/lib/examples/testris.py @@ -756,4 +756,4 @@ def _tick(_=None): app.request_quit() -app.every(_tick, period=20, async_=app.timer_async) \ No newline at end of file +app.every(_tick, period=20) \ No newline at end of file diff --git a/lib/examples/tiny_hello.py b/lib/examples/tiny_hello.py index 7e268303..9dcdac3e 100644 --- a/lib/examples/tiny_hello.py +++ b/lib/examples/tiny_hello.py @@ -140,5 +140,5 @@ def main(): def _tick(_=None): poll() - app.every(_tick, period=1, async_=app.timer_async) + app.every(_tick, period=1) main() \ No newline at end of file diff --git a/lib/examples/tiny_toasters/tiny_toasters.py b/lib/examples/tiny_toasters/tiny_toasters.py index de99eb7f..8f69e106 100644 --- a/lib/examples/tiny_toasters/tiny_toasters.py +++ b/lib/examples/tiny_toasters/tiny_toasters.py @@ -228,5 +228,5 @@ def _tick(_=None): gc.collect() - app.every(_tick, period=50, async_=app.timer_async) + app.every(_tick, period=50) main() \ No newline at end of file diff --git a/lib/examples/tower_climb/tower_climb.py b/lib/examples/tower_climb/tower_climb.py index 9a6179e5..fed457c3 100644 --- a/lib/examples/tower_climb/tower_climb.py +++ b/lib/examples/tower_climb/tower_climb.py @@ -1230,7 +1230,7 @@ def main(): else: _start_round() - app.every(_tick, period=16, async_=app.timer_async) + app.every(_tick, period=16) main() \ No newline at end of file diff --git a/lib/examples/widgets_energy_panel.py b/lib/examples/widgets_energy_panel.py index 97d5f335..1b168012 100644 --- a/lib/examples/widgets_energy_panel.py +++ b/lib/examples/widgets_energy_panel.py @@ -255,6 +255,6 @@ def _on_spike(_s=None, _e=None): seg.set_change_cb(_on_seg) spike_btn.add_event_cb(pd.events.MOUSEBUTTONDOWN, _on_spike) -app.every(_live_tick, period=500, async_=app.timer_async) +app.every(_live_tick, period=500) screen.visible = True \ No newline at end of file diff --git a/lib/utils/console.py b/lib/utils/console.py index 9a819d1e..55ed7f55 100644 --- a/lib/utils/console.py +++ b/lib/utils/console.py @@ -664,7 +664,7 @@ def _ensure_timer(self): self._subscription = self._app.every(self._refresh_ms, self._tick) return try: - from multimer import auto as multimer + import multimer except ImportError: return if not multimer.Timer: diff --git a/lib/utils/tft_config.py b/lib/utils/tft_config.py index 3319d6d7..cb1ddc5d 100644 --- a/lib/utils/tft_config.py +++ b/lib/utils/tft_config.py @@ -19,7 +19,7 @@ if BUFFERED: from displaybuf import DisplayBuffer - from multimer import auto as timer + import multimer font_dir = sep.join(tft_text.__file__.split(sep)[:-1]) + sep + "fonts" @@ -61,9 +61,9 @@ def show(area=None): display.show = show display.draw = display - tim = timer.Timer() + tim = multimer.Timer() tim.init( - mode=timer.Timer.PERIODIC, + mode=multimer.Timer.PERIODIC, period=33, callback=lambda t: display.show(), ) diff --git a/tools/README.md b/tools/README.md index 1c7f9f6a..76e9dceb 100644 --- a/tools/README.md +++ b/tools/README.md @@ -68,96 +68,45 @@ concrete launcher used in automation. .venv/bin/python -m unittest discover -s tests ``` -### Preferred method (parallel interpreters, fail-fast, both timer modes) +### Preferred method (parallel interpreters, fail-fast) -For thorough verification (timer/multimer/interpreter changes, or “run the full -matrix”), prefer **example-by-example**, **all selected interpreters in parallel** -per example (`--jobs 0`, default), **`--fail-fast`**, and **both** -`PYDEVICES_TIMER_ASYNC` modes as separate kit runs. +For thorough verification (timer/multimer/interpreter changes, or "run the full +matrix"), prefer **example-by-example**, **all selected interpreters in parallel** +per example (`--jobs 0`, default) and **`--fail-fast`**. There is one timer +mode: multimer delivers on every host by itself, so the old sync/async split +(`PYDEVICES_TIMER_ASYNC`) is gone; the kit still accepts the variable and +ignores it. -| Mode | Interpreters | -|------|----------| -| Sync (`PYDEVICES_TIMER_ASYNC=0`) | **5** desktop SDL: `micropython`, `micropython.exe`, `circuitpython`, `cpython-venv`, `python.exe` | -| Async (`PYDEVICES_TIMER_ASYNC=1`) | **7** — the five above plus `pyscript`, `jupyter` | -| Android (opt-in) | `android` — `pydevices/bin/android.py` (or `~/bin/android.py` on PATH) + emulator/device + `org.pydevices.runner` APK; **not** in the default 5/7 lists (`--only-interpreter android`) | +| Interpreters | +|----------| +| **7** — desktop SDL: `micropython`, `micropython.exe`, `circuitpython`, `cpython-venv`, `python.exe`; plus `pyscript`, `jupyter` | +| Android (opt-in) | `android` — `pydevices/bin/android.py` (or `~/bin/android.py` on PATH) + emulator/device + `org.pydevices.runner` APK; **not** in the default list (`--only-interpreter android`) | Default timing is already short (`duration_s=2`, `timeout_s=15` in the -interpreters/manifest defaults). After each example’s parallel wave finishes, if +interpreters/manifest defaults). After each example's parallel wave finishes, if any cell failed, stop before the next example; fix the root cause, then resume. ```bash -# PyScript needs the static server (async mode) +# PyScript needs the static server python tools/serve.py # separate terminal; reuse if already on :8000 export SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy PYTHONUNBUFFERED=1 mkdir -p /tmp/pydevices-examples-matrix -SYNC_RT="micropython micropython.exe circuitpython cpython-venv python.exe" -ASYNC_RT="$SYNC_RT pyscript jupyter" +RT="micropython micropython.exe circuitpython cpython-venv python.exe pyscript jupyter" set -o pipefail # keep kit exit status through tee -# Sync — 5 interpreters concurrently per example -PYDEVICES_TIMER_ASYNC=0 stdbuf -oL -eL \ +stdbuf -oL -eL \ .venv/bin/python tools/example_test_kit.py --no-unit-tests --fail-fast \ - --only-interpreter $SYNC_RT \ - --results-json /tmp/pydevices-examples-matrix/sync.json \ - 2>&1 | stdbuf -oL -eL tee /tmp/pydevices-examples-matrix/sync.log - -# After sync is clean — async, all 7 -PYDEVICES_TIMER_ASYNC=1 stdbuf -oL -eL \ - .venv/bin/python tools/example_test_kit.py --no-unit-tests --fail-fast \ - --only-interpreter $ASYNC_RT \ - --results-json /tmp/pydevices-examples-matrix/async.json \ - 2>&1 | stdbuf -oL -eL tee /tmp/pydevices-examples-matrix/async.log -``` - -Live log lines: `Running @ N interpreter(s) in parallel...`, then -`start` / `done` per interpreter. `--fail-fast` waits for the current example’s -workers, then exits if any cell failed. Resume with `--only-example` -(remaining ids) or by restarting that mode from the failed example. Use -`--jobs 1` for fully serial interpreters when isolating races. See -[Windows PE under WSL](#windows-pe-under-wsl) for PE window / quit notes. - -`--curated-only` is a smoke shortcut, not a substitute for the preferred gate. - -### Matrix commands (scoped / smoke) - -```bash -# Curated set across available interpreters (smoke) -.venv/bin/python tools/example_test_kit.py --curated-only - -# Scope (space-separated ids on one flag; see note below) -.venv/bin/python tools/example_test_kit.py --only-example calculator --only-interpreter micropython -.venv/bin/python tools/example_test_kit.py --no-unit-tests --only-interpreter cpython-venv micropython -.venv/bin/python tools/example_test_kit.py --no-unit-tests \ - --only-example calc_lvgl lv_test_timer --only-interpreter circuitpython - -# Order: --order examples (default) / --order interpreters -# Broader: --all-except-harness -``` - -`--only-example` and `--only-interpreter` use `nargs="+"`: pass multiple ids -space-separated after **one** occurrence of the flag. Repeating the flag -silently keeps only the last list (`--only-interpreter circuitpython --only-interpreter -python.exe` runs just `python.exe`). Same rule for `lv_timer_test_kit.py` -`--only` / `--modes`. - -**Headless desktop** (dummy SDL — default for matrix/smoke): - -```bash -SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy \ - .venv/bin/python tools/example_test_kit.py --no-unit-tests --only-interpreter cpython-venv + --only-interpreter $RT \ + --results-json /tmp/pydevices-examples-matrix/all.json \ + 2>&1 | stdbuf -oL -eL tee /tmp/pydevices-examples-matrix/all.log ``` Unix subprocesses see that shell export. Windows `.exe` behavior is different — see [Windows PE under WSL](#windows-pe-under-wsl). -**Async timers on desktop:** the kit forwards `PYDEVICES_TIMER_ASYNC` as wrapper -`--timer-async` (uses `env_set`, works for Windows PE under WSL). Shell export -is the preferred way to select mode for a full kit run (see Preferred method -above). Semantics: [App and board config — timer_async](https://github.com/PyDevices/pydevices/blob/main/docs/app-and-board-config.md#timer_async-in-srclibboard_configpy). - ### Windows PE under WSL `micropython.exe` and `python.exe` are Windows PE binaries launched from WSL. diff --git a/tools/example_test_kit.py b/tools/example_test_kit.py index b96a9fa3..2d81e16b 100755 --- a/tools/example_test_kit.py +++ b/tools/example_test_kit.py @@ -474,6 +474,8 @@ def run_subprocess_case( apply_sibling_env(env, repo_root=str(REPO)) _ensure_user_micropy_lib(env) # Windows PE under WSL cannot read Linux-exported env; pass via argv + env_set. + # PYDEVICES_TIMER_ASYNC is retired (multimer has one timer model); it is + # still forwarded so old matrix invocations run, and the wrapper ignores it. timer_async = env.get("PYDEVICES_TIMER_ASYNC") if timer_async is not None: cmd.extend(["--timer-async", str(timer_async)]) diff --git a/tools/example_test_manifest.toml b/tools/example_test_manifest.toml index 2adaab1c..44735ee0 100644 --- a/tools/example_test_manifest.toml +++ b/tools/example_test_manifest.toml @@ -4,8 +4,8 @@ # kind: oneshot | loop | async | lvgl | pdwidgets | harness | legacy # quit: native_exit | poll | inject | pending | interactive # bootstrap: full (default) | headless — headless skips utils.path / display init -# Timer mode for kit runs: host PYDEVICES_TIMER_ASYNC is forwarded as wrapper -# --timer-async (board_config / displaydev.env_set). Examples do not read env. +# There is one timer mode: multimer delivers on every host by itself, so the +# old PYDEVICES_TIMER_ASYNC sync/async split is gone (it is still accepted). [defaults] duration_s = 2 @@ -146,7 +146,6 @@ deps = ["utils", "tft_config"] # --- LVGL timer smoke test (matrix) --- # Also covered by tools/lv_timer_test_kit.py (``lv_test_timer.py kit``). -# Follows app.timer_async; does not set PYDEVICES_TIMER_ASYNC itself. [examples.lv_test_timer] import = "lv_test_timer" diff --git a/tools/example_test_wrapper.py b/tools/example_test_wrapper.py index f1e0fec7..de943f5f 100755 --- a/tools/example_test_wrapper.py +++ b/tools/example_test_wrapper.py @@ -699,8 +699,8 @@ def main(argv=None): key, value = item.split("=", 1) if key: env_set(key, value) - if args.get("timer_async") is not None: - env_set("PYDEVICES_TIMER_ASYNC", args["timer_async"]) + # --timer-async is accepted and ignored: multimer has one timer model + # on every host, and board configs no longer export timer_async. # Install the deadline hook AFTER bootstrap: it imports multimer, which is # only on sys.path once _setup_bootstrap has run. The hook drives quit for diff --git a/tools/lv_timer_test_kit.py b/tools/lv_timer_test_kit.py index 3105c163..0b988233 100755 --- a/tools/lv_timer_test_kit.py +++ b/tools/lv_timer_test_kit.py @@ -6,14 +6,13 @@ checks, then an injected ``events.Quit``). Each child prints ``KIT_RESULT=`` on stdout; exit code 0 is expected on success. -The example follows ``app.timer_async`` (it does not set env vars). This kit -sets ``PYDEVICES_TIMER_ASYNC`` in the child environment so ``board_config`` -constructs sync or async timers; modes are ``sync`` and ``async``. +multimer has one timer model on every host now, so the kit runs one mode, +``sync`` (the name is kept so old result files still line up). Where a host +owns an asyncio loop, multimer rides it by itself. From repo root: python tools/lv_timer_test_kit.py python tools/lv_timer_test_kit.py --only cpython-venv - python tools/lv_timer_test_kit.py --only cpython-venv --modes async python tools/lv_timer_test_kit.py --backend sdl2 ``--backend`` forces one multimer backend through @@ -67,7 +66,7 @@ def _temp_dir() -> Path: # Back-compat alias for docs/CLI that used ``cpython``. INTERPRETER_ALIASES = {"cpython": "cpython-venv"} -MODES = ("sync", "async") +MODES = ("sync",) sys.path.insert(0, str(TOOLS)) from example_test_kit import load_interpreters, resolve_interpreter_exe # noqa: E402 diff --git a/tools/prove_repl/demo_crash.py b/tools/prove_repl/demo_crash.py new file mode 100644 index 00000000..a5a27ae1 --- /dev/null +++ b/tools/prove_repl/demo_crash.py @@ -0,0 +1,7 @@ +"""A script that raises must surface the error and exit, not enter the loop.""" + +import multimer + +multimer.keepalive() +multimer.every(20, lambda t: None, name="tick") +raise ValueError("boom") diff --git a/tools/prove_repl/demo_keepalive.py b/tools/prove_repl/demo_keepalive.py new file mode 100644 index 00000000..e2b36c52 --- /dev/null +++ b/tools/prove_repl/demo_keepalive.py @@ -0,0 +1,24 @@ +"""Script mode: keepalive holds the process until the program says stop. + +No app.run(). The exit hook takes the main thread after the last line and +delivers until the timer deinits itself, then the process exits. +""" + +import multimer + +count = [0] + + +def _tick(t): + count[0] += 1 + if count[0] % 5 == 0: + print("[tick]", count[0]) + if count[0] >= 15: + print("[demo] stopping at", count[0]) + t.deinit() + + +multimer.keepalive() +tim = multimer.every(20, _tick, name="tick") +print("[demo] strategy:", multimer.strategy()) +print("-- script body ends here --") diff --git a/tools/prove_repl/demo_repl.py b/tools/prove_repl/demo_repl.py new file mode 100644 index 00000000..9ee25500 --- /dev/null +++ b/tools/prove_repl/demo_repl.py @@ -0,0 +1,13 @@ +"""Hosts with no prompt to fall through to: multimer.repl() is the prompt. + +CircuitPython's supervisor resets the board when code.py returns, so the +program holds the main thread itself and reads lines between deliveries. +""" + +import multimer + +ticks = [] +fast = multimer.every(10, lambda t: ticks.append(multimer.ticks_ms()), name="fast") +print("[demo] armed; source:", multimer.info()["source"]) +multimer.repl() +print("[demo] repl returned with", len(ticks), "ticks") diff --git a/tools/prove_repl/demo_timers.py b/tools/prove_repl/demo_timers.py new file mode 100644 index 00000000..2deb3825 --- /dev/null +++ b/tools/prove_repl/demo_timers.py @@ -0,0 +1,25 @@ +"""No app.run(), no App: two timers, then the script ends. + +Under ``-i`` the prompt must come back with both timers still firing and +inspectable; without ``-i`` the process may exit (no keepalive was asked +for), like a daemon thread. +""" + +import multimer + +ticks = [] +slow = [] + + +def _fast(t): + ticks.append(multimer.ticks_ms()) + + +def _slow(t): + slow.append(multimer.ticks_ms()) + + +fast = multimer.every(10, _fast, name="fast") +slower = multimer.every(100, _slow, name="slower") +print("[demo] armed; source:", multimer.info()["source"], "strategy:", multimer.strategy()) +print("-- script body ends here --") diff --git a/tools/prove_repl/prove.py b/tools/prove_repl/prove.py new file mode 100644 index 00000000..bf7a513d --- /dev/null +++ b/tools/prove_repl/prove.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""Prove the REPL goal: a timer-driven script ends, the prompt comes back, +the timers keep firing, and the program can be inspected at ``>>>``. + +Runs each interpreter's ``-i`` on a real pty (so CPython uses readline or +``_pyrepl`` exactly as a person would), types statements after a delay, and +checks that the tick count grew between two reads. Also runs script mode +(keepalive holds the process until the program stops itself) and crash mode +(an uncaught exception exits instead of entering the loop). + +usage: prove.py [--pd PATH] [--mp BIN] [--cp BIN] [--python EXE ...] + +Exit status is the number of failed checks. Every check is shown able to +fail: ``--planted-fault`` runs the REPL check with ``MULTIMER_SOURCE=none``, +and the input hook off (``MULTIMER_INPUTHOOK=0``), where a bare prompt +cannot deliver, and expects the failure. +""" + +import argparse +import os +import pty +import re +import select +import subprocess +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_PD = HERE.parent.parent.parent / "pydevices" + + +def pty_run(argv, env, settle, statements, gap=0.6, total=15): + pid, fd = pty.fork() + if pid == 0: + os.environ.update(env) + os.environ["TERM"] = "dumb" + os.execvp(argv[0], argv) + out = b"" + + def drain(timeout): + nonlocal out + end = time.time() + timeout + while time.time() < end: + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + out += os.read(fd, 65536) + except OSError: + return False + return True + + drain(settle) + for s in statements: + if s is None: + drain(gap) + continue + os.write(fd, (s + "\n").encode()) + drain(gap) + os.write(fd, b"\x04") + drain(1.0) + try: + os.kill(pid, 9) + except OSError: + pass + os.waitpid(pid, 0) + return out.decode(errors="replace") + + +def check(label, ok, detail=""): + print(("PASS " if ok else "FAIL ") + label + ("" if not detail else " -- " + detail)) + return 0 if ok else 1 + + +def repl_goal(label, argv, env, expect_fail=False): + out = pty_run( + argv + ["-i", str(HERE / "demo_timers.py")], + env, + 1.5, + [ + "print('COUNT1', len(ticks))", + None, + "print('COUNT2', len(ticks), 'SLOW', len(slow))", + "import multimer; multimer.report()", + ], + ) + c = re.findall(r"COUNT1 (\d+)", out) + d = re.findall(r"COUNT2 (\d+) SLOW (\d+)", out) + grew = bool(c and d) and int(d[0][0]) > int(c[0]) + 30 + slow_ok = bool(d) and int(d[0][1]) >= 1 + reported = "multimer on" in out and "name='fast'" in out + if expect_fail: + return check(label + " (planted fault: no wake source)", not grew, "counts %s -> %s" % (c, d)) + fails = check(label + ": ticks grow at the prompt", grew, "counts %s -> %s" % (c, d)) + fails += check(label + ": the 100 ms timer fired too", slow_ok) + fails += check(label + ": report() answers at the prompt", reported) + if fails: + print("---- transcript\n" + out + "\n----") + return fails + + +def inloop_repl(label, argv, env): + """The in-loop line REPL: ticks grow between two reads, report() answers.""" + out = pty_run( + argv + [str(HERE / "demo_repl.py")], + env, + 1.5, + [ + "print('COUNT1', len(ticks))", + None, + "print('COUNT2', len(ticks))", + "multimer.report()", + ], + ) + c = re.findall(r"COUNT1 (\d+)", out) + d = re.findall(r"COUNT2 (\d+)", out) + grew = bool(c and d) and int(d[0]) > int(c[0]) + 30 + fails = check(label + ": multimer.repl() keeps ticks growing between lines", grew, "counts %s -> %s" % (c, d)) + fails += check(label + ": report() answers inside repl()", "multimer on" in out and "name='fast'" in out) + fails += check(label + ": Ctrl-D returns from repl()", "repl returned" in out) + if fails: + print("---- transcript\n" + out + "\n----") + return fails + + +def script_mode(label, argv, env): + t0 = time.time() + p = subprocess.run( + argv + [str(HERE / "demo_keepalive.py")], env=dict(os.environ, **env), + capture_output=True, text=True, timeout=30, + ) + took = time.time() - t0 + out = p.stdout + p.stderr + ok = "stopping at 15" in out and p.returncode == 0 and took < 5 + return check(label + ": keepalive holds the process until the program stops", ok, "rc=%d %.1fs" % (p.returncode, took)) + + +def crash_mode(label, argv, env): + t0 = time.time() + p = subprocess.run( + argv + [str(HERE / "demo_crash.py")], env=dict(os.environ, **env), + capture_output=True, text=True, timeout=30, + ) + took = time.time() - t0 + out = p.stdout + p.stderr + ok = "boom" in out and p.returncode != 0 and took < 5 + return check(label + ": a crashing script exits instead of looping", ok, "rc=%d %.1fs" % (p.returncode, took)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pd", default=str(DEFAULT_PD)) + ap.add_argument("--mp", default=None, help="unix micropython binary") + ap.add_argument("--cp", default=None, help="unix circuitpython binary") + ap.add_argument("--python", action="append", default=[], help="CPython executable (repeatable)") + ap.add_argument("--planted-fault", action="store_true") + a = ap.parse_args() + pd = Path(a.pd) + paths = "%s:%s" % (pd / "lib", pd / "utils") + env = {"PYTHONPATH": paths, "MICROPYPATH": paths + ":.frozen"} + fails = 0 + pythons = a.python or [sys.executable] + for py in pythons: + label = "CPython %s" % subprocess.run([py, "-c", "import sys;print(sys.version.split()[0])"], capture_output=True, text=True).stdout.strip() + fails += repl_goal(label, [py], env) + if a.planted_fault: + fails += repl_goal(label, [py], dict(env, MULTIMER_SOURCE="none", MULTIMER_INPUTHOOK="0"), expect_fail=True) + fails += script_mode(label, [py], env) + fails += crash_mode(label, [py], env) + if a.mp: + fails += repl_goal("MicroPython unix", [a.mp], env) + if a.planted_fault: + fails += repl_goal("MicroPython unix", [a.mp], dict(env, MULTIMER_SOURCE="none", MULTIMER_INPUTHOOK="0"), expect_fail=True) + fails += script_mode("MicroPython unix", [a.mp], env) + fails += crash_mode("MicroPython unix", [a.mp], env) + if a.cp: + # CircuitPython has no fall-through REPL: multimer.repl() is the prompt. + fails += inloop_repl("CircuitPython unix", [a.cp], env) + fails += script_mode("CircuitPython unix", [a.cp], env) + fails += crash_mode("CircuitPython unix", [a.cp], env) + print("failures:", fails) + return fails + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/prove_repl/prove_jupyter.py b/tools/prove_repl/prove_jupyter.py new file mode 100644 index 00000000..5cff2d4b --- /dev/null +++ b/tools/prove_repl/prove_jupyter.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Prove the notebook case: timers armed in one cell keep firing between cells. + +Starts an IPython kernel with jupyter_client (no browser), runs a cell that +arms two multimer timers and ends, waits, then reads the counts from a second +cell and calls multimer.report() from a third. The kernel's asyncio loop is +the wake source, so the cells return while the timers run. + +usage: prove_jupyter.py [--pd PATH] +Exit status is the number of failed checks. +""" + +import argparse +import os +import queue +import re +import sys +import time +from pathlib import Path + +HERE = Path(__file__).resolve().parent +DEFAULT_PD = HERE.parent.parent.parent / "pydevices" + +CELL_ARM = """ +import multimer +ticks = [] +slow = [] +fast = multimer.every(10, lambda t: ticks.append(multimer.ticks_ms()), name="fast") +slower = multimer.every(100, lambda t: slow.append(1), name="slower") +print("SOURCE", multimer.info()["source"], "STRATEGY", multimer.strategy()) +""" +CELL_COUNT1 = "print('COUNT1', len(ticks), len(slow))" +CELL_COUNT2 = "print('COUNT2', len(ticks), len(slow))" +CELL_REPORT = "multimer.report()" +CELL_APP = """ +import board_config, appdev +app = appdev.App(board_config) +shows = [] +app.every(50, lambda t: shows.append(1), name="app.tick") +print("APP", app.strategy, type(app.primary).__name__) +""" +CELL_APP_COUNT = "print('APPCOUNT', len(shows), app.primary.frame_clock.period_ms)" + + +def run_cell(kc, code, timeout=30): + msg_id = kc.execute(code) + out = "" + while True: + try: + msg = kc.get_iopub_msg(timeout=timeout) + except queue.Empty: + break + t = msg["header"]["msg_type"] + c = msg["content"] + if t == "stream": + out += c["text"] + elif t == "error": + out += "\n".join(c["traceback"]) + elif t == "status" and c["execution_state"] == "idle" and msg["parent_header"].get("msg_id") == msg_id: + break + return out + + +def check(label, ok, detail=""): + print(("PASS " if ok else "FAIL ") + label + ("" if not detail else " -- " + detail)) + return 0 if ok else 1 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--pd", default=str(DEFAULT_PD)) + a = ap.parse_args() + pd = Path(a.pd) + from jupyter_client import KernelManager + + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join([str(pd / "lib"), str(pd / "utils"), str(pd / "board_configs" / "jndisplay")]) + km = KernelManager(kernel_name="python3") + km.start_kernel(env=env) + kc = km.client() + kc.start_channels() + kc.wait_for_ready(timeout=60) + fails = 0 + try: + out = run_cell(kc, CELL_ARM) + src = re.findall(r"SOURCE (\S+) STRATEGY (\S+)", out) + fails += check("cell 1 arms timers on the kernel's loop", bool(src) and src[0][0] == "asyncio" and src[0][1] == "ambient", out.strip()) + time.sleep(1.0) + c1 = re.findall(r"COUNT1 (\d+) (\d+)", run_cell(kc, CELL_COUNT1)) + time.sleep(1.0) + c2 = re.findall(r"COUNT2 (\d+) (\d+)", run_cell(kc, CELL_COUNT2)) + grew = bool(c1 and c2) and int(c2[0][0]) > int(c1[0][0]) + 50 and int(c2[0][1]) > int(c1[0][1]) + fails += check("timers keep firing between cells", grew, "counts %s -> %s" % (c1, c2)) + rep = run_cell(kc, CELL_REPORT) + fails += check("report() answers from a cell", "source=asyncio" in rep and "name='fast'" in rep, rep.strip()[:200]) + out = run_cell(kc, CELL_APP) + app_ok = "APP ambient JNDisplay" in out + fails += check("an App on JNDisplay arms in a notebook", app_ok, out.strip()[-300:]) + if app_ok: + time.sleep(0.6) + ac = re.findall(r"APPCOUNT (\d+) (\d+)", run_cell(kc, CELL_APP_COUNT)) + fails += check("the App's timers ran between cells", bool(ac) and int(ac[0][0]) >= 5, str(ac)) + finally: + kc.stop_channels() + km.shutdown_kernel(now=True) + print("failures:", fails) + return fails + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/prove_repl/wasm_host.mjs b/tools/prove_repl/wasm_host.mjs new file mode 100644 index 00000000..fa9a4db2 --- /dev/null +++ b/tools/prove_repl/wasm_host.mjs @@ -0,0 +1,53 @@ +// Drive the direct MicroPython WebAssembly build the way a browser page does: +// load the interpreter, mount the pydevices tree, run a timer script that ends, +// then RETURN to the JS event loop. The page loop is the wake source; nothing +// here pumps. Two reads a second apart must show the tick count growing, and +// multimer.report() must answer. +// +// usage: node wasm_host.mjs [path/to/micropython.mjs] +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { readdirSync, readFileSync, statSync } from "node:fs"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const PD = process.env.PD || join(HERE, "../../../pydevices"); +const MJS = process.argv[2] || join(PD, "bin/micropython.mjs"); +const { loadMicroPython } = await import(MJS); + +let captured = ""; +const mp = await loadMicroPython({ + stdout: (line) => { captured += line + "\n"; console.log(line); }, + heapsize: 16 * 1024 * 1024, +}); + +function copyTree(src, dst) { + mp.FS.mkdir(dst); + for (const name of readdirSync(src)) { + if (name === "__pycache__" || name.startsWith(".")) continue; + const s = join(src, name), d = dst + "/" + name; + if (statSync(s).isDirectory()) copyTree(s, d); + else if (name.endsWith(".py")) mp.FS.writeFile(d, readFileSync(s)); + } +} +copyTree(join(PD, "lib"), "/lib"); +copyTree(join(PD, "utils"), "/utils"); +mp.runPython(`import sys +sys.path.insert(0, "/utils") +sys.path.insert(0, "/lib")`); + +await mp.runPythonAsync(readFileSync(join(HERE, "demo_timers.py"), "utf8")); +console.log("[host] script returned to the JS event loop; not pumping anything"); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +await sleep(1000); +mp.runPython("print('COUNT1', len(ticks), len(slow))"); +await sleep(1000); +mp.runPython("print('COUNT2', len(ticks), len(slow))"); +mp.runPython("import multimer; multimer.report()"); + +const c1 = captured.match(/COUNT1 (\d+) (\d+)/), c2 = captured.match(/COUNT2 (\d+) (\d+)/); +const grew = c1 && c2 && (+c2[1] > +c1[1] + 30) && (+c2[2] > +c1[2]); +const reported = /multimer on micropython\/webassembly/.test(captured) && /name='fast'/.test(captured); +console.log((grew ? "PASS" : "FAIL") + " wasm: ticks grow while the page loop idles"); +console.log((reported ? "PASS" : "FAIL") + " wasm: report() answers"); +process.exit((grew ? 0 : 1) + (reported ? 0 : 1)); From 1fa0f5ac94e90420d9ac08f144cadd415909d389 Mon Sep 17 00:00:00 2001 From: Brad Barnett Date: Sat, 26 Sep 2026 06:00:50 +0000 Subject: [PATCH 2/7] prove_hostloop: one timer model, no async scenario --- tools/prove_hostloop/_app.py | 6 +++--- tools/prove_hostloop/demo_async.py | 6 ------ tools/prove_hostloop/prove.sh | 4 ++-- 3 files changed, 5 insertions(+), 11 deletions(-) delete mode 100644 tools/prove_hostloop/demo_async.py diff --git a/tools/prove_hostloop/_app.py b/tools/prove_hostloop/_app.py index e068c226..7f8fb178 100644 --- a/tools/prove_hostloop/_app.py +++ b/tools/prove_hostloop/_app.py @@ -23,9 +23,9 @@ def quit(self): print("[app] display released") -def make_app(timer_async=False): - app = App(displays=[Display()], timer_async=timer_async, refresh_period=0) - print("[app] strategy:", app.strategy, "async:", app.timer_async) +def make_app(): + app = App(displays=[Display()], refresh_period=0) + print("[app] strategy:", app.strategy) ticks = [] @app.every(TICK_MS) diff --git a/tools/prove_hostloop/demo_async.py b/tools/prove_hostloop/demo_async.py deleted file mode 100644 index 9f40e0b7..00000000 --- a/tools/prove_hostloop/demo_async.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Async timers, no app.run(). Needs hostloop's drive path.""" - -from _app import make_app - -app = make_app(timer_async=True) -print("-- script body ends here --") diff --git a/tools/prove_hostloop/prove.sh b/tools/prove_hostloop/prove.sh index 03262790..3cb059c7 100755 --- a/tools/prove_hostloop/prove.sh +++ b/tools/prove_hostloop/prove.sh @@ -21,7 +21,7 @@ run() { # run