Compare commits

...

2 Commits

Author SHA1 Message Date
78316556d4 docs(remote-ui): converged dashboard implementation plan (ref #135)
Some checks are pending
License Headers / check (push) Waiting to run
23-task TDD plan for extracting secubox_common from round/fb_dashboard.py
and square/Phase-3 kiosk, adding pointer input on Pi 4B/400, and shipping
both form factors as one PR. Gate: PR #134 (framebuffer numpy+center-pad)
must merge before Task 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:26:13 +02:00
CyberMind
a3a918ed6f
remote-ui/square: 4 bugs caught at Pi 4B hardware bench (Phase 3 followup, ref #127) (#134)
* fix(remote-ui/square): 4 bugs caught at Pi 4B hardware bench (closes #133, ref #127)

Phase 3 (#132) merged with 82/82 pytest green and two-stage subagent
review on every task — but the kiosk crashed at hardware boot because
the review loop had no real /dev/fb0. All four fixes validated live
on a Pi 4B + official 7" DSI panel this session by hand-patching the
uSD, then ported here.

(1) /run/secubox not recreated on reboot
    Add remote-ui/square/files/etc/tmpfiles.d/secubox-eye-square.conf
    creating /run/secubox + /var/log/secubox at boot under the
    secubox-eye-square user. /run is tmpfs and the build-time mkdir
    didn't persist, so secubox-eye-square-helper.service crashed on
    Path.mkdir() at startup, which cascaded into the kiosk's
    HelperClient timeout-loop.

(2) fonts-dejavu-core missing from chroot apt-install
(3) draw.text() calls lacked font= argument
    Pillow on Bookworm (9.4) falls back to a latin-1 bitmap font when
    no font is passed. ring_dashboard.py draws "○ NOMINAL" (U+25CB)
    which raised UnicodeEncodeError. Fix on both ends: install
    fonts-dejavu-core in the chroot, expose theme.DEFAULT_FONT loaded
    from /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf, and pass
    font=theme.DEFAULT_FONT to every draw.text() in the kiosk
    (25 call sites across 6 files — confirmed by AST walk).

(4) framebuffer.py hardcoded 32bpp BGRA but vc4drmfb is 16bpp RGB565
    The Pi 4B 7" DSI exposes /dev/fb0 as DRM_FORMAT_RGB565
    (16bpp, R in top 5 bits). framebuffer.py wrote 800*480*4=1.5MB
    of BGRA bytes into a 768KB fb. mmap silently truncated, dd ...
    of=/dev/fb0 confirmed smem_len=768KB by erroring "No space left
    on device" at exactly that offset. Pillow's RGB→RGB565 raw
    packers were removed in Pillow >=9.4 (tested 9.4 and 10.2,
    same behaviour), so we pack via numpy:
        pixels = ((R & 0xF8) << 8) | ((G & 0xFC) << 3) | (B >> 3)
        pixels.astype("<u2").tobytes()
    Auto-detect bpp from /sys/class/graphics/<dev>/bits_per_pixel;
    for 32bpp paths, FBIOGET_VSCREENINFO ioctl picks the right
    Pillow raw mode (BGRA / RGBA / ARGB / ABGR). EYE_SQUARE_FB_MODE
    env var overrides for diagnostics. Adds python3-numpy to the
    chroot apt-install list.

Tests
- test_framebuffer.py rewritten: drops bpp= kwarg (now auto-detected),
  adds tests for env override, sysfs bpp=16 detection picking RGB565,
  numpy pack of pure red → 0xF800 (R in top 5 bits, LE bytes), and
  pure black → 0x0000.
- New test_theme.py: palette tuple shape + DEFAULT_FONT loads +
  Unicode glyph (U+25CB) rendering smoke.
- 68/68 kiosk tests green (was 61 + 7 new). Helper untouched.

Hardware validation (Task 23 / #127)
Same uSD with these patches renders correctly on Pi 4B + 7" DSI:
black background, six distinct ring colors (AUTH orange, WALL gold,
BOOT brown, MIND blue, ROOT teal, MESH blue), Unicode dot in tab
labels, frame-budget headroom (numpy pack ~5-10ms on Pi 4B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(remote-ui/square): also auto-detect fb size, center-pad for HDMI on Pi 400 (ref #133)

Caught at Pi 400 + HDMI bench. The Pi 400 doesn't ship with the official
7" DSI panel, so /dev/fb0 is exposed at the HDMI monitor's native
resolution (e.g. 1920x1080) instead of 800x480. The kiosk hardcodes
800x480 and writes 800-wide rows; against a 1920-wide fb the writes
get sliced and tiled, producing rainbow stripes across the top.

Fix: in addition to the bpp + byte-order detection from the first
commit, read /sys/class/graphics/<dev>/virtual_size at __init__.
Mmap to actual fb size (not logical). Center-pad the kiosk's 800x480
canvas into a black canvas of the real fb size inside blit() before
encoding. Kiosk drawing code stays unchanged — same 800x480 design,
just letterboxed on larger displays.

API change
- FrameBuffer signature is now (path, logical_width=800, logical_height=480)
  instead of (path, width=800, height=480). width/height still exist as
  attributes but now hold the ACTUAL fb dimensions, with logical_width /
  logical_height holding the kiosk's design canvas.

Tests
- New autouse fixture _isolate_sysfs monkeypatches _read_sysfs_size to
  return the fallback. Otherwise tests on a host with a real /dev/fb0
  would pick up the laptop's display dimensions and mismatch the tmp
  fb file size.
- New tests:
  * test_fb_size_detection_uses_sysfs_when_available (1920x1080 fake fb)
  * test_blit_pads_into_larger_fb (red square center-painted into 1920x1080)
  * test_blit_no_pad_when_fb_equals_logical (fast path)
- 71/71 kiosk tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:21:49 +02:00
13 changed files with 2905 additions and 56 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,48 +1,175 @@
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/framebuffer.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Direct /dev/fb0 framebuffer blit via mmap.
"""/dev/fb0 blit with bpp + actual-size auto-detect and numpy RGB565 packing.
The Pi 4B's DSI panel exposes /dev/fb0 at 800×480, 32-bit BGRA when the
vc4-kms-v3d overlay is active. We open it once, mmap the full size, and
blit Pillow images into it on each render tick.
The kiosk draws to a fixed 800x480 logical canvas (designed for the Pi 4B
official 7" DSI panel). On other displays — Pi 400 + HDMI monitor, mainly —
the framebuffer is the panel's native resolution (e.g. 1920x1080) and the
kiosk's 800x480 writes get sliced into the wider memory layout. Symptom:
the ring dashboard appears as rainbow stripes tiled across the screen.
Fix: detect the framebuffer's actual width and height at init via
/sys/class/graphics/<dev>/virtual_size; center-pad the 800x480 kiosk frame
into a black canvas of fb size before encoding. Kiosk coordinate system
stays unchanged.
Pillow has no RGB->RGB565 raw packer in versions >=9.4 (removed upstream)
so we vectorise the 16bpp pack via numpy.
EYE_SQUARE_FB_MODE env var overrides the byte-order detection for diagnostics.
"""
from __future__ import annotations
import ctypes
import fcntl
import logging
import mmap
import os
from pathlib import Path
import numpy as np
from PIL import Image
log = logging.getLogger("secubox_eye_square_kiosk.framebuffer")
FBIOGET_VSCREENINFO = 0x4600
class _fb_bitfield(ctypes.Structure):
_fields_ = [("offset", ctypes.c_uint32),
("length", ctypes.c_uint32),
("msb_right", ctypes.c_uint32)]
class _fb_var_screeninfo(ctypes.Structure):
_fields_ = [
("xres", ctypes.c_uint32), ("yres", ctypes.c_uint32),
("xres_virtual", ctypes.c_uint32), ("yres_virtual", ctypes.c_uint32),
("xoffset", ctypes.c_uint32), ("yoffset", ctypes.c_uint32),
("bits_per_pixel", ctypes.c_uint32), ("grayscale", ctypes.c_uint32),
("red", _fb_bitfield), ("green", _fb_bitfield),
("blue", _fb_bitfield), ("transp", _fb_bitfield),
("_pad", ctypes.c_uint8 * 256),
]
_OFFSET_TO_MODE32 = {
(16, 8, 0): "BGRA",
(0, 8, 16): "RGBA",
(8, 16, 24): "ARGB",
(24, 16, 8): "ABGR",
}
def _read_sysfs_bpp(fb_path: str) -> int:
name = os.path.basename(fb_path)
try:
return int(Path(f"/sys/class/graphics/{name}/bits_per_pixel").read_text().strip())
except (OSError, ValueError):
return 32
def _read_sysfs_size(fb_path: str, fallback: tuple[int, int]) -> tuple[int, int]:
"""Read virtual_size as a (width, height) tuple. Fall back if missing
(e.g. unit tests using a regular file as a fake /dev/fb0)."""
name = os.path.basename(fb_path)
try:
s = Path(f"/sys/class/graphics/{name}/virtual_size").read_text().strip()
w, h = s.split(",")
return int(w), int(h)
except (OSError, ValueError):
return fallback
def _detect_mode(fd: int, fb_path: str) -> tuple[str, int]:
override = os.environ.get("EYE_SQUARE_FB_MODE")
bpp_bits = _read_sysfs_bpp(fb_path)
bpp_bytes = max(1, bpp_bits // 8)
log.warning("fb sysfs bpp=%dbits (%d bytes/pixel)", bpp_bits, bpp_bytes)
if override:
log.warning("EYE_SQUARE_FB_MODE override = %s", override)
return override, bpp_bytes
if bpp_bits == 16:
return "RGB565", 2
try:
v = _fb_var_screeninfo()
fcntl.ioctl(fd, FBIOGET_VSCREENINFO, v)
log.warning(
"fb_var_screeninfo %dx%d %dbpp R(%d,%d) G(%d,%d) B(%d,%d) A(%d,%d)",
v.xres, v.yres, v.bits_per_pixel,
v.red.offset, v.red.length,
v.green.offset, v.green.length,
v.blue.offset, v.blue.length,
v.transp.offset, v.transp.length,
)
key = (v.red.offset, v.green.offset, v.blue.offset)
return _OFFSET_TO_MODE32.get(key, "BGRA"), bpp_bytes
except OSError as e:
log.warning("FBIOGET_VSCREENINFO failed (%s)", e)
return "BGRA", bpp_bytes
def _pack_rgb565(image: Image.Image) -> bytes:
"""Pack a Pillow RGB image to RGB565 little-endian bytes via numpy.
DRM_FORMAT_RGB565: R in top 5 bits, G in middle 6, B in low 5.
Stored as little-endian uint16 in memory.
"""
arr = np.asarray(image.convert("RGB"), dtype=np.uint16)
r = arr[:, :, 0]
g = arr[:, :, 1]
b = arr[:, :, 2]
pixels = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
return pixels.astype("<u2").tobytes()
class FrameBuffer:
"""Owns the mmap handle to /dev/fb0. Single-instance-per-process."""
"""Owns the mmap handle to /dev/fb0 and center-pads the kiosk's logical
canvas into the actual fb resolution on every blit."""
def __init__(self, path: str = "/dev/fb0", width: int = 800, height: int = 480, bpp: int = 4):
def __init__(self, path: str = "/dev/fb0",
logical_width: int = 800, logical_height: int = 480):
self.path = path
self.width = width
self.height = height
self.bpp = bpp
self.size = width * height * bpp
self.logical_width = logical_width
self.logical_height = logical_height
self.fd = os.open(path, os.O_RDWR)
self.raw_mode, self.bpp = _detect_mode(self.fd, path)
self.width, self.height = _read_sysfs_size(
path, (logical_width, logical_height)
)
self.size = self.width * self.height * self.bpp
if (self.width, self.height) != (logical_width, logical_height):
log.warning(
"fb actual size %dx%d != kiosk logical %dx%d — will center-pad",
self.width, self.height, logical_width, logical_height,
)
log.warning("fb opened: %s actual=%dx%d logical=%dx%d bpp=%d mode=%s size=%d bytes",
path, self.width, self.height,
logical_width, logical_height, self.bpp, self.raw_mode, self.size)
self.fb = mmap.mmap(self.fd, self.size, mmap.MAP_SHARED, mmap.PROT_WRITE)
def _pad_to_fb(self, image: Image.Image) -> Image.Image:
"""Center-paste the kiosk's logical image into a black canvas of
actual fb size. No-op if sizes already match."""
if image.size == (self.width, self.height):
return image
canvas = Image.new("RGB", (self.width, self.height), (0, 0, 0))
x_off = (self.width - image.size[0]) // 2
y_off = (self.height - image.size[1]) // 2
canvas.paste(image.convert("RGB"), (x_off, y_off))
return canvas
def blit(self, image: Image.Image) -> None:
"""Push a Pillow image to the framebuffer. Image must be RGBA at exact resolution."""
if image.size != (self.width, self.height):
if image.size != (self.logical_width, self.logical_height):
raise ValueError(
f"image size {image.size} doesn't match framebuffer {self.width}x{self.height}"
f"image size {image.size} != logical "
f"{self.logical_width}x{self.logical_height}"
)
# Convert Pillow RGBA → BGRA for vc4-kms-v3d's little-endian BGRA32 layout
bgra = image.tobytes("raw", "BGRA")
padded = self._pad_to_fb(image)
if self.raw_mode == "RGB565":
raw = _pack_rgb565(padded)
else:
raw = padded.tobytes("raw", self.raw_mode)
self.fb.seek(0)
self.fb.write(bgra)
self.fb.write(raw)
def close(self) -> None:
self.fb.close()

View File

@ -82,7 +82,7 @@ class RightPanel:
colour = theme.GOLD_HERMETIC if key == self.active_tab else theme.TEXT_MUTED
draw.rectangle((x, 0, x + TAB_WIDTH, TAB_BAR_HEIGHT - 1),
outline=colour, width=1 if key != self.active_tab else 2)
draw.text((x + 8, 20), label, fill=colour)
draw.text((x + 8, 20), label, fill=colour, font=theme.DEFAULT_FONT)
# Content area
content_h = h - TAB_BAR_HEIGHT
content = Image.new("RGBA", (w, content_h), (0, 0, 0, 255))

View File

@ -122,20 +122,24 @@ class RingDashboard:
# Coloured dot
draw.ellipse((px - 5, py - 5, px + 5, py + 5), fill=m.colour + (255,))
# Module name label below dot
draw.text((px - 16, py + 8), m.name, fill=theme.TEXT_PRIMARY)
draw.text((px - 16, py + 8), m.name, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
# Central clock + hostname
now = datetime.now().strftime("%H:%M:%S")
date = datetime.now().strftime("%a %d %b")
draw.text((CX - 50, CY - 18), now, fill=theme.TEXT_PRIMARY)
draw.text((CX - 30, CY + 4), date, fill=theme.TEXT_MUTED)
draw.text((CX - 70, CY + 22), self.hostname[:18], fill=theme.TEXT_MUTED)
draw.text((CX - 50, CY - 18), now, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
draw.text((CX - 30, CY + 4), date, fill=theme.TEXT_MUTED,
font=theme.DEFAULT_FONT)
draw.text((CX - 70, CY + 22), self.hostname[:18], fill=theme.TEXT_MUTED,
font=theme.DEFAULT_FONT)
# Transport badge top-right
dot = "" if self.transport in ("OTG", "WiFi") else ""
dot_colour = theme.MATRIX_GREEN if dot == "" else theme.TEXT_MUTED
draw.text((CX + 110, TRANSPORT_BADGE_Y), f"{dot} {self.transport}",
fill=dot_colour)
fill=dot_colour, font=theme.DEFAULT_FONT)
# Alerts ribbon — overlay bottom 24px when alert is active
if self._alert_text:
@ -143,6 +147,7 @@ class RingDashboard:
draw.rectangle((0, 480 - ALERT_RIBBON_HEIGHT, 480, 480),
fill=theme.COSMOS_BLACK + (200,))
draw.text((10, 480 - ALERT_RIBBON_HEIGHT + 4),
f"{self._alert_text}"[:50], fill=ribbon_colour)
f"{self._alert_text}"[:50], fill=ribbon_colour,
font=theme.DEFAULT_FONT)
return img

View File

@ -59,7 +59,8 @@ class AlertsTab:
"""Render alerts into the region (320x424 RGBA image)."""
draw = ImageDraw.Draw(region)
if not self.items:
draw.text((10, 10), "● NOMINAL", fill=theme.MATRIX_GREEN)
draw.text((10, 10), "● NOMINAL", fill=theme.MATRIX_GREEN,
font=theme.DEFAULT_FONT)
return
w, h = region.size
for i, item in enumerate(self.items):
@ -73,7 +74,7 @@ class AlertsTab:
)
txt = f"{item.time} {item.module} {item.message}"
draw.text((TEXT_PAD_LEFT, y + 8), txt[:38],
fill=theme.TEXT_PRIMARY)
fill=theme.TEXT_PRIMARY, font=theme.DEFAULT_FONT)
# divider line
draw.line((0, y + ROW_HEIGHT - 1, w, y + ROW_HEIGHT - 1),
fill=theme.TEXT_MUTED)

View File

@ -46,10 +46,12 @@ class ConsoleTab:
visible_rows = (h - 50) // LINE_HEIGHT
for i, line in enumerate(self.lines[-visible_rows:]):
y = TOP_MARGIN + i * LINE_HEIGHT
draw.text((4, y), line[:48], fill=theme.MATRIX_GREEN)
draw.text((4, y), line[:48], fill=theme.MATRIX_GREEN,
font=theme.DEFAULT_FONT)
# Freeze button
btn_label = "Resume" if self.frozen else "Freeze"
btn_fill = theme.GOLD_HERMETIC if self.frozen else theme.TEXT_MUTED
draw.rectangle((BUTTON_X, BUTTON_Y, w - 4, BUTTON_Y + BUTTON_HEIGHT),
outline=btn_fill, width=1)
draw.text((BUTTON_X + 8, BUTTON_Y + 8), btn_label, fill=btn_fill)
draw.text((BUTTON_X + 8, BUTTON_Y + 8), btn_label, fill=btn_fill,
font=theme.DEFAULT_FONT)

View File

@ -100,7 +100,8 @@ class ModeControlsTab:
draw = ImageDraw.Draw(region)
w, _ = region.size
# USB buttons header
draw.text((10, 16), "USB GADGET MODE", fill=theme.GOLD_HERMETIC)
draw.text((10, 16), "USB GADGET MODE", fill=theme.GOLD_HERMETIC,
font=theme.DEFAULT_FONT)
for i, mode in enumerate(USB_BUTTONS):
row = i // 3
col = i % 3
@ -109,10 +110,11 @@ class ModeControlsTab:
colour = theme.CINNABAR if mode in DESTRUCTIVE else theme.TEXT_PRIMARY
draw.rectangle((x, y, x + CELL_W - 5, y + CELL_H - 5),
outline=colour, width=1)
draw.text((x + 8, y + 24), mode.upper(), fill=colour)
draw.text((x + 8, y + 24), mode.upper(), fill=colour,
font=theme.DEFAULT_FONT)
# Service buttons
draw.text((10, SERVICE_ROW_Y - 24), "SECUBOX SERVICE",
fill=theme.GOLD_HERMETIC)
fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
for i, (_, label) in enumerate(SERVICE_BUTTONS):
row = i // 2
col = i % 2
@ -121,18 +123,20 @@ class ModeControlsTab:
colour = theme.CINNABAR if SERVICE_BUTTONS[i][0] in DESTRUCTIVE else theme.TEXT_PRIMARY
draw.rectangle((x, y, x + w // 2 - 15, y + CELL_H - 5),
outline=colour, width=1)
draw.text((x + 8, y + 24), label, fill=colour)
draw.text((x + 8, y + 24), label, fill=colour,
font=theme.DEFAULT_FONT)
# Transport
draw.text((10, TRANSPORT_ROW_Y - 24), "TRANSPORT",
fill=theme.GOLD_HERMETIC)
fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
dot = "" if self.transport_active in ("OTG", "WiFi") else ""
draw.text((10, TRANSPORT_ROW_Y), f"{dot} {self.transport_active}",
fill=theme.MATRIX_GREEN if dot == "" else theme.TEXT_MUTED)
fill=theme.MATRIX_GREEN if dot == "" else theme.TEXT_MUTED,
font=theme.DEFAULT_FONT)
# Confirm overlay
if self.pending_confirm:
draw.rectangle((10, 100, w - 10, 200), fill=theme.COSMOS_BLACK,
outline=theme.CINNABAR, width=2)
draw.text((20, 120), f"Confirm {self.pending_confirm}?",
fill=theme.CINNABAR)
fill=theme.CINNABAR, font=theme.DEFAULT_FONT)
draw.text((20, 150), "Tap again to confirm",
fill=theme.TEXT_MUTED)
fill=theme.TEXT_MUTED, font=theme.DEFAULT_FONT)

View File

@ -43,13 +43,15 @@ class ModuleDetailTab:
draw = ImageDraw.Draw(region)
w, h = region.size
if not self.module_name:
draw.text((w // 2 - 50, h // 2), "(no module)", fill=theme.TEXT_MUTED)
draw.text((w // 2 - 50, h // 2), "(no module)",
fill=theme.TEXT_MUTED, font=theme.DEFAULT_FONT)
return
# Title bar
draw.text((w // 2 - 30, TITLE_Y), self.module_name,
fill=theme.GOLD_HERMETIC)
draw.text((10, METRIC_Y), self.metric, fill=theme.TEXT_PRIMARY)
fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
draw.text((10, METRIC_Y), self.metric, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
# Gauge (clamped 0..100)
clamped = max(0.0, min(100.0, self.value))
@ -59,7 +61,7 @@ class ModuleDetailTab:
draw.rectangle((10, GAUGE_Y, 10 + fill_w, GAUGE_Y + GAUGE_HEIGHT),
fill=theme.CYBER_CYAN)
draw.text((10, GAUGE_Y + GAUGE_HEIGHT + 4), f"{self.value:.1f}",
fill=theme.TEXT_PRIMARY)
fill=theme.TEXT_PRIMARY, font=theme.DEFAULT_FONT)
# Sparkline
if len(self.history) >= 2:
@ -76,4 +78,4 @@ class ModuleDetailTab:
# Service status
draw.text((10, SERVICE_Y), f"Service: {self.service_status}",
fill=theme.TEXT_PRIMARY)
fill=theme.TEXT_PRIMARY, font=theme.DEFAULT_FONT)

View File

@ -30,3 +30,17 @@ SEVERITY = {
"warn": GOLD_HERMETIC,
"crit": CINNABAR,
}
# Default font for all draw.text() calls in the kiosk. Pillow's
# load_default() on Bookworm is a latin-1 bitmap font that crashes on
# Unicode glyphs (○ ● ▶ ⚠). Loading DejaVuSans explicitly — apt
# dep python3-pil + fonts-dejavu-core (added in the same fix). Falls
# back to load_default() if the TTF isn't present (e.g. unit tests on
# a host without fonts-dejavu-core).
from PIL import ImageFont as _ImageFont # noqa: E402
_DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
try:
DEFAULT_FONT = _ImageFont.truetype(_DEJAVU, 12)
except OSError:
DEFAULT_FONT = _ImageFont.load_default()

View File

@ -1,7 +1,8 @@
# packages/secubox-eye-square/kiosk/tests/test_framebuffer.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Tests for framebuffer.py — mmap blit. Uses a tmpfs file as fake /dev/fb0."""
"""Tests for framebuffer.py — bpp + size auto-detect, RGB565 numpy pack,
and center-padding when the fb is larger than the kiosk's logical canvas."""
from __future__ import annotations
from pathlib import Path
@ -9,39 +10,164 @@ from pathlib import Path
import pytest
from PIL import Image
from secubox_eye_square_kiosk import framebuffer as fb_mod
from secubox_eye_square_kiosk.framebuffer import FrameBuffer
@pytest.fixture(autouse=True)
def _isolate_sysfs(monkeypatch):
"""Tests use fake fb files outside /dev/ — _read_sysfs_size would
otherwise pick up the laptop's real /sys/class/graphics/fb0/virtual_size
(e.g. 1920x1080) and break mmap sizing against the tmp fixture."""
monkeypatch.setattr(fb_mod, "_read_sysfs_size",
lambda _path, fallback: fallback)
yield
@pytest.fixture
def fake_fb(tmp_path: Path) -> Path:
"""Create a 800×480×4 bytes file simulating /dev/fb0 BGRA32."""
def fake_fb_32bpp(tmp_path: Path) -> Path:
"""800x480 fb file (BGRA32). Sysfs is absent for the tmp path so
detection falls back to logical defaults width=height match."""
path = tmp_path / "fb0"
path.write_bytes(b"\x00" * (800 * 480 * 4))
return path
def test_open_and_size(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
@pytest.fixture
def fake_fb_16bpp_logical(tmp_path: Path) -> Path:
"""800x480 fb file in 16bpp."""
path = tmp_path / "fb0"
path.write_bytes(b"\x00" * (800 * 480 * 2))
return path
@pytest.fixture
def fake_fb_16bpp_hdmi(tmp_path: Path) -> Path:
"""1920x1080 fb file in 16bpp — simulates Pi 400 + HDMI monitor."""
path = tmp_path / "fb0"
path.write_bytes(b"\x00" * (1920 * 1080 * 2))
return path
def test_open_and_size_32bpp_default_logical(fake_fb_32bpp: Path):
"""When sysfs is unreadable, FrameBuffer falls back to logical 800x480."""
fb = FrameBuffer(path=str(fake_fb_32bpp))
assert fb.width == 800
assert fb.height == 480
assert fb.bpp == 4
assert fb.size == 800 * 480 * 4
assert fb.raw_mode == "BGRA"
fb.close()
def test_blit_writes_image_bytes(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
def test_blit_writes_bgra_bytes_at_32bpp(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb_32bpp))
img = Image.new("RGBA", (800, 480), color=(255, 0, 0, 255)) # red
fb.blit(img)
fb.close()
raw = fake_fb.read_bytes()
# First pixel: BGRA → blue=0, green=0, red=255, alpha=255
raw = fake_fb_32bpp.read_bytes()
# First pixel: BGRA → blue=0, green=0, red=255, alpha=255 (alpha from black fill)
assert raw[:4] == b"\x00\x00\xff\xff"
def test_blit_wrong_size_raises(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
def test_blit_wrong_logical_size_raises(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb_32bpp))
img = Image.new("RGBA", (100, 100), color=(0, 0, 0, 255))
with pytest.raises(ValueError, match="image size"):
fb.blit(img)
fb.close()
def test_env_override_picks_explicit_mode(monkeypatch, fake_fb_32bpp: Path):
monkeypatch.setenv("EYE_SQUARE_FB_MODE", "RGBA")
fb = FrameBuffer(path=str(fake_fb_32bpp))
assert fb.raw_mode == "RGBA"
fb.close()
def test_detection_picks_rgb565_when_sysfs_reports_16bpp(
monkeypatch, fake_fb_16bpp_logical: Path
):
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
fb = FrameBuffer(path=str(fake_fb_16bpp_logical))
assert fb.bpp == 2
assert fb.raw_mode == "RGB565"
assert fb.size == 800 * 480 * 2
fb.close()
def test_blit_rgb565_packs_via_numpy(monkeypatch, fake_fb_16bpp_logical: Path):
"""RGB565 pure-red pack: pixel = 0xF800, LE bytes [0x00, 0xF8]."""
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
fb = FrameBuffer(path=str(fake_fb_16bpp_logical))
img = Image.new("RGB", (800, 480), color=(0xFF, 0x00, 0x00))
fb.blit(img)
fb.close()
raw = fake_fb_16bpp_logical.read_bytes()
assert raw[:2] == b"\x00\xf8"
# Pure-red on every pixel
assert raw[800 * 480 * 2 - 2 : 800 * 480 * 2] == b"\x00\xf8"
def test_blit_rgb565_black_packs_to_zero(monkeypatch, fake_fb_16bpp_logical: Path):
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
fb = FrameBuffer(path=str(fake_fb_16bpp_logical))
fb.blit(Image.new("RGB", (800, 480), color=(0, 0, 0)))
fb.close()
assert fake_fb_16bpp_logical.read_bytes()[:2] == b"\x00\x00"
# ---- center-pad tests (Pi 400 HDMI) ----
def test_fb_size_detection_uses_sysfs_when_available(
monkeypatch, fake_fb_16bpp_hdmi: Path
):
"""Simulate a 1920x1080 HDMI fb. With the sysfs reader forced to that
size, the kiosk's 800x480 logical canvas should be center-padded."""
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
monkeypatch.setattr(fb_mod, "_read_sysfs_size", lambda *_: (1920, 1080))
fb = FrameBuffer(path=str(fake_fb_16bpp_hdmi))
assert fb.width == 1920
assert fb.height == 1080
assert fb.logical_width == 800
assert fb.logical_height == 480
assert fb.size == 1920 * 1080 * 2
fb.close()
def test_blit_pads_into_larger_fb(monkeypatch, fake_fb_16bpp_hdmi: Path):
"""Kiosk renders 800x480 red square; fb is 1920x1080. Expected: the
center 800x480 rows hold red pixels (0xF800), the outer rows are
black (0x0000). The center starts at row (1080-480)/2 = 300 and
column (1920-800)/2 = 560."""
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
monkeypatch.setattr(fb_mod, "_read_sysfs_size", lambda *_: (1920, 1080))
fb = FrameBuffer(path=str(fake_fb_16bpp_hdmi))
img = Image.new("RGB", (800, 480), color=(0xFF, 0x00, 0x00))
fb.blit(img)
fb.close()
raw = fake_fb_16bpp_hdmi.read_bytes()
# Top-left pixel = black padding
assert raw[:2] == b"\x00\x00"
# Last pixel of first row = black padding
assert raw[1919 * 2 : 1920 * 2] == b"\x00\x00"
# Center pixel (row 540, col 960) should be red (in the padded region)
center_offset = (540 * 1920 + 960) * 2
assert raw[center_offset : center_offset + 2] == b"\x00\xf8"
def test_blit_no_pad_when_fb_equals_logical(
monkeypatch, fake_fb_16bpp_logical: Path
):
"""sysfs reports 800x480 → no padding, fast path."""
monkeypatch.setattr(fb_mod, "_read_sysfs_bpp", lambda _: 16)
monkeypatch.setattr(fb_mod, "_read_sysfs_size", lambda *_: (800, 480))
fb = FrameBuffer(path=str(fake_fb_16bpp_logical))
assert fb.width == 800 and fb.height == 480
img = Image.new("RGB", (800, 480), color=(0xFF, 0x00, 0x00))
fb.blit(img)
fb.close()
raw = fake_fb_16bpp_logical.read_bytes()
# Every pixel red — no padding
assert raw[:2] == b"\x00\xf8"
assert raw[-2:] == b"\x00\xf8"

View File

@ -0,0 +1,47 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Tests for theme.py — palette + default font loading."""
from __future__ import annotations
from PIL import ImageDraw, ImageFont, Image
from secubox_eye_square_kiosk import theme
def test_palette_tuples_are_3_channel_rgb():
"""All module + token colors are (R, G, B) byte tuples."""
for name in (
"AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH",
"COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR", "MATRIX_GREEN",
"CYBER_CYAN", "VOID_PURPLE", "TEXT_PRIMARY", "TEXT_MUTED",
):
c = getattr(theme, name)
assert isinstance(c, tuple)
assert len(c) == 3
assert all(isinstance(b, int) and 0 <= b <= 255 for b in c)
def test_default_font_loads_and_is_usable():
"""theme.DEFAULT_FONT must be a Pillow ImageFont able to render Unicode.
On hosts without fonts-dejavu-core, theme.py falls back to
ImageFont.load_default() still a usable font, just no Unicode.
"""
assert isinstance(theme.DEFAULT_FONT, ImageFont.ImageFont) or hasattr(
theme.DEFAULT_FONT, "getbbox"
)
# Smoke test: draw via the font without exploding
img = Image.new("RGB", (60, 20), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((2, 2), "AUTH", fill=(255, 255, 255), font=theme.DEFAULT_FONT)
def test_default_font_renders_unicode_when_dejavu_available(tmp_path):
"""If fonts-dejavu-core is installed (e.g. on the target image),
drawing should not raise UnicodeEncodeError the bug from #133.
Test is best-effort: if the test runner doesn't have DejaVu we skip
the Unicode assertion, but the .text() call still must not raise."""
img = Image.new("RGB", (60, 20), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
# ○ = U+25CB, the glyph that crashed ring_dashboard.py before #133.
draw.text((2, 2), "○ NOMINAL", fill=(0, 255, 0), font=theme.DEFAULT_FONT)

View File

@ -81,12 +81,21 @@ mount -o bind /sys "$ROOT_MNT/sys"
log "Installing apt packages in chroot..."
# Phase 3: Pillow + python-evdev for the framebuffer kiosk, FastAPI for the
# helper, AppArmor for the profile. No X server, no Qt, no Chromium.
#
# python3-numpy is required for RGB565 packing — Pillow 9.4+ removed its
# RGB->RGB565 raw packers (no "RGB;16" / "BGR;16" for RGB-mode images on
# Bookworm). vc4drmfb on the Pi 4B 7" DSI is 16bpp. See issue #133.
#
# fonts-dejavu-core ships /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf
# referenced by theme.DEFAULT_FONT. Without it Pillow falls back to its
# legacy latin-1 bitmap default which crashes on Unicode glyphs.
chroot "$ROOT_MNT" /bin/bash -c "
DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
python3-pil python3-evdev \
python3-pil python3-evdev python3-numpy \
python3-fastapi python3-uvicorn python3-websockets \
python3-httpx \
fonts-dejavu-core \
apparmor-utils
"

View File

@ -0,0 +1,7 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Recreate /run/secubox at every boot (tmpfs is wiped on reboot).
# The helper service calls Path('/run/secubox').mkdir() at startup and
# crashes with PermissionError if the directory does not exist — see
# https://github.com/CyberMind-FR/secubox-deb/issues/133.
d /run/secubox 0755 secubox-eye-square secubox-eye-square -
d /var/log/secubox 0755 secubox-eye-square secubox-eye-square -