From f6dade865faa573c94e5dc9cb7bcb8ec53930e5d Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Mon, 9 Nov 2026 20:52:40 -0600 Subject: [PATCH] appkit, touchkit and System: run on CircuitPython boards Home and System ran on a Waveshare ESP32-S3-Touch-LCD-4.3 under CircuitPython 11.0.0-alpha.2 with a few gaps. These close them. touchkit.net reads the address, signal and hostname from wifi.radio where there is no network module, so System's network rows and stats() work. System shows CircuitPython's pre-release in its interpreter line ("CircuitPython 11.0.0-alpha.2"), which the version tuple leaves out. appkit's scope finds the built-in lvgl module on CircuitPython too, so an app's LVGL objects are closed with it. The launcher can now restart on CircuitPython: the next app's name goes in /data/.next_app, then supervisor.reload(), or microcontroller.reset() from a callback. That makes the memory restart and plain scripts from the home screen work there. Ctrl-C ends code.py on CircuitPython, so appkit.serve() also runs lines typed on the console while the apps keep going, and apps.system.stats() answers from it. Checked on the board: code.py boots Home, System opens (761-914 ms, 25 fps, the arc turning), stats() answers over the console, a restart from the top level and one from a timer both land in the app asked for, and a script app runs and comes back to Home. appkit's tests pass on CPython. --- apps/system/__init__.py | 7 +++++ lib/appkit/README.md | 12 +++++++-- lib/appkit/ROADMAP.md | 2 -- lib/appkit/foreground.py | 58 ++++++++++++++++++++++++++++++++++++++++ lib/appkit/launcher.py | 22 +++++++++++---- lib/appkit/scope.py | 7 ++--- lib/touchkit/net.py | 35 +++++++++++++++++++++--- lib/touchkit/shell.py | 3 ++- 8 files changed, 129 insertions(+), 17 deletions(-) diff --git a/apps/system/__init__.py b/apps/system/__init__.py index 6782bfd..1d42714 100644 --- a/apps/system/__init__.py +++ b/apps/system/__init__.py @@ -155,6 +155,13 @@ def _interpreter(): name = {"micropython": "MicroPython", "circuitpython": "CircuitPython", "cpython": "CPython"}.get( impl.name, impl.name ) + if impl.name == "circuitpython": + # Its version tuple leaves out the pre-release, which tells one + # alpha from the next: "11.0.0-alpha.2 on 2026-11-05". + try: + return "%s %s" % (name, os.uname().version.split(" on ")[0]) + except Exception: + pass v = impl.version return "%s %d.%d.%d" % (name, v[0], v[1], v[2]) diff --git a/lib/appkit/README.md b/lib/appkit/README.md index e99d95d..fd2d02d 100644 --- a/lib/appkit/README.md +++ b/lib/appkit/README.md @@ -157,6 +157,12 @@ timer still running, and `appkit.serve()` picks the loop up again. A Ctrl-C that lands inside a timer's callback is held until the callback ends, so it sometimes needs pressing twice. +On a CircuitPython board Ctrl-C ends `code.py`, display and all, so there's +no REPL to go to while the apps run. The loop reads the console instead: +type a line and it runs in `code.py`'s namespace, with its value printed. +`launcher.current` or `import apps.system; apps.system.stats()` answer +while the apps keep running. + ## An app set Put each app in its own folder under `apps/`, with an `app.json` naming it: @@ -229,8 +235,10 @@ import board_config ... ``` -On CircuitPython boards a launcher can't run scripts yet: there's no -restart to go through. +On a CircuitPython board the launcher keeps the next app's name in +`/data/.next_app`, so CIRCUITPY has to be writable from code. While a +computer has the drive mounted it isn't: a script then doesn't start, and a +restart for memory carries on in-process instead. ## A frozen build diff --git a/lib/appkit/ROADMAP.md b/lib/appkit/ROADMAP.md index e0589fe..04f2da3 100644 --- a/lib/appkit/ROADMAP.md +++ b/lib/appkit/ROADMAP.md @@ -1,7 +1,5 @@ # Roadmap -- **Scripts on CircuitPython boards.** Run a plain script from the home - screen there too, through CircuitPython's own way to start a file next. - **Publishing.** appkit in the mip index with the other packages here, and later a pip package. - **A model for a front panel.** An app can expose a model (describe, apply, diff --git a/lib/appkit/foreground.py b/lib/appkit/foreground.py index e01700f..6f19395 100644 --- a/lib/appkit/foreground.py +++ b/lib/appkit/foreground.py @@ -35,6 +35,11 @@ def frame(): that lands inside a timer's callback is kept by the timers until they're done, so it sometimes needs pressing twice. ``appkit.serve()`` resumes the loop. + +CircuitPython has no REPL to go to with the program still running: Ctrl-C +ends ``code.py``, and the display with it. There `serve` reads lines typed +on the console instead, runs each in ``code.py``'s namespace and prints what +it returns, so ``apps.system.stats()`` answers while the apps keep running. """ import sys @@ -59,6 +64,10 @@ def frame(): def _report(exc): print("appkit.foreground: the foreground function failed:") + _print_exc(exc) + + +def _print_exc(exc): pe = getattr(sys, "print_exception", None) if pe is not None: pe(exc) @@ -214,6 +223,52 @@ def _done(app): return app is not None and app.quit_requested +def _console(): + """A function that runs the lines typed on CircuitPython's console so + far, or None on any other host (or a CircuitPython with no console).""" + if sys.implementation.name != "circuitpython": + return None + try: + import supervisor + + rt = supervisor.runtime + rt.serial_bytes_available + # code.py's globals; CircuitPython keeps __main__ out of sys.modules. + namespace = __import__("__main__").__dict__ + except Exception: + return None + buf = [""] + + def poll(): + n = rt.serial_bytes_available + if not n: + return + text = buf[0] + sys.stdin.read(n).replace("\r", "\n") + while "\n" in text: + line, text = text.split("\n", 1) + line = line.strip() + if line: + print(">>> " + line) + _run_line(line, namespace) + buf[0] = text + + return poll + + +def _run_line(src, namespace): + try: + try: + code = compile(src, "", "eval") + except SyntaxError: + exec(compile(src, "", "exec"), namespace) + else: + value = eval(code, namespace) + if value is not None: + print(repr(value)) + except Exception as exc: + _print_exc(exc) + + def serve(app=None): """Run the foreground function over and over until the program quits. @@ -233,9 +288,12 @@ def serve(app=None): # A stop() asked for before the loop started still counts: a restart # from inside boot(), say. _stop_timer() + console = _console() _serving = True try: while not _done(app): + if console is not None: + console() fn = _fn if fn is None: multimer.sleep_ms(IDLE_MS) diff --git a/lib/appkit/launcher.py b/lib/appkit/launcher.py index d723eaa..24911ef 100644 --- a/lib/appkit/launcher.py +++ b/lib/appkit/launcher.py @@ -35,7 +35,11 @@ ESP32 (MicroPython) ``machine.RTC().memory()`` ``machine.soft_reset()``, or ``machine.reset()`` from a callback; main.py calls ``boot()`` again -Other boards a file, ``/data/.next_app`` the same +CircuitPython a file, ``/data/.next_app`` ``supervisor.reload()``, or + ``microcontroller.reset()`` from a + callback; code.py calls ``boot()`` + again +Other boards a file, ``/data/.next_app`` the same as ESP32 Desktop MicroPython ``/.next_app`` exit with code 75; run the program under ``python -m appkit.launcher`` to have it started again @@ -356,8 +360,7 @@ def restart_into(name, count=1, path=None): With *name* None the program restarts with nothing pending, so it starts as it does from cold: on its home screen. Doesn't return where the host can restart (it exits, execs or resets). In a browser it returns after - asking for the reload: stop work and let the page go. Raises - ``NotImplementedError`` where there is no restart. + asking for the reload: stop work and let the page go. """ h = host() if h == "browser": @@ -371,8 +374,6 @@ def restart_into(name, count=1, path=None): params.set("app_path", path) js.window.location.search = params.toString() return - if h == "circuitpython": - raise NotImplementedError("no restart fallback on CircuitPython boards yet") if name is not None: save_next(name, count, path) else: @@ -395,6 +396,17 @@ def restart_into(name, count=1, path=None): # A real soft reset stops it with every other timer. multimer.after(500, lambda t: machine.reset(), name="launcher.reset") machine.soft_reset() + elif h == "circuitpython": + import microcontroller + import supervisor + + # The same as a board's soft reset: reload() raises an exception that + # a callback's caller catches, so from a callback reset the chip; the + # file in /data survives either. + if _in_callback(): + microcontroller.reset() + multimer.after(500, lambda t: microcontroller.reset(), name="launcher.reset") + supervisor.reload() elif h == "desktop": import os diff --git a/lib/appkit/scope.py b/lib/appkit/scope.py index a1555b1..4e9d572 100644 --- a/lib/appkit/scope.py +++ b/lib/appkit/scope.py @@ -85,11 +85,12 @@ def mem_free(): def _lvgl(): """The lvgl module when it is loaded and initialised, else None. - MicroPython keeps built-in modules out of ``sys.modules``, so there an - import (free for a built-in, an ImportError without one) is the test. + MicroPython and CircuitPython keep built-in modules out of + ``sys.modules``, so there an import (free for a built-in, an ImportError + without one) is the test. """ lv = sys.modules.get("lvgl") - if lv is None and sys.implementation.name == "micropython": + if lv is None and sys.implementation.name in ("micropython", "circuitpython"): try: import lvgl as lv except ImportError: diff --git a/lib/touchkit/net.py b/lib/touchkit/net.py index 14ac596..c393a84 100644 --- a/lib/touchkit/net.py +++ b/lib/touchkit/net.py @@ -5,7 +5,9 @@ A desktop has no ``wifi`` or ``network`` module and is on its network already: `up` is then True once the computer has an address, and -`ip` and `hostname` are the computer's own. +`ip` and `hostname` are the computer's own. CircuitPython has no +``network`` module either: there `ip`, `rssi` and `hostname` read +``wifi.radio``. The board joins a known network at boot (`boot`) and stays joined while apps come and go, like the display. An app that needs the network @@ -26,7 +28,10 @@ def _host_ip(): For a host with no ``network`` module (a desktop), which is on its LAN already: a UDP socket "connected" to a documentation address (TEST-NET-1, RFC 5737) sends nothing and names the interface the OS would use.""" - import socket + try: + import socket + except ImportError: # CircuitPython names it socketpool + return None s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: @@ -41,6 +46,16 @@ def _host_ip(): s.close() +def _radio(): + """CircuitPython's ``wifi.radio``, where there is no ``network`` module; else None.""" + try: + import wifi + + return wifi.radio + except (ImportError, AttributeError): + return None + + def hostname(): """The board's name on the network, without ``.local``, or None.""" name = settings.get("hostname") @@ -50,7 +65,10 @@ def hostname(): import network return network.hostname() - except ImportError: # a desktop: the computer's own name + except ImportError: # CircuitPython's radio, or a desktop: the computer's own name + r = _radio() + if r is not None: + return r.hostname try: import socket @@ -152,10 +170,15 @@ def setup_job(): def rssi(): + """The signal from the access point in dBm, or None off the network.""" try: import network return network.WLAN(network.STA_IF).status("rssi") + except ImportError: + r = _radio() + info = r.ap_info if r is not None else None + return info.rssi if info is not None else None except Exception: return None @@ -169,7 +192,11 @@ def ip(): if wl.isconnected(): return wl.ifconfig()[0] except ImportError: - return _host_ip() + r = _radio() + if r is None: + return _host_ip() + addr = r.ipv4_address + return str(addr) if addr else None except Exception: pass return None diff --git a/lib/touchkit/shell.py b/lib/touchkit/shell.py index 0138daf..6bdf1d3 100644 --- a/lib/touchkit/shell.py +++ b/lib/touchkit/shell.py @@ -90,9 +90,10 @@ def make_home_button(self): def _boot_button(self): try: import board_config as board - from machine import Pin pin = board.boot_button # a lazy role: reading it may hand back a factory + from machine import Pin + if not hasattr(pin, "irq"): pin = pin() pin.irq(trigger=Pin.IRQ_FALLING, handler=_boot_irq)