Files
Jean-Pierre MartineauandClaude Opus 5.5 99b991707e Comments of the scripts and the packaging files are in ASD-STE100
The Python scripts, the packaging code, and the full-line comments of the
shell scripts. Heredocs, marker lines, licence lines, and code do not
change. The help text of type-text.py now starts with a complete STE
sentence, and uart1-mux.py now says three writes, as the code does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 21:37:03 -04:00

149 lines
5.7 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Type text on the keyboard of a Cardputer Zero from another computer.
A long string, such as an API key or a Wi-Fi password, is difficult to enter on a keyboard
with 46 keys and sticky modifiers. This script sends the text over SSH and presses the keys
for you. It writes key events into the keyboard device of the Cardputer. Thus the program
that has the screen (the launcher, MeshTerm, or any app of M5) gets the keys in the same
way as if a person typed them.
python scripts/cardputer-zero/type-text.py --host pi@192.168.0.165 -i ~/.ssh/id_ed25519
The script asks for the text and hides what you type. Paste the text, then press Enter. A
secret never shows on the screen. It never enters your shell history. It never appears on a
command line, where the process list can show it, because it goes over SSH on standard
input. Piped input also works (``... | type-text.py``). ``--enter`` presses Enter on the
Cardputer after the text.
The script can type only the characters that the keyboard has keys for: letters, digits,
space, and the symbols on the Sym layer. The symbols go out as the codes that the keymap of
M5 gives them. The script refuses any other character before it presses a key.
Necessary: ``ssh`` on this computer, and a Cardputer user in the ``input`` group (the
default user ``pi`` is in this group).
"""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import subprocess
import sys
#: The event device of the keyboard, in the place that the launcher gives.
KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event"
#: The key codes (linux/input-event-codes.h) of the characters that the keyboard types
#: without Shift.
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 that its keys send. The keymap of M5
#: (``/usr/share/keymaps/tca8418_keypad_m5stack_keymap.map``) changes each code to a symbol.
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
#: This program runs on the Cardputer, under its own Python. It presses each key, and 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 that ``text`` uses.
If a character has no key, the function exits and names the characters.
"""
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: from a pipe, or from a prompt.
The prompt hides the text unless ``show`` is true.
"""
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 only letters and digits. Thus no quote in the
# program or in 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())