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>
269 lines
10 KiB
Python
269 lines
10 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""On the Cardputer Zero: pixels to the framebuffer, keys from the keyboard's event device.
|
||
|
||
The launcher starts an app with the screen to itself and tells it where things are:
|
||
``APPLAUNCH_LINUX_FBDEV_DEVICE`` names the panel's framebuffer and
|
||
``APPLAUNCH_LINUX_KEYBOARD_DEVICE`` its keyboard, which it shares rather than grabs — so the
|
||
app reads key events itself, while the launcher watches the same stream for a held Esc
|
||
(3 s, then SIGTERM to the app's process group). This front end follows that contract.
|
||
|
||
**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. **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
|
||
as the sticky Shift is armed. Sym is the exception: its layer sends *placeholder* key codes
|
||
(``KEY_LEFTBRACE`` for ``!``, …) that M5's own keymap gives their symbols, so this module
|
||
gives them the same ones, and is the only place that knows.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import mmap
|
||
import os
|
||
import signal
|
||
import struct
|
||
import threading
|
||
from collections.abc import Callable
|
||
from pathlib import Path
|
||
|
||
from ..services import modifier_watch
|
||
from .font import Font
|
||
from .keys import Key, encode
|
||
from .raster import Raster, rgb565
|
||
from .run import DEFAULT_BG, DEFAULT_FG
|
||
from .vt import Terminal
|
||
|
||
FB_ENV = "APPLAUNCH_LINUX_FBDEV_DEVICE"
|
||
KEYBOARD_ENV = "APPLAUNCH_LINUX_KEYBOARD_DEVICE"
|
||
#: Where the launcher's own default puts the keyboard, when no variable says.
|
||
DEFAULT_KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event"
|
||
|
||
#: ``struct input_event`` on a 64-bit kernel: timeval, then type, code, value.
|
||
_EVENT = struct.Struct("llHHi")
|
||
_EV_KEY = 0x01
|
||
|
||
# Modifier key codes (linux/input-event-codes.h).
|
||
_SHIFT = {42, 54}
|
||
_CTRL = {29, 97}
|
||
_ALT = {56, 100}
|
||
|
||
#: Key codes that are not text, by the name :mod:`.keys` spells them.
|
||
_NAMED: dict[int, str] = {
|
||
1: "escape",
|
||
14: "backspace",
|
||
15: "tab",
|
||
28: "enter",
|
||
96: "enter",
|
||
102: "home",
|
||
103: "up",
|
||
104: "pageup",
|
||
105: "left",
|
||
106: "right",
|
||
107: "end",
|
||
108: "down",
|
||
109: "pagedown",
|
||
110: "insert",
|
||
111: "delete",
|
||
**{59 + n: f"f{n + 1}" for n in range(10)}, # KEY_F1..KEY_F10
|
||
87: "f11",
|
||
88: "f12",
|
||
}
|
||
|
||
#: The base layer's characters.
|
||
_TEXT: dict[int, str] = {
|
||
**{2 + n: "1234567890"[n] for n in range(10)},
|
||
**{16 + n: "qwertyuiop"[n] for n in range(10)},
|
||
**{30 + n: "asdfghjkl"[n] for n in range(9)},
|
||
**{44 + n: "zxcvbnm"[n] for n in range(7)},
|
||
57: " ",
|
||
}
|
||
|
||
#: The Sym layer's placeholder codes and the characters M5's console keymap gives them.
|
||
_SYM: dict[int, str] = {
|
||
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
|
||
|
||
|
||
class KeyState:
|
||
"""Turns the keyboard's events into :class:`~.keys.Key` presses, tracking modifiers."""
|
||
|
||
def __init__(self) -> None:
|
||
"""Start with no modifier held."""
|
||
self._held: set[int] = set()
|
||
|
||
@property
|
||
def shift(self) -> bool:
|
||
"""Whether a Shift key is held — or armed, which the driver reports the same way."""
|
||
return bool(self._held & _SHIFT)
|
||
|
||
def event(self, code: int, value: int) -> Key | None:
|
||
"""One ``EV_KEY`` event (``value`` 1 down, 2 repeat, 0 up): the press it makes."""
|
||
if code in _SHIFT | _CTRL | _ALT:
|
||
if value:
|
||
self._held.add(code)
|
||
else:
|
||
self._held.discard(code)
|
||
return None
|
||
if value == 0:
|
||
return None
|
||
shift = self.shift
|
||
ctrl = bool(self._held & _CTRL)
|
||
alt = bool(self._held & _ALT)
|
||
name = _NAMED.get(code)
|
||
if name is not None:
|
||
return Key(name=name, shift=shift, ctrl=ctrl, alt=alt)
|
||
text = _SYM.get(code) or _TEXT.get(code)
|
||
if text is None:
|
||
return None # Fn and Sym themselves, media keys, brightness: nothing to type
|
||
if shift and text.isalpha():
|
||
text = text.upper()
|
||
return Key(text=text, ctrl=ctrl, alt=alt)
|
||
|
||
|
||
class Framebuffer:
|
||
"""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 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
|
||
try:
|
||
return node.read_text().strip()
|
||
except OSError:
|
||
return None
|
||
|
||
def write(self, pixels: bytes | bytearray, row_bytes: int, first: int, last: int) -> None:
|
||
"""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)):
|
||
start = y * self.stride
|
||
self._map[start : start + row_bytes] = view[y * row_bytes : (y + 1) * row_bytes]
|
||
|
||
def close(self) -> None:
|
||
"""Unmap and close the framebuffer."""
|
||
self._map.close()
|
||
os.close(self._fd)
|
||
|
||
|
||
class Device:
|
||
"""The front end on the device: a framebuffer writer and a keyboard reader."""
|
||
|
||
def __init__(
|
||
self,
|
||
terminal: Terminal,
|
||
lock: threading.Lock,
|
||
type_text: Callable[[str], None],
|
||
*,
|
||
font: Font,
|
||
framebuffer: str,
|
||
keyboard: str,
|
||
) -> None:
|
||
"""Open the framebuffer, and start the drawing and key-reading threads."""
|
||
self._lock = lock
|
||
self._type = type_text
|
||
self._raster = Raster(
|
||
terminal, font, pack=rgb565, default_fg=DEFAULT_FG, default_bg=DEFAULT_BG
|
||
)
|
||
self._fb = Framebuffer(framebuffer, width=self._raster.width, height=self._raster.height)
|
||
self._keyboard = keyboard
|
||
self._ready = threading.Event()
|
||
self._closing = False
|
||
threading.Thread(target=self._draw, name="cardputer-fb", daemon=True).start()
|
||
threading.Thread(target=self._read_keys, name="cardputer-keys", daemon=True).start()
|
||
|
||
def frame_ready(self) -> None:
|
||
"""Wake the drawing thread: a frame is complete."""
|
||
self._ready.set()
|
||
|
||
def close(self) -> None:
|
||
"""Stop both threads."""
|
||
self._closing = True
|
||
self._ready.set()
|
||
|
||
def _draw(self) -> None:
|
||
while not self._closing:
|
||
self._ready.wait()
|
||
self._ready.clear()
|
||
if self._closing:
|
||
break
|
||
with self._lock:
|
||
bands = self._raster.update()
|
||
pixels = bytes(self._raster.pixels)
|
||
for first, last in bands:
|
||
self._fb.write(pixels, self._raster.stride, first, last)
|
||
self._fb.close()
|
||
|
||
def _read_keys(self) -> None:
|
||
state = KeyState()
|
||
with open(self._keyboard, "rb", buffering=0) as stream:
|
||
while not self._closing:
|
||
data = stream.read(_EVENT.size)
|
||
if len(data) < _EVENT.size:
|
||
break
|
||
_, _, kind, code, value = _EVENT.unpack(data)
|
||
if kind != _EV_KEY:
|
||
continue
|
||
was_shifted = state.shift
|
||
key = state.event(code, value)
|
||
if state.shift != was_shifted:
|
||
modifier_watch.report_shift(state.shift)
|
||
if key is not None:
|
||
sequence = encode(key)
|
||
if sequence:
|
||
self._type(sequence)
|
||
|
||
|
||
def _leave_on_sigterm() -> None:
|
||
"""Treat the launcher's SIGTERM (a held Esc) as an interrupt, so MeshTerm cleans up."""
|
||
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||
|
||
|
||
def front_end():
|
||
"""The device as a :data:`~.run.FrontEndFactory`, from the launcher's environment."""
|
||
from .font import find_font
|
||
|
||
font = find_font()
|
||
framebuffer = os.environ.get(FB_ENV) or "/dev/fb0"
|
||
keyboard = os.environ.get(KEYBOARD_ENV) or DEFAULT_KEYBOARD
|
||
_leave_on_sigterm()
|
||
|
||
def build(terminal: Terminal, lock: threading.Lock, type_text: Callable[[str], None]):
|
||
return Device(
|
||
terminal, lock, type_text, font=font, framebuffer=framebuffer, keyboard=keyboard
|
||
)
|
||
|
||
return build
|