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 # SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr> """/dev/fb0 blit with bpp + actual-size auto-detect and numpy RGB565 packing.
# 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.
The Pi 4B's DSI panel exposes /dev/fb0 at 800×480, 32-bit BGRA when the The kiosk draws to a fixed 800x480 logical canvas (designed for the Pi 4B
vc4-kms-v3d overlay is active. We open it once, mmap the full size, and official 7" DSI panel). On other displays — Pi 400 + HDMI monitor, mainly —
blit Pillow images into it on each render tick. 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 from __future__ import annotations
import ctypes
import fcntl
import logging import logging
import mmap import mmap
import os import os
from pathlib import Path from pathlib import Path
import numpy as np
from PIL import Image from PIL import Image
log = logging.getLogger("secubox_eye_square_kiosk.framebuffer") 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: 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.path = path
self.width = width self.logical_width = logical_width
self.height = height self.logical_height = logical_height
self.bpp = bpp
self.size = width * height * bpp
self.fd = os.open(path, os.O_RDWR) 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) 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: 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.logical_width, self.logical_height):
if image.size != (self.width, self.height):
raise ValueError( 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 padded = self._pad_to_fb(image)
bgra = image.tobytes("raw", "BGRA") if self.raw_mode == "RGB565":
raw = _pack_rgb565(padded)
else:
raw = padded.tobytes("raw", self.raw_mode)
self.fb.seek(0) self.fb.seek(0)
self.fb.write(bgra) self.fb.write(raw)
def close(self) -> None: def close(self) -> None:
self.fb.close() self.fb.close()

View File

@ -82,7 +82,7 @@ class RightPanel:
colour = theme.GOLD_HERMETIC if key == self.active_tab else theme.TEXT_MUTED colour = theme.GOLD_HERMETIC if key == self.active_tab else theme.TEXT_MUTED
draw.rectangle((x, 0, x + TAB_WIDTH, TAB_BAR_HEIGHT - 1), draw.rectangle((x, 0, x + TAB_WIDTH, TAB_BAR_HEIGHT - 1),
outline=colour, width=1 if key != self.active_tab else 2) 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 area
content_h = h - TAB_BAR_HEIGHT content_h = h - TAB_BAR_HEIGHT
content = Image.new("RGBA", (w, content_h), (0, 0, 0, 255)) content = Image.new("RGBA", (w, content_h), (0, 0, 0, 255))

View File

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

View File

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

View File

@ -46,10 +46,12 @@ class ConsoleTab:
visible_rows = (h - 50) // LINE_HEIGHT visible_rows = (h - 50) // LINE_HEIGHT
for i, line in enumerate(self.lines[-visible_rows:]): for i, line in enumerate(self.lines[-visible_rows:]):
y = TOP_MARGIN + i * LINE_HEIGHT 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 # Freeze button
btn_label = "Resume" if self.frozen else "Freeze" btn_label = "Resume" if self.frozen else "Freeze"
btn_fill = theme.GOLD_HERMETIC if self.frozen else theme.TEXT_MUTED btn_fill = theme.GOLD_HERMETIC if self.frozen else theme.TEXT_MUTED
draw.rectangle((BUTTON_X, BUTTON_Y, w - 4, BUTTON_Y + BUTTON_HEIGHT), draw.rectangle((BUTTON_X, BUTTON_Y, w - 4, BUTTON_Y + BUTTON_HEIGHT),
outline=btn_fill, width=1) 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) draw = ImageDraw.Draw(region)
w, _ = region.size w, _ = region.size
# USB buttons header # 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): for i, mode in enumerate(USB_BUTTONS):
row = i // 3 row = i // 3
col = i % 3 col = i % 3
@ -109,10 +110,11 @@ class ModeControlsTab:
colour = theme.CINNABAR if mode in DESTRUCTIVE else theme.TEXT_PRIMARY colour = theme.CINNABAR if mode in DESTRUCTIVE else theme.TEXT_PRIMARY
draw.rectangle((x, y, x + CELL_W - 5, y + CELL_H - 5), draw.rectangle((x, y, x + CELL_W - 5, y + CELL_H - 5),
outline=colour, width=1) 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 # Service buttons
draw.text((10, SERVICE_ROW_Y - 24), "SECUBOX SERVICE", 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): for i, (_, label) in enumerate(SERVICE_BUTTONS):
row = i // 2 row = i // 2
col = 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 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), draw.rectangle((x, y, x + w // 2 - 15, y + CELL_H - 5),
outline=colour, width=1) 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 # Transport
draw.text((10, TRANSPORT_ROW_Y - 24), "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 "" dot = "" if self.transport_active in ("OTG", "WiFi") else ""
draw.text((10, TRANSPORT_ROW_Y), f"{dot} {self.transport_active}", 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 # Confirm overlay
if self.pending_confirm: if self.pending_confirm:
draw.rectangle((10, 100, w - 10, 200), fill=theme.COSMOS_BLACK, draw.rectangle((10, 100, w - 10, 200), fill=theme.COSMOS_BLACK,
outline=theme.CINNABAR, width=2) outline=theme.CINNABAR, width=2)
draw.text((20, 120), f"Confirm {self.pending_confirm}?", 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", 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) draw = ImageDraw.Draw(region)
w, h = region.size w, h = region.size
if not self.module_name: 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 return
# Title bar # Title bar
draw.text((w // 2 - 30, TITLE_Y), self.module_name, draw.text((w // 2 - 30, TITLE_Y), self.module_name,
fill=theme.GOLD_HERMETIC) fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
draw.text((10, METRIC_Y), self.metric, fill=theme.TEXT_PRIMARY) draw.text((10, METRIC_Y), self.metric, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
# Gauge (clamped 0..100) # Gauge (clamped 0..100)
clamped = max(0.0, min(100.0, self.value)) 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), draw.rectangle((10, GAUGE_Y, 10 + fill_w, GAUGE_Y + GAUGE_HEIGHT),
fill=theme.CYBER_CYAN) fill=theme.CYBER_CYAN)
draw.text((10, GAUGE_Y + GAUGE_HEIGHT + 4), f"{self.value:.1f}", 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 # Sparkline
if len(self.history) >= 2: if len(self.history) >= 2:
@ -76,4 +78,4 @@ class ModuleDetailTab:
# Service status # Service status
draw.text((10, SERVICE_Y), f"Service: {self.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, "warn": GOLD_HERMETIC,
"crit": CINNABAR, "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 # packages/secubox-eye-square/kiosk/tests/test_framebuffer.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0 # SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr> # 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 __future__ import annotations
from pathlib import Path from pathlib import Path
@ -9,39 +10,164 @@ from pathlib import Path
import pytest import pytest
from PIL import Image from PIL import Image
from secubox_eye_square_kiosk import framebuffer as fb_mod
from secubox_eye_square_kiosk.framebuffer import FrameBuffer 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 @pytest.fixture
def fake_fb(tmp_path: Path) -> Path: def fake_fb_32bpp(tmp_path: Path) -> Path:
"""Create a 800×480×4 bytes file simulating /dev/fb0 BGRA32.""" """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 = tmp_path / "fb0"
path.write_bytes(b"\x00" * (800 * 480 * 4)) path.write_bytes(b"\x00" * (800 * 480 * 4))
return path return path
def test_open_and_size(fake_fb: Path): @pytest.fixture
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4) 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.width == 800
assert fb.height == 480 assert fb.height == 480
assert fb.bpp == 4 assert fb.bpp == 4
assert fb.size == 800 * 480 * 4 assert fb.size == 800 * 480 * 4
assert fb.raw_mode == "BGRA"
fb.close() fb.close()
def test_blit_writes_image_bytes(fake_fb: Path): def test_blit_writes_bgra_bytes_at_32bpp(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4) fb = FrameBuffer(path=str(fake_fb_32bpp))
img = Image.new("RGBA", (800, 480), color=(255, 0, 0, 255)) # red img = Image.new("RGBA", (800, 480), color=(255, 0, 0, 255)) # red
fb.blit(img) fb.blit(img)
fb.close() fb.close()
raw = fake_fb.read_bytes() raw = fake_fb_32bpp.read_bytes()
# First pixel: BGRA → blue=0, green=0, red=255, alpha=255 # First pixel: BGRA → blue=0, green=0, red=255, alpha=255 (alpha from black fill)
assert raw[:4] == b"\x00\x00\xff\xff" assert raw[:4] == b"\x00\x00\xff\xff"
def test_blit_wrong_size_raises(fake_fb: Path): def test_blit_wrong_logical_size_raises(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4) fb = FrameBuffer(path=str(fake_fb_32bpp))
img = Image.new("RGBA", (100, 100), color=(0, 0, 0, 255)) img = Image.new("RGBA", (100, 100), color=(0, 0, 0, 255))
with pytest.raises(ValueError, match="image size"): with pytest.raises(ValueError, match="image size"):
fb.blit(img) fb.blit(img)
fb.close() 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..." log "Installing apt packages in chroot..."
# Phase 3: Pillow + python-evdev for the framebuffer kiosk, FastAPI for the # Phase 3: Pillow + python-evdev for the framebuffer kiosk, FastAPI for the
# helper, AppArmor for the profile. No X server, no Qt, no Chromium. # 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 " chroot "$ROOT_MNT" /bin/bash -c "
DEBIAN_FRONTEND=noninteractive apt-get update DEBIAN_FRONTEND=noninteractive apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \ DEBIAN_FRONTEND=noninteractive apt-get install -y \
python3-pil python3-evdev \ python3-pil python3-evdev python3-numpy \
python3-fastapi python3-uvicorn python3-websockets \ python3-fastapi python3-uvicorn python3-websockets \
python3-httpx \ python3-httpx \
fonts-dejavu-core \
apparmor-utils 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 -