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)