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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-05 17:41:07 -04:00
1 parent db4cc657fe
commit c78a802ac6
5 files changed
+206 -8

No files matched your search

+14
View File
@@ -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
+28 -6
View File
@@ -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)
+1 -1
View File
@@ -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`) |
+138
View File
@@ -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())
+25 -1
View File
@@ -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 ----------------------------------------------------------------------