Files
MeshTerm/scripts/cardputer-zero/type-text.py
T
Jean-Pierre MartineauandClaude Opus 5.5 c78a802ac6 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>
2026-10-05 17:41:07 -04:00

139 lines
5.4 KiB
Python

#!/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())