Files
MeshTerm/meshterm/emulator/framebuffer.py
T
Jean-Pierre MartineauandClaude Opus 5.5 3e251a9707 Docstrings and comments of meshterm/emulator are in ASD-STE100
The same rewrite as for the core package. The physical screen of a
handheld is now the display, and the computer is the handheld. The
comment on FONT_ENV now says that it is the environment variable.

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

293 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""On the Cardputer Zero: pixels to the framebuffer, key events from the keyboard's device file.
The launcher starts an app with the screen for that app only, and tells it where things
are. ``APPLAUNCH_LINUX_FBDEV_DEVICE`` names the framebuffer of the display, and
``APPLAUNCH_LINUX_KEYBOARD_DEVICE`` names its keyboard. The launcher shares the keyboard
and does not grab it. Thus the app reads the key events itself, while the launcher
watches the same stream for a held Esc key (3 s, then SIGTERM to the process group of the
app, and SIGKILL 3 s after that).
This front end obeys that contract. The Esc key goes through
:mod:`~meshterm.services.hold_to_quit`, which shows the hold on the display, where the
launcher does not draw any more. The front end takes the SIGTERM as a quit, because that is
what it is (:func:`~meshterm.services.hold_to_quit.leave_on_sigterm`).
**Written before the hardware was available** (2026-09-30), from the published sources of
M5:
* the framebuffer and keyboard paths, and the Esc policy, from the launcher,
* the key codes, from the keymaps of the keyboard driver, and
* the characters of the Sym layer, from the console keymap of M5
(``tca8418_keypad_m5stack_keymap.map``).
The key mapping has unit tests. **Run on the handheld since 2026-10-05.** There, the key
codes and the Sym layer agreed with the keyboard and with the installed keymap of M5. We
also found there that the display must get its pixels through a memory mapping
(:class:`Framebuffer`).
The keyboard driver does the Fn layer in the kernel. Thus Fn+4 arrives as a plain
``KEY_F4``, and the arrows arrive as real arrow keys. Shift is a real ``KEY_LEFTSHIFT``
that stays down while the sticky Shift is armed. Sym is the exception. Its layer sends
placeholder key codes (``KEY_LEFTBRACE`` for ``!``, …), and the keymap of M5 gives them
their symbols. This module gives them the same symbols, and it is the only place in
MeshTerm that knows them.
"""
from __future__ import annotations
import mmap
import os
import struct
import threading
from collections.abc import Callable
from pathlib import Path
from ..services import hold_to_quit, 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"
#: The keyboard device file in the launcher's own default, when no variable names one.
DEFAULT_KEYBOARD = "/dev/input/by-path/platform-3f804000.i2c-event"
#: ``struct input_event`` on a 64-bit kernel: timeval, then type, code, and value.
_EVENT = struct.Struct("llHHi")
_EV_KEY = 0x01
#: ``KEY_ESC``. When it is held, it is the launcher's way out (refer to
#: :mod:`~meshterm.services.hold_to_quit`).
_ESC = 1
# Modifier key codes (linux/input-event-codes.h).
_SHIFT = {42, 54}
_CTRL = {29, 97}
_ALT = {56, 100}
#: Key codes that are not text, mapped to the names that :mod:`.keys` spells them with.
_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 characters of the base layer.
_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 placeholder codes of the Sym layer, and the characters that the console keymap of
#: M5 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:
"""Changes keyboard events into :class:`~.keys.Key` presses, and tracks the 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. The driver reports the two in the same way."""
return bool(self._held & _SHIFT)
def event(self, code: int, value: int) -> Key | None:
"""The key press that one ``EV_KEY`` event makes (``value`` 1 down, 2 repeat, 0 up)."""
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 display's framebuffer, written a band of pixel rows at a time through a memory mapping.
**Through a memory mapping, never ``write()``.** The display has a DRM driver
(``panel-mipi-dbi``), and its ``/dev/fb0`` is the fbdev emulation of the kernel. That
is a buffer that the kernel copies to the display only where it knows that something
changed. What the kernel watches is the memory mapping. A write through the mapping
causes a page fault, and the kernel sends that page at the next update of the display.
Bytes from ``pwrite`` go into the same buffer, and a read of ``/dev/fb0`` shows them.
But they never get to the display. On the handheld, MeshTerm ran while the loading
screen of the launcher stayed visible over it, and the user quit a splash that they
could not see. The apps of M5 also draw through the memory mapping (LVGL's
``lv_linux_fbdev``). We found this 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`` display."""
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 is the display
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 handheld: 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, then start two threads: one draws, one reads the keyboard."""
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 thread that draws: 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()
esc = hold_to_quit.EscKey(self._type)
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
if code == _ESC:
# Down and its repeats are one hold. A tap is typed when it comes up.
if value:
esc.down()
else:
esc.up()
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:
esc.before()
self._type(sequence)
def front_end():
"""The handheld 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
# From here, not only from the CLI: the launcher's SIGTERM can arrive while MeshTerm
# still imports its modules. Then MeshTerm must stop as an interrupt, not unhandled.
hold_to_quit.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