mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 19:43:10 +00:00
Compare commits
2 Commits
b5e44e720a
...
78316556d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 78316556d4 | |||
|
|
a3a918ed6f |
2505
docs/superpowers/plans/2026-05-14-converged-dashboard.md
Normal file
2505
docs/superpowers/plans/2026-05-14-converged-dashboard.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
47
packages/secubox-eye-square/kiosk/tests/test_theme.py
Normal file
47
packages/secubox-eye-square/kiosk/tests/test_theme.py
Normal 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)
|
||||
|
|
@ -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
|
||||
"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -
|
||||
Loading…
Reference in New Issue
Block a user