From c78a802ac67209ccb186894f7575d6494410068f Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Mon, 5 Oct 2026 17:41:07 -0400 Subject: [PATCH] The Cardputer's panel shows MeshTerm, not the launcher's loading screen On the device MeshTerm ran, drew every frame, and stayed invisible: the panel kept the launcher's last frame while a splash nobody could see waited for a key. The panel is a DRM driver whose /dev/fb0 is fbdev emulation, and the kernel copies to the glass only what it sees change through the *mapping*. pwrite() put the pixels in the buffer, where reading /dev/fb0 back showed them, and never on the panel. The frame now goes through an mmap, as M5's own LVGL apps write theirs. Checked on the panel. Also scripts/cardputer-zero/type-text.py: paste a long string (an API key, a Wi-Fi password) on another computer and it is typed on the Cardputer's keyboard over SSH, into whatever app has the screen. Input is hidden and travels on stdin, never on a command line. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/devices/cardputer-zero.md | 14 +++ meshterm/emulator/framebuffer.py | 34 +++++-- scripts/README.md | 2 +- scripts/cardputer-zero/type-text.py | 138 ++++++++++++++++++++++++++++ tests/test_emulator.py | 26 +++++- 5 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 scripts/cardputer-zero/type-text.py diff --git a/docs/devices/cardputer-zero.md b/docs/devices/cardputer-zero.md index 02eef4b..f0b600e 100644 --- a/docs/devices/cardputer-zero.md +++ b/docs/devices/cardputer-zero.md @@ -88,6 +88,20 @@ scripts/cardputer-zero/launcher-entry.sh MeshTerm now has an icon in the launcher. `scripts/cardputer-zero/launcher-entry.sh remove` takes it out again. +### Typing long text from your computer + +A long password or key is hard work on a keyboard this small. From another computer that +can reach the Cardputer over SSH, `scripts/cardputer-zero/type-text.py` types it for you, +into whatever app has the screen: + +```bash +python scripts/cardputer-zero/type-text.py --host pi@cardputer.local +``` + +Paste the text when it asks, and press Enter. Your typing stays hidden, and the text goes +to the Cardputer over SSH, so it never shows on either screen. Open the field you want it +in on the Cardputer first, and leave its keyboard alone until the tool says it's done. + ## Trying it in the emulator The emulator opens a window showing the Cardputer Zero's screen, scaled up so it's diff --git a/meshterm/emulator/framebuffer.py b/meshterm/emulator/framebuffer.py index 3b5d202..b63ad90 100644 --- a/meshterm/emulator/framebuffer.py +++ b/meshterm/emulator/framebuffer.py @@ -10,8 +10,10 @@ app reads key events itself, while the launcher watches the same stream for a he **Written ahead of the hardware** (2026-09-30), from M5's published sources: the framebuffer and keyboard paths and the Esc policy from the launcher, the key codes from the keyboard driver's keymaps, and the Sym layer's characters from M5's console keymap -(``tca8418_keypad_m5stack_keymap.map``). The mapping is unit-tested; the I/O waits for a -device to run on. +(``tca8418_keypad_m5stack_keymap.map``). The mapping is unit-tested. **Run on the device +since 2026-10-05**, where the key codes and the Sym layer checked out against the keyboard +and M5's installed keymap, and the panel turned out to need its pixels written through a +mapping (:class:`Framebuffer`). The keyboard driver does the Fn layer in the kernel, so Fn+4 arrives as a plain ``KEY_F4`` and the arrows as real arrow keys; Shift is a real ``KEY_LEFTSHIFT`` held down for as long @@ -22,6 +24,7 @@ gives them the same ones, and is the only place that knows. from __future__ import annotations +import mmap import os import signal import struct @@ -127,16 +130,33 @@ class KeyState: class Framebuffer: - """The panel's framebuffer, written a band of pixel rows at a time.""" + """The panel's framebuffer, written a band of pixel rows at a time through a mapping. + + **Through a mapping, never ``write()``.** The panel is a DRM driver (``panel-mipi-dbi``) + whose ``/dev/fb0`` is the kernel's fbdev emulation: a buffer the kernel copies to the + panel only where it knows something changed, and what it watches is the *mapping* — + a write through it faults the page, and the page goes on the next refresh. Bytes sent + with ``pwrite`` land in the same buffer, where reading ``/dev/fb0`` back shows them, and + never reach the glass: on the device MeshTerm ran with the launcher's loading screen + still showing over it, and the reader quit a splash they couldn't see. M5's own apps + draw through the mapping too (LVGL's ``lv_linux_fbdev``). Found on the hardware, + 2026-10-05. + """ def __init__(self, path: str, *, width: int, height: int) -> None: - """Open the framebuffer at ``path`` for a ``width`` × ``height`` panel.""" + """Open and map the framebuffer at ``path`` for a ``width`` × ``height`` panel.""" self.path = path self._fd = os.open(path, os.O_RDWR) stride = self._sysfs("stride") self.stride = int(stride) if stride else width * 2 self.width = width self.height = height + self._map = mmap.mmap( + self._fd, + self.stride * height, + mmap.MAP_SHARED, # type: ignore[attr-defined] # Linux only, as the panel is + mmap.PROT_READ | mmap.PROT_WRITE, # type: ignore[attr-defined] + ) def _sysfs(self, name: str) -> str | None: node = Path("/sys/class/graphics") / Path(self.path).name / name @@ -149,10 +169,12 @@ class Framebuffer: """Write pixel rows ``first`` to ``last`` (exclusive) of a ``row_bytes``-wide image.""" view = memoryview(pixels) for y in range(first, min(last, self.height)): - os.pwrite(self._fd, view[y * row_bytes : (y + 1) * row_bytes], y * self.stride) + start = y * self.stride + self._map[start : start + row_bytes] = view[y * row_bytes : (y + 1) * row_bytes] def close(self) -> None: - """Close the framebuffer.""" + """Unmap and close the framebuffer.""" + self._map.close() os.close(self._fd) diff --git a/scripts/README.md b/scripts/README.md index dbffa64..d9bd990 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,7 @@ Repo-side tools. None of these ship in the wheel. | Script | What it is for | |---|---| | `basemap-doctor.py` | Why a user's map has no basemap — certificates, DNS, proxy, or colour depth | -| `cardputer-zero/` | `launcher-entry.sh` puts a source checkout on the Cardputer Zero's app launcher (and `remove` takes it off), until there is a store package; `meshterm.png` is its placeholder icon | +| `cardputer-zero/` | `launcher-entry.sh` puts a source checkout on the Cardputer Zero's app launcher (and `remove` takes it off), until there is a store package; `meshterm.png` is its placeholder icon. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH | | `picocalc-lyra/` | Calculinux device setup: console font, palette, Wi-Fi; `xiao-radio/` is the XIAO radio kit (firmware build, flashing, UART setup) | | `uconsole/` | The uConsole's always-on SPI bridge service (MeshTerm can also drive that radio directly, with no script at all — see `docs/devices/uconsole.md`) | diff --git a/scripts/cardputer-zero/type-text.py b/scripts/cardputer-zero/type-text.py new file mode 100644 index 0000000..c9dfa01 --- /dev/null +++ b/scripts/cardputer-zero/type-text.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""type-text: type on a Cardputer Zero's keyboard from another computer. + +A long string — an API key, a Wi-Fi password — is miserable to enter on a 46-key keyboard +with sticky modifiers. This sends text over SSH and presses the keys for you: it writes key +events into the Cardputer's own keyboard device, so whatever has the screen (the launcher, +MeshTerm, any of M5's apps) gets them exactly as if they had been typed. + + python scripts/cardputer-zero/type-text.py --host pi@192.168.0.165 -i ~/.ssh/id_ed25519 + +It asks for the text with your typing hidden: paste it, then press Enter. A secret never +shows on screen, never enters your shell history, and never sits on a command line where +the process list would show it — it travels over SSH on standard input. Piped input works +too (``... | type-text.py``). ``--enter`` presses Enter on the Cardputer afterwards. + +What it can type is what the keyboard has keys for: letters, digits, space, and the +symbols on the Sym layer, which go out as the codes M5's keymap gives them. Anything else +is refused before a single key is pressed. + +Needs: ``ssh`` on this computer, and a Cardputer user in the ``input`` group (the stock +``pi`` is). +""" + +from __future__ import annotations + +import argparse +import base64 +import getpass +import json +import os +import subprocess +import sys + +#: The keyboard's event device, where the launcher says it is. +KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event" + +#: Key codes (linux/input-event-codes.h) for what the keyboard can type unshifted. +PLAIN = { + **{ch: 16 + i for i, ch in enumerate("qwertyuiop")}, + **{ch: 30 + i for i, ch in enumerate("asdfghjkl")}, + **{ch: 44 + i for i, ch in enumerate("zxcvbnm")}, + **{ch: 2 + i for i, ch in enumerate("1234567890")}, + " ": 57, +} + +#: The Sym layer: the placeholder codes its keys send, with the symbol M5's keymap +#: (``/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map``) turns each into. +SYM = { + "!": 26, "@": 27, "#": 39, "$": 40, "%": 41, "^": 43, "&": 51, "*": 52, + "(": 53, ")": 94, "~": 55, "`": 69, "_": 70, "-": 71, "+": 72, "=": 73, + "[": 74, "]": 75, "{": 76, "}": 77, ";": 79, ":": 80, "'": 81, '"': 82, + "<": 83, ">": 85, "\\": 86, "|": 89, ",": 90, ".": 91, "/": 92, "?": 93, +} # fmt: skip + +#: Runs on the Cardputer, under its own Python: presses each key, Shift for capitals. +DEVICE = """ +import base64, json, struct, sys, time +EV = struct.Struct("llHHi") +KEYS = json.loads(base64.b64decode(sys.argv[1])) +SHIFT, ENTER = 42, 28 +text = sys.stdin.read() +board = open(sys.argv[2], "wb", buffering=0) +def emit(code, value): + board.write(EV.pack(0, 0, 1, code, value) + EV.pack(0, 0, 0, 0, 0)) +def tap(code, shift=False): + if shift: + emit(SHIFT, 1); time.sleep(0.02) + emit(code, 1); time.sleep(0.03); emit(code, 0) + if shift: + time.sleep(0.02); emit(SHIFT, 0) + time.sleep(0.09) +for ch in text: + upper = ch.isalpha() and ch.isupper() + tap(KEYS[ch.lower() if upper else ch], upper) +if sys.argv[3] == "1": + tap(ENTER) +print(f"typed {len(text)} characters on the Cardputer", file=sys.stderr) +""" + + +def keys_for(text: str) -> dict[str, int]: + """The key for each character ``text`` uses, or exit naming the ones it can't type.""" + table = {**PLAIN, **SYM} + missing = sorted({ch for ch in text if (ch.lower() if ch.isalpha() else ch) not in table}) + if missing: + sys.exit(f"type-text: the Cardputer has no key for {' '.join(map(repr, missing))}") + return table + + +def read_text(show: bool) -> str: + """The text to type: piped in, or asked for (hidden unless ``show``).""" + if not sys.stdin.isatty(): + text = sys.stdin.read() + elif show: + text = input("Text to type: ") + else: + text = getpass.getpass("Text to type (hidden — paste it, then press Enter): ") + return text.strip("\r\n") + + +def main() -> int: + """Read the text, then type it on the Cardputer over SSH.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--host", + default=os.environ.get("CARDPUTER_HOST", "pi@cardputer.local"), + help="user@host of the Cardputer (default: $CARDPUTER_HOST, else pi@cardputer.local)", + ) + parser.add_argument("-i", dest="identity", help="SSH private key to log in with") + parser.add_argument("--enter", action="store_true", help="press Enter after the text") + parser.add_argument("--show", action="store_true", help="show the text as you type it") + args = parser.parse_args() + + text = read_text(args.show) + if not text: + print("type-text: nothing to type", file=sys.stderr) + return 1 + table = keys_for(text) + # Both go as base64: the remote shell sees letters and digits only, so no quote in the + # program or the symbol table (an apostrophe is one of its keys) can break its quoting. + program = base64.b64encode(DEVICE.encode()).decode() + keys = base64.b64encode(json.dumps(table).encode()).decode() + remote = [ + "python3", + "-c", + f"'import base64; exec(base64.b64decode(\"{program}\"))'", + keys, + KEYBOARD, + "1" if args.enter else "0", + ] + ssh = ["ssh"] + (["-i", os.path.expanduser(args.identity)] if args.identity else []) + done = subprocess.run([*ssh, args.host, *remote], input=text, text=True, check=False) + return done.returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_emulator.py b/tests/test_emulator.py index 990ed54..9a029fa 100644 --- a/tests/test_emulator.py +++ b/tests/test_emulator.py @@ -3,6 +3,8 @@ from __future__ import annotations +import mmap +import os import threading import time @@ -11,7 +13,7 @@ from prompt_toolkit.input.vt100_parser import Vt100Parser from prompt_toolkit.keys import Keys from meshterm.emulator.font import CELL_H, MARKS, MISSING, build_font, load_bdf -from meshterm.emulator.framebuffer import KeyState +from meshterm.emulator.framebuffer import Framebuffer, KeyState from meshterm.emulator.keys import Key, encode from meshterm.emulator.raster import PANEL_H, PANEL_W, Raster, rgb565 from meshterm.emulator.run import panel_output, run @@ -220,6 +222,28 @@ def test_the_raster_draws_a_cell_in_its_colours() -> None: assert raster.update() == [(raster.top + 13 * 12, raster.top + 14 * 12)] +@pytest.mark.skipif(not hasattr(mmap, "MAP_SHARED"), reason="the panel is Linux's") +def test_the_panel_is_written_through_its_mapping(tmp_path) -> None: + """Rows land at their stride through the map: the panel redraws only what the map dirties. + + A ``pwrite`` to the Cardputer's ``/dev/fb0`` reaches the buffer and never the glass, so + MeshTerm ran behind the launcher's loading screen. A plain file stands in for the + device; what is pinned is that the bytes go through the mapping, in the right place. + """ + path = tmp_path / "fb0" + path.write_bytes(bytes(8 * 4)) + fb = Framebuffer(str(path), width=4, height=4) # no sysfs here: stride is width * 2 + write = os.pwrite + try: + os.pwrite = lambda *a: pytest.fail("the panel must not be written with pwrite") # type: ignore[assignment] + image = bytes(range(32)) + fb.write(image, 8, 1, 3) + finally: + os.pwrite = write # type: ignore[assignment] + fb.close() + assert path.read_bytes() == bytes(8) + image[8:24] + bytes(8) + + # --- a whole run ----------------------------------------------------------------------