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:
1 parent
db4cc657fe
commit
c78a802ac6
5 files changed
+206
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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
@@ -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 ----------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user