Compare commits

..

No commits in common. "78316556d47cf1aed316357bf67e0a9c72c2859d" and "b5e44e720abe33350be1439a30363ede3a2c7513" have entirely different histories.

13 changed files with 56 additions and 2905 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,175 +1,48 @@
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/framebuffer.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""/dev/fb0 blit with bpp + actual-size auto-detect and numpy RGB565 packing.
# 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.
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.
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.
"""
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 and center-pads the kiosk's logical
canvas into the actual fb resolution on every blit."""
"""Owns the mmap handle to /dev/fb0. Single-instance-per-process."""
def __init__(self, path: str = "/dev/fb0",
logical_width: int = 800, logical_height: int = 480):
def __init__(self, path: str = "/dev/fb0", width: int = 800, height: int = 480, bpp: int = 4):
self.path = path
self.logical_width = logical_width
self.logical_height = logical_height
self.width = width
self.height = height
self.bpp = bpp
self.size = width * height * bpp
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:
if image.size != (self.logical_width, self.logical_height):
"""Push a Pillow image to the framebuffer. Image must be RGBA at exact resolution."""
if image.size != (self.width, self.height):
raise ValueError(
f"image size {image.size} != logical "
f"{self.logical_width}x{self.logical_height}"
f"image size {image.size} doesn't match framebuffer {self.width}x{self.height}"
)
padded = self._pad_to_fb(image)
if self.raw_mode == "RGB565":
raw = _pack_rgb565(padded)
else:
raw = padded.tobytes("raw", self.raw_mode)
# Convert Pillow RGBA → BGRA for vc4-kms-v3d's little-endian BGRA32 layout
bgra = image.tobytes("raw", "BGRA")
self.fb.seek(0)
self.fb.write(raw)
self.fb.write(bgra)
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, font=theme.DEFAULT_FONT)
draw.text((x + 8, 20), label, fill=colour)
# Content area
content_h = h - TAB_BAR_HEIGHT
content = Image.new("RGBA", (w, content_h), (0, 0, 0, 255))

View File

@ -122,24 +122,20 @@ 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,
font=theme.DEFAULT_FONT)
draw.text((px - 16, py + 8), m.name, fill=theme.TEXT_PRIMARY)
# 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,
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)
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)
# 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, font=theme.DEFAULT_FONT)
fill=dot_colour)
# Alerts ribbon — overlay bottom 24px when alert is active
if self._alert_text:
@ -147,7 +143,6 @@ 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,
font=theme.DEFAULT_FONT)
f"{self._alert_text}"[:50], fill=ribbon_colour)
return img

View File

@ -59,8 +59,7 @@ 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,
font=theme.DEFAULT_FONT)
draw.text((10, 10), "● NOMINAL", fill=theme.MATRIX_GREEN)
return
w, h = region.size
for i, item in enumerate(self.items):
@ -74,7 +73,7 @@ class AlertsTab:
)
txt = f"{item.time} {item.module} {item.message}"
draw.text((TEXT_PAD_LEFT, y + 8), txt[:38],
fill=theme.TEXT_PRIMARY, font=theme.DEFAULT_FONT)
fill=theme.TEXT_PRIMARY)
# divider line
draw.line((0, y + ROW_HEIGHT - 1, w, y + ROW_HEIGHT - 1),
fill=theme.TEXT_MUTED)

View File

@ -46,12 +46,10 @@ 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,
font=theme.DEFAULT_FONT)
draw.text((4, y), line[:48], fill=theme.MATRIX_GREEN)
# 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,
font=theme.DEFAULT_FONT)
draw.text((BUTTON_X + 8, BUTTON_Y + 8), btn_label, fill=btn_fill)

View File

@ -100,8 +100,7 @@ class ModeControlsTab:
draw = ImageDraw.Draw(region)
w, _ = region.size
# USB buttons header
draw.text((10, 16), "USB GADGET MODE", fill=theme.GOLD_HERMETIC,
font=theme.DEFAULT_FONT)
draw.text((10, 16), "USB GADGET MODE", fill=theme.GOLD_HERMETIC)
for i, mode in enumerate(USB_BUTTONS):
row = i // 3
col = i % 3
@ -110,11 +109,10 @@ 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,
font=theme.DEFAULT_FONT)
draw.text((x + 8, y + 24), mode.upper(), fill=colour)
# Service buttons
draw.text((10, SERVICE_ROW_Y - 24), "SECUBOX SERVICE",
fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
fill=theme.GOLD_HERMETIC)
for i, (_, label) in enumerate(SERVICE_BUTTONS):
row = i // 2
col = i % 2
@ -123,20 +121,18 @@ 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,
font=theme.DEFAULT_FONT)
draw.text((x + 8, y + 24), label, fill=colour)
# Transport
draw.text((10, TRANSPORT_ROW_Y - 24), "TRANSPORT",
fill=theme.GOLD_HERMETIC, font=theme.DEFAULT_FONT)
fill=theme.GOLD_HERMETIC)
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,
font=theme.DEFAULT_FONT)
fill=theme.MATRIX_GREEN if dot == "" else theme.TEXT_MUTED)
# 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, font=theme.DEFAULT_FONT)
fill=theme.CINNABAR)
draw.text((20, 150), "Tap again to confirm",
fill=theme.TEXT_MUTED, font=theme.DEFAULT_FONT)
fill=theme.TEXT_MUTED)

View File

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

View File

@ -30,17 +30,3 @@ 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,8 +1,7 @@
# 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 — bpp + size auto-detect, RGB565 numpy pack,
and center-padding when the fb is larger than the kiosk's logical canvas."""
"""Tests for framebuffer.py — mmap blit. Uses a tmpfs file as fake /dev/fb0."""
from __future__ import annotations
from pathlib import Path
@ -10,164 +9,39 @@ 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_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."""
def fake_fb(tmp_path: Path) -> Path:
"""Create a 800×480×4 bytes file simulating /dev/fb0 BGRA32."""
path = tmp_path / "fb0"
path.write_bytes(b"\x00" * (800 * 480 * 4))
return path
@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))
def test_open_and_size(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
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_bgra_bytes_at_32bpp(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb_32bpp))
def test_blit_writes_image_bytes(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
img = Image.new("RGBA", (800, 480), color=(255, 0, 0, 255)) # red
fb.blit(img)
fb.close()
raw = fake_fb_32bpp.read_bytes()
# First pixel: BGRA → blue=0, green=0, red=255, alpha=255 (alpha from black fill)
raw = fake_fb.read_bytes()
# First pixel: BGRA → blue=0, green=0, red=255, alpha=255
assert raw[:4] == b"\x00\x00\xff\xff"
def test_blit_wrong_logical_size_raises(fake_fb_32bpp: Path):
fb = FrameBuffer(path=str(fake_fb_32bpp))
def test_blit_wrong_size_raises(fake_fb: Path):
fb = FrameBuffer(path=str(fake_fb), width=800, height=480, bpp=4)
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

@ -1,47 +0,0 @@
# 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,21 +81,12 @@ 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-numpy \
python3-pil python3-evdev \
python3-fastapi python3-uvicorn python3-websockets \
python3-httpx \
fonts-dejavu-core \
apparmor-utils
"

View File

@ -1,7 +0,0 @@
# 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 -