Compare commits

...

35 Commits

Author SHA1 Message Date
8996847745 fixup(square): install secubox-otg-gadget.sh to /usr/local/sbin (ref #135)
The square build script enables secubox-otg-gadget.service but never copies
the composer script to /usr/local/sbin/. On boot the service ExecStarts a
missing file → fails silently → the Pi 4B's USB-C bus stays unconfigured →
MOCHAbin/host see no enumeration (zero dmesg events, xhci setup timeouts).

The round build copies it at build-eye-remote-image.sh:618; bringing square
to parity. The script itself lives in remote-ui/common/shell/, already
shared between the two images.

Hardware-confirmed: Pi 4B booted and rendered the dashboard but MOCHAbin
got no USB events whatsoever on either a hub-deep port or a direct port,
across cable swaps. Root cause was the missing composer, not cabling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:06 +02:00
f4acd5a9ca fixup(square): ship remote-ui/common/assets/icons to /var/www/common/assets (ref #135)
The square build script shipped remote-ui/common/python/ but skipped
remote-ui/common/assets/. secubox_common.icons.load_module_icon resolves
icons at /var/www/common/assets/icons/ — when that path is missing on the
deployed image, every module pod falls back to a first-letter placeholder.

The round build script already does this at line 925; bringing square to
parity. Fails the build (exit 2) if no *-48.png icons land — same
shape as the existing python-package guard right above.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:06:03 +02:00
387fabb4f3 fixup(common): pod_size=48 to match deployed icon sizes (ref #135)
Module icons were falling back to first-letter placeholders because
SquareDashboard/RoundDashboard called paint_pod_cluster(pod_size=40)
while load_module_icon only ships sizes 22/48/96/128. Bump pod_size
to 48 and radius from 70 to 78 so the pod inner edge (54) stays clear
of the central button (44).

Confirmed on Pi 4B square hardware: pods now render with full icons.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:50:08 +02:00
5546169971 build(systemd): PYTHONPATH=/var/www/common/python for both kiosks (ref #135) 2026-05-15 07:45:02 +02:00
c9539f8ba6 build(images): ship secubox_common to /var/www/common/python/ on both images (ref #135) 2026-05-15 07:44:55 +02:00
b57891941b refactor(round): fb_dashboard.draw_dashboard delegates to RoundDashboard (ref #135) 2026-05-15 07:41:54 +02:00
39769c318b feat(round): RoundDashboard via secubox_common (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:37:56 +02:00
eeb455b673 test(square): cross-form-factor secubox_common API stability test (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:35:12 +02:00
4aa36f104e fixup(square): __main__ drop dead touch_input plumbing (ref #135)
T15's commit kept find_touch_devices()/read_events() in the loop with
a `pass` body — the implementer recognised it as duplicate input but
preserved it to keep the call shape. PointerInput already discovers
every device exposing BTN_LEFT or BTN_TOUCH (T12), which covers USB
mouse, USB touchpad, AND the 7" DSI touchscreen in one path. Calling
read_events on the same fds in parallel would consume events from
the shared device stream without dispatching them.

Drop the empty loop and the four unused imports (TouchEvent, classify,
find_touch_devices, read_events). Leave touch_input.py on disk — it
still has tests and might be useful for a different scenario, but
the kiosk main loop no longer uses it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:33:00 +02:00
626e68904f feat(square): __main__ wires SquareDashboard + PointerInput + cursor overlay (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 07:28:06 +02:00
4e03649954 refactor(square): theme + modules_table become re-export shims for secubox_common (ref #135)
theme.py becomes a thin re-export of secubox_common.theme — keeps
`from .theme import COSMOS_BLACK` etc. working in tabs/* without
sweeping import rewrites in this PR. Also exposes DEFAULT_FONT =
load_default_font(12) at module level for legacy module-level imports.

modules_table.py becomes a re-export of secubox_common.modules. The
legacy in-package Module had `radius` and `unit` fields:
- `radius` was form-factor-specific layout (round Pi Zero W used
  214..149, square uses 200..125 via SquareDashboard.RING_RADII).
  Layout properly lives on the dashboard subclass now, not the
  Module dataclass.
- `unit` was defined but never read in production code.

The legacy test_modules_table.py asserted the old [214,201,...] radii
plus extractor behaviour that is fully duplicated in test_modules.py
under secubox_common. Removed as part of the same commit.

Full square/ kiosk suite still 73 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:25:18 +02:00
d2ec163912 fixup(square): cursor sprite uses theme.GOLD_HERMETIC, real test asserts (ref #135)
Code review on T13 flagged four items:
1. Off-canvas guard used hardcoded 12/16 magic numbers; refactor to
   _W/_H constants so the guard stays coupled to the polygon shape.
2. _OUTLINE comment claimed GOLD_HERMETIC but duplicated the hex —
   import theme.GOLD_HERMETIC so palette changes propagate.
3. test_cursor_clamped_to_image_bounds had a comment but no assert —
   silent green even if draw_cursor were deleted. Add an assertion
   that at least one pixel in the clipped 5×5 corner is non-black.
4. test_cursor_negative_coords_dont_crash had no assert either —
   pin the invariant that the canvas is untouched on no-op return.

Also added the sys.path bootstrap line to the test file so the
secubox_common import resolves on dev hosts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:20:20 +02:00
c214c946c2 feat(square): cursor.py — 12×16 arrow sprite for pointer overlay (ref #135) 2026-05-15 07:17:30 +02:00
50a687654c fixup(square): pointer_input motion-event contract + log level (ref #135)
Code review on T12 flagged five items:
1. _handle_evdev_event was silent on REL/ABS but _drain_test_queue
   emitted "motion" events — tests asserted motion presence which
   only worked through the test path. Real Pi 4B + USB mouse would
   never see motion events. Align both paths: emit one motion event
   per axis update from the real path too. T15 ignores motion events,
   so no downstream breakage.
2. log.warning("pointer devices found: ...") fires on every clean
   boot, polluting the log. Switch to log.info for the populated
   case and keep log.warning only when zero devices are found (the
   genuinely unexpected condition).
3. except (OSError, PermissionError) — PermissionError is a subclass
   of OSError, redundant. Drop it.
4. import os, fcntl was inside _discover_devices (PEP 8 E401 +
   non-top-level). Hoist both to module top.
5. _last_motion = 0.0 default keeps the cursor hidden until first
   motion — intentional but non-obvious; add a one-line comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:15:52 +02:00
75991ec5b0 feat(square): pointer_input — mouse + touchpad via python-evdev (ref #135) 2026-05-15 07:08:41 +02:00
238304f351 fixup(square): drop dead tm var, clarify paint_background omission (ref #135)
Code review on T11 flagged two housekeeping items:
1. test_kiosk_smoke.py kept TransportManager assignment from the old
   pre-fixup smoke test, but the new SquareDashboard path doesn't
   need it — F841 dead-var noise. Drop both the import and the
   unused assignment.
2. SquareDashboard.layout() skips paint_background because Image.new()
   already fills with the same colour. Add a one-line comment so
   future readers don't wonder why a base-class primitive went
   unused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:06:12 +02:00
4c176f8be2 fixup(square): smoke test uses SquareDashboard, drops RingDashboard tap test (ref #135)
T11 deleted ring_dashboard.py but test_kiosk_smoke.py still imported
RingDashboard, breaking collection. Rewrite the compose-frame test to
drive SquareDashboard (which already composes the 800x480 frame
internally) and drop the on_module_tap test — that routing moves
into __main__.py at T15 and will be covered there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:47:07 +02:00
153023b390 feat(square): SquareDashboard via secubox_common; drop ring_dashboard.py (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:45:58 +02:00
26e7f45285 fixup(common): paint_alert_ribbon honest fill + docstring (ref #135)
Code review on T10 flagged that the docstring claimed
"semi-transparent fill" but ImageDraw.rectangle on an RGBA image
just overwrites pixels with the raw tuple — no compositing happens.
The kiosk's framebuffer blit then calls .convert("RGB") which drops
the alpha channel entirely, so the visible result was always a
solid black strip regardless of the alpha=200 hint.

Switch fill to (0, 0, 0, 255), rewrite the docstring to describe
what the code actually does, and add an inline comment explaining
why alpha is pinned to 255 (so future authors don't try to "dim"
the ribbon by lowering it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:37:10 +02:00
b15497d668 feat(common): canvas — paint_central_button + paint_alert_ribbon (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:25:51 +02:00
8922b2f59e fixup(common): exercise paint_pod_cluster icon-paste path + drop dead asserts (ref #135)
Code review on T9 flagged two test-quality gaps:
1. Both T9 tests used pod_size 20 / 30, which have no matching icon
   files (sizes on disk: 22, 48, 96, 128), so load_module_icon
   always returned None and the icon-paste code path went untested.
   Adds test_paint_pod_cluster_uses_icon_when_available at
   pod_size=48 (real file present) — exercises img.paste(icon, ...)
   on every commit. Calls icons._cache_clear() first to avoid stale
   None cache entries from earlier tests in the session.
2. test_paint_pod_cluster_six_coloured_circles computed dr/dg/db
   colour distances but never asserted on them — dead code that
   misled the contract. Drop the computation; the assertion stays
   the same (loose non-black check, justified because the letter
   fallback may paint the centre pixel white).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:24:19 +02:00
f3fba94bc4 feat(common): canvas — paint_pod_cluster (icon or letter on coloured disc) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:16:45 +02:00
f1e2375e57 fixup(common): type-annotate paint_concentric_arcs modules param (ref #135)
Code review on T8 flagged the `modules` parameter as the only
unannotated arg on `paint_concentric_arcs` — every other arg
(`img`, `center`, `metrics`, `radii`) carries an annotation, so this
one creates an inconsistent surface and gives no IDE assist. Use
`Iterable[Module]` (accurate — zip() only needs an iterable) and
import Module from secubox_common.modules. Required before T9 adds
more callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:15:00 +02:00
1cc3f8ee8e fixup(common): saturate concentric_arcs test metrics for CPU-count portability (ref #135)
T8 commit ea7c2781 left test_paint_concentric_arcs_six_rings_present
brittle: MIND's load_avg_1=4.0 only saturates the arc on a 4-core
machine. On the 20-core dev host _CPU_COUNT=20 makes the MIND ring
fill 20% (4/20), leaving the 3 o'clock sample on the dark track and
failing the colour-distance assertion. Push every metric to 999 so
each ring clamps to 100% regardless of os.cpu_count().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:05:41 +02:00
ea7c278126 feat(common): canvas — paint_concentric_arcs (per-module ring fill) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 06:04:17 +02:00
43a994fde0 fixup(common): paint_rainbow_ring erase colour + dead test assert (ref #135)
Code review on T7 flagged two plan defects:
1. The inner-disc erase was hardcoded to (0,0,0,255), but both
   RoundDashboard (T17) and SquareDashboard (T11) composite against
   COSMOS_BLACK (8,8,8) — gaps between primitives (r≈22-50,
   r≈90-125) would show a dark-ring artefact. Add a `background`
   parameter defaulting to theme.COSMOS_BLACK so callers blend
   correctly.
2. test_paint_rainbow_ring_pixels_in_band_are_colored had an
   always-false guard `if 240 + 250 < 480` (490 < 480 = False), so
   the outer-radius assertion was dead code. Use radius 238 instead
   (x=478 in-bounds) and drop the guard — the outside pixel stays
   at the fixture's initial (0,0,0) because rainbow_ring never
   touches it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 06:01:18 +02:00
74c5076b60 feat(common): canvas — paint_rainbow_ring (HSV hue rotation) (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 05:53:42 +02:00
c31c61bf3b feat(common): canvas.py — DashboardCanvas base + paint_background (ref #135) 2026-05-15 05:48:43 +02:00
8809104760 feat(common): icons.py — module icon loader with multi-path resolution (closes round/ ICONS_DIR bug, ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 05:45:19 +02:00
50e4c05a67 fixup(common): MIND extract divides load_avg by os.cpu_count() (ref #135)
Code review on T4 flagged the hardcoded /4.0 divisor: on Pi Zero W
(single-core, round/ target), load_avg=1.0 would render the MIND
arc at only 25% fill even though the CPU is fully saturated.
Reading os.cpu_count() at import time makes the formula self-adapt
per device — round/ (1 core), square/ Pi 4B/400 (4 cores) — without
any per-platform branching.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:01:01 +02:00
33fb2a20b0 feat(common): modules.py — canonical Hamiltonian module table (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 01:51:37 +02:00
0f8498a37a fixup(common): remove dead ImageFont import from test_theme (ref #135)
Code review flagged: test_theme.py imported PIL.ImageFont but never
referenced it directly — fonts come back through theme.load_default_font().
F401 noise; clean up before downstream lint runs catch it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:41:17 +02:00
847af3e563 feat(common): theme.py — palette constants + DEFAULT_FONT loader (ref #135)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:35:18 +02:00
7b0f5306f5 fixup(common): complete SPDX header (ref #135)
Code review on T2 flagged the truncated 1-line SPDX header (project
canon is 4 lines: SPDX + Copyright + License + LICENCE.md ref). The
plan's code blocks propagated the short form; restoring the full
header now so it doesn't ripple through T3-T18+.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:33:17 +02:00
f552411f96 feat(common): seed secubox_common package skeleton (ref #135) 2026-05-14 15:16:42 +02:00
33 changed files with 1542 additions and 597 deletions

View File

@ -1,97 +1,98 @@
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.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.
"""SecuBox Eye Square kiosk — event loop driver."""
"""SecuBox Eye Square kiosk — event loop driver (converged)."""
from __future__ import annotations
import logging
import os
import sys
import time
from pathlib import Path
from PIL import Image
from .cursor import draw_cursor
from .framebuffer import FrameBuffer
from .helper_client import HelperClient
from .pointer_input import PointerInput
from .right_panel import RightPanel
from .ring_dashboard import RingDashboard
from .sim import SimState, step
from .square_dashboard import SquareDashboard
from .transport_manager import TransportManager
log = logging.getLogger("secubox_eye_square_kiosk")
FB_PATH = os.environ.get("EYE_SQUARE_FB", "/dev/fb0")
HELPER_SOCK = os.environ.get("EYE_SQUARE_HELPER_SOCK",
"/run/secubox/eye-square-helper.sock")
HELPER_SOCK = os.environ.get(
"EYE_SQUARE_HELPER_SOCK", "/run/secubox/eye-square-helper.sock"
)
TARGET_FPS = 30
PROBE_INTERVAL_S = 30
METRICS_INTERVAL_S = 2
def main() -> int:
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s")
log.info("Starting SecuBox Eye Square kiosk")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(name)s %(levelname)s %(message)s",
)
log.info("Starting SecuBox Eye Square kiosk (converged)")
# Helper + TransportManager
helper = HelperClient(HELPER_SOCK)
tm = TransportManager(simulate=False)
tm.probe()
# SIM state for fallback
sim = SimState()
# Dashboard + right panel
rd = RingDashboard()
panel = RightPanel(helper)
rd.on_module_tap = panel.on_module_tap
tm.on_transport_change = lambda active: (
panel.on_transport_change(active),
rd.set_transport(active),
)
dashboard = SquareDashboard(right_panel=panel)
tm.on_transport_change = lambda active: panel.on_transport_change(active)
# Framebuffer
try:
fb = FrameBuffer(FB_PATH)
except OSError as e:
log.error("Cannot open framebuffer %s: %s", FB_PATH, e)
return 1
# PointerInput's _discover_devices picks up every /dev/input/event*
# that exposes BTN_LEFT or BTN_TOUCH — that covers USB mouse, USB
# touchpad, and the 7" DSI touchscreen in one place. The legacy
# touch_input.py free functions are kept on disk for now but not
# called from the loop to avoid duplicate reads on the same fds.
pointer = PointerInput(fb_size=(fb.width, fb.height))
last_probe = 0.0
last_metrics = 0.0
frame_period = 1.0 / TARGET_FPS
metrics: dict = {}
try:
while True:
now = time.time()
# Periodic transport probe
# Periodic transport probe + metrics refresh.
if now - last_probe > PROBE_INTERVAL_S:
tm.probe()
last_probe = now
# Periodic metrics fetch (or SIM drift)
if now - last_metrics > METRICS_INTERVAL_S:
metrics = tm.fetch_metrics()
if metrics is None:
fetched = tm.fetch_metrics()
if fetched is None:
step(sim, refresh_interval_s=METRICS_INTERVAL_S)
metrics = sim.to_dict()
rd.update_metrics(metrics)
else:
metrics = fetched
last_metrics = now
# Animation tick
rd.advance()
# Compose frame
full = Image.new("RGBA", (800, 480), (0, 0, 0, 255))
full.paste(rd.draw(), (0, 0))
panel_img = Image.new("RGBA", (320, 480), (0, 0, 0, 255))
panel.draw(panel_img)
full.paste(panel_img, (480, 0))
# Input poll + dispatch — mouse/touchpad/touchscreen all go
# through PointerInput (T12) which discovers BTN_LEFT and
# BTN_TOUCH devices.
for ev in pointer.poll():
if ev.kind == "tap":
_dispatch_tap(ev.x, ev.y, panel, dashboard)
# Render.
full = dashboard.layout(metrics)
if pointer.cursor_visible:
draw_cursor(full, *pointer.cursor_xy)
fb.blit(full)
time.sleep(frame_period)
@ -102,5 +103,14 @@ def main() -> int:
return 0
def _dispatch_tap(x: int, y: int, panel: RightPanel, dashboard) -> None:
if x >= 480:
panel.handle_tap(x - 480, y)
else:
# Future: dashboard.handle_tap(x, y) — pod cluster interaction.
# For now the dashboard is read-only; only the tab bar takes taps.
pass
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,35 @@
# 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.
"""Cursor sprite — drawn as the last overlay each frame when the pointer
has moved within the AUTO_HIDE_S window."""
from __future__ import annotations
from PIL import Image, ImageDraw
from secubox_common import theme
# Arrow polygon (hand-drawn, top-left origin). _W/_H bound the sprite
# and feed the off-canvas guard so the guard stays in sync with the
# polygon shape.
_W, _H = 12, 16
_OUTLINE = theme.GOLD_HERMETIC + (255,)
_FILL = (0x00, 0x00, 0x00, 255)
_POLY = [
(0, 0), (10, 6), (5, 6), (8, 14), (5, 15), (3, 8), (0, 11),
]
def draw_cursor(img: Image.Image, x: int, y: int) -> None:
"""Draw the cursor sprite with hot-spot at (x, y).
Sprite extends 0..11 px right and 0..15 px down from the hot-spot.
Partial off-canvas placement is fine Pillow's polygon clips itself.
Coordinates with x < 0 or y < 0 fully off-canvas: no-op."""
if x + _W < 0 or y + _H < 0 or x >= img.size[0] or y >= img.size[1]:
return
draw = ImageDraw.Draw(img)
shifted = [(x + px, y + py) for (px, py) in _POLY]
draw.polygon(shifted, fill=_FILL, outline=_OUTLINE)

View File

@ -2,81 +2,14 @@
# 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.
"""6-module RINGS table — colour, ring radius, metric extractor.
"""Backward-compat shim — re-exports secubox_common.modules.
Hamiltonian order: AUTH WALL BOOT MIND ROOT MESH AUTH.
Each entry corresponds to one concentric arc on the 480×480 round canvas.
The legacy in-package Module dataclass had `radius` and `unit` fields.
`radius` is now a layout property of SquareDashboard.RING_RADII (each
form factor uses different radii round Pi Zero W used 214..149,
square Pi 4B/400 uses 200..125). `unit` was never read in production.
The unit-test that asserted the old radii is removed alongside this
shim coverage is fully duplicated in
remote-ui/common/python/secubox_common/tests/test_modules.py.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable
from . import theme
@dataclass(frozen=True)
class Module:
"""One module's rendering metadata."""
name: str # "AUTH", "WALL", ...
colour: tuple[int, int, int] # RGB tuple from theme.py
radius: int # arc radius in pixels (centre at 240,240)
metric: str # API field name, e.g. "cpu_percent"
unit: str # display unit, e.g. "%"
extract: Callable[[dict], float] # (state-dict) → 0..1 fill ratio
def _clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, v))
MODULES: list[Module] = [
Module(
name="AUTH",
colour=theme.AUTH,
radius=214,
metric="cpu_percent",
unit="%",
extract=lambda s: _clamp(s.get("cpu_percent", 0.0) / 100.0),
),
Module(
name="WALL",
colour=theme.WALL,
radius=201,
metric="mem_percent",
unit="%",
extract=lambda s: _clamp(s.get("mem_percent", 0.0) / 100.0),
),
Module(
name="BOOT",
colour=theme.BOOT,
radius=188,
metric="disk_percent",
unit="%",
extract=lambda s: _clamp(s.get("disk_percent", 0.0) / 100.0),
),
Module(
name="MIND",
colour=theme.MIND,
radius=175,
metric="load_avg_1",
unit="×",
extract=lambda s: _clamp(s.get("load_avg_1", 0.0) / 4.0),
),
Module(
name="ROOT",
colour=theme.ROOT,
radius=162,
metric="cpu_temp",
unit="°C",
extract=lambda s: _clamp((s.get("cpu_temp", 35.0) - 35.0) / 50.0),
),
Module(
name="MESH",
colour=theme.MESH,
radius=149,
metric="wifi_rssi",
unit="dBm",
extract=lambda s: _clamp((s.get("wifi_rssi", -90) + 90.0) / 70.0),
),
]
from secubox_common.modules import Module, MODULES # noqa: F401

View File

@ -0,0 +1,201 @@
# 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.
"""PointerInput — mouse + touchpad via python-evdev.
Reads /dev/input/event* devices that expose BTN_LEFT or BTN_TOUCH,
emits InputEvent("motion"/"tap", x, y) at the current cursor position.
The cursor position is clamped to the framebuffer bounds passed in at
construction. Mouse devices send relative motion (REL_X/Y); touchpads
send absolute (ABS_X/Y). Both are mapped through to (cursor_x, cursor_y).
Auto-hide: `cursor_visible` returns False if no motion in the last
AUTO_HIDE_S seconds. The kiosk overlay logic uses this to skip drawing
the cursor sprite when idle.
USB unplug: OSError on read marks the device gone; `poll()` keeps
running and re-tries device discovery every 30 s.
"""
from __future__ import annotations
import fcntl
import logging
import os
import time
from dataclasses import dataclass
log = logging.getLogger("secubox_eye_square_kiosk.pointer_input")
try:
from evdev import InputDevice, list_devices, ecodes
HAS_EVDEV = True
except ImportError:
HAS_EVDEV = False
@dataclass
class InputEvent:
kind: str # "tap" | "motion"
x: int
y: int
# Event-code names used by tests' _inject_for_tests helper.
_TEST_CODE_TO_TYPE_CODE = {
"EV_REL_X": ("REL", "REL_X"),
"EV_REL_Y": ("REL", "REL_Y"),
"EV_ABS_X": ("ABS", "ABS_X"),
"EV_ABS_Y": ("ABS", "ABS_Y"),
"EV_KEY_BTN_LEFT": ("KEY", "BTN_LEFT"),
"EV_KEY_BTN_TOUCH": ("KEY", "BTN_TOUCH"),
"EV_SYN": ("SYN", "SYN_REPORT"),
}
class PointerInput:
AUTO_HIDE_S = 3.0
REDISCOVERY_INTERVAL_S = 30.0
def __init__(self, fb_size: tuple[int, int]):
self.fb_w, self.fb_h = fb_size
self._x = fb_size[0] // 2
self._y = fb_size[1] // 2
# Epoch-relative — cursor stays hidden until first motion.
self._last_motion = 0.0
self._last_rediscovery = 0.0
self._test_queue: list[tuple] = []
self._device_gone = False
self._devices = []
if HAS_EVDEV:
self._devices = self._discover_devices()
@property
def cursor_xy(self) -> tuple[int, int]:
return (self._x, self._y)
@property
def cursor_visible(self) -> bool:
return (time.time() - self._last_motion) < self.AUTO_HIDE_S
def poll(self) -> list[InputEvent]:
out: list[InputEvent] = []
# Drain test queue first.
out.extend(self._drain_test_queue())
# Real devices.
for dev in list(self._devices):
try:
for ev in dev.read():
e = self._handle_evdev_event(ev)
if e is not None:
out.append(e)
except BlockingIOError:
continue # nothing queued, normal
except OSError as ose:
log.warning("pointer device %s gone: %s", dev.path, ose)
self._devices.remove(dev)
self._device_gone = True
# Periodic re-discovery if any device was lost.
if self._device_gone and HAS_EVDEV:
now = time.time()
if now - self._last_rediscovery > self.REDISCOVERY_INTERVAL_S:
self._devices = self._discover_devices()
self._last_rediscovery = now
if self._devices:
self._device_gone = False
return out
# ---- internals ----
def _discover_devices(self) -> list:
if not HAS_EVDEV:
return []
devices = []
for path in list_devices():
try:
dev = InputDevice(path)
caps = dev.capabilities()
key_caps = caps.get(ecodes.EV_KEY, [])
if ecodes.BTN_LEFT in key_caps or ecodes.BTN_TOUCH in key_caps:
# Make it non-blocking so poll() can drain without hanging.
flags = fcntl.fcntl(dev.fd, fcntl.F_GETFL)
fcntl.fcntl(dev.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
devices.append(dev)
except OSError:
continue
if devices:
log.info("pointer devices found: %s", [d.path for d in devices])
else:
log.warning("no pointer devices found (mouse/touchpad/touchscreen)")
return devices
def _handle_evdev_event(self, ev) -> "InputEvent | None":
if not HAS_EVDEV:
return None
if ev.type == ecodes.EV_REL:
if ev.code == ecodes.REL_X:
self._x = self._clamp_x(self._x + ev.value)
self._touch_motion()
return InputEvent("motion", self._x, self._y)
elif ev.code == ecodes.REL_Y:
self._y = self._clamp_y(self._y + ev.value)
self._touch_motion()
return InputEvent("motion", self._x, self._y)
elif ev.type == ecodes.EV_ABS:
if ev.code == ecodes.ABS_X:
self._x = self._clamp_x(ev.value)
self._touch_motion()
return InputEvent("motion", self._x, self._y)
elif ev.code == ecodes.ABS_Y:
self._y = self._clamp_y(ev.value)
self._touch_motion()
return InputEvent("motion", self._x, self._y)
elif ev.type == ecodes.EV_KEY:
if ev.code in (ecodes.BTN_LEFT, ecodes.BTN_TOUCH) and ev.value == 1:
return InputEvent("tap", self._x, self._y)
return None
def _drain_test_queue(self) -> list[InputEvent]:
out: list[InputEvent] = []
had_motion = False
for name, value in self._test_queue:
kind, code = _TEST_CODE_TO_TYPE_CODE.get(name, (None, None))
if kind is None:
continue
if kind == "REL":
if code == "REL_X":
self._x = self._clamp_x(self._x + value); had_motion = True
elif code == "REL_Y":
self._y = self._clamp_y(self._y + value); had_motion = True
elif kind == "ABS":
if code == "ABS_X":
self._x = self._clamp_x(value); had_motion = True
elif code == "ABS_Y":
self._y = self._clamp_y(value); had_motion = True
elif kind == "KEY":
if value == 1 and code in ("BTN_LEFT", "BTN_TOUCH"):
out.append(InputEvent("tap", self._x, self._y))
if had_motion:
self._touch_motion()
out.append(InputEvent("motion", self._x, self._y))
self._test_queue.clear()
return out
def _touch_motion(self) -> None:
self._last_motion = time.time()
def _clamp_x(self, x: int) -> int:
return max(0, min(self.fb_w - 1, int(x)))
def _clamp_y(self, y: int) -> int:
return max(0, min(self.fb_h - 1, int(y)))
# ---- test hooks ----
def _inject_for_tests(self, events: list[tuple]) -> None:
self._test_queue.extend(events)
def _mark_device_gone_for_tests(self) -> None:
self._device_gone = True
self._devices = []

View File

@ -1,153 +0,0 @@
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.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.
"""Ring dashboard — left 480x480 Pillow renderer.
Pixel-faithful intent vs Phase 1 round/index.html: 6 concentric arcs
(radii 214/201/188/175/162/149), each module colour-mapped, smooth fill
animation toward target value, central clock + hostname + uptime,
transport badge, status row, temperature bar. Alerts ribbon overlays
bottom 24px when severity warn.
"""
from __future__ import annotations
import math
import socket
import time
from datetime import datetime
from typing import Callable, Optional
from PIL import Image, ImageDraw
from . import theme
from .modules_table import MODULES, Module
CX, CY = 240, 240
RING_WIDTH = 5
EASE_STEPS = 8 # animation frames between metric updates
POD_DISTANCE = 235
ALERT_RIBBON_HEIGHT = 24
TRANSPORT_BADGE_Y = 14
class RingDashboard:
"""480x480 left half. update_metrics() sets target values; advance() eases
current values toward target each tick; draw() renders the frame."""
def __init__(self):
self.size = (480, 480)
self.transport = "SIM"
self.hostname = socket.gethostname()
self._current: dict[str, float] = {m.metric: 0.0 for m in MODULES}
self._target: dict[str, float] = {m.metric: 0.0 for m in MODULES}
self._alert_text = ""
self._alert_severity = "info"
self.on_module_tap: Callable[[str], None] = lambda _: None
def update_metrics(self, metrics: dict) -> None:
"""Set new target values. _current eases toward _target over EASE_STEPS frames.
Stores raw metric values (e.g. cpu_percent=80.0). The modules_table
extract() function converts to 0..1 fill ratio at draw time.
"""
for m in MODULES:
if m.metric in metrics:
self._target[m.metric] = float(metrics[m.metric])
def advance(self) -> None:
"""One easing frame — move _current toward _target by 1/EASE_STEPS."""
for m in MODULES:
cur = self._current[m.metric]
tgt = self._target[m.metric]
self._current[m.metric] = cur + (tgt - cur) / EASE_STEPS
def set_transport(self, active: str) -> None:
self.transport = active
def set_alert_ribbon(self, text: str, severity: str = "info") -> None:
self._alert_text = text
self._alert_severity = severity
def clear_alert_ribbon(self) -> None:
self._alert_text = ""
def handle_tap(self, x: int, y: int) -> None:
"""Detect pod taps. Pods sit at angles -π/2, -π/2+π/3, ... around the ring."""
dx, dy = x - CX, y - CY
dist = math.hypot(dx, dy)
if abs(dist - POD_DISTANCE) > 30:
return
# angle in radians, 0 = right, -π/2 = top
angle = math.atan2(dy, dx)
# Normalise so AUTH is at -π/2 (top), increment by π/3 clockwise
normalised = (angle + math.pi / 2) % (2 * math.pi)
idx = int(normalised / (math.pi / 3))
if 0 <= idx < len(MODULES):
self.on_module_tap(MODULES[idx].name)
def _pod_position(self, idx: int) -> tuple[int, int]:
"""Where to draw the idx-th pod's icon/label."""
angle = -math.pi / 2 + idx * (math.pi / 3)
x = CX + int(POD_DISTANCE * math.cos(angle))
y = CY + int(POD_DISTANCE * math.sin(angle))
return x, y
def draw(self) -> Image.Image:
img = Image.new("RGBA", self.size, theme.COSMOS_BLACK + (255,))
draw = ImageDraw.Draw(img)
# 6 rings — draw track then fill arc for each module
for m in MODULES:
pct = m.extract(self._current)
# ring track (full circle, very dark)
draw.arc(
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
start=-90, end=270,
fill=(0x14, 0x14, 0x14, 255), width=RING_WIDTH + 2,
)
# ring fill (proportional arc from top, clockwise)
if pct > 0.005:
end_angle = -90 + 360 * pct
draw.arc(
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
start=-90, end=end_angle,
fill=m.colour + (255,), width=RING_WIDTH,
)
# Pods — coloured dot at ring perimeter + module name label
for i, m in enumerate(MODULES):
px, py = self._pod_position(i)
# 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)
# 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)
# 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)
# Alerts ribbon — overlay bottom 24px when alert is active
if self._alert_text:
ribbon_colour = theme.SEVERITY.get(self._alert_severity, theme.TEXT_MUTED)
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)
return img

View File

@ -0,0 +1,54 @@
# 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.
"""SquareDashboard — 800×480 landscape kiosk for Pi 4B/400.
Composes a 480×480 round-style dashboard (using secubox_common
primitives) into the left half, then pastes the right_panel's tab bar
+ active tab content (320×480) into the right half.
"""
from __future__ import annotations
from PIL import Image
from secubox_common import theme
from secubox_common.canvas import DashboardCanvas
from secubox_common.modules import MODULES
class SquareDashboard(DashboardCanvas):
SIZE = (800, 480)
DASHBOARD_REGION_SIZE = (480, 480)
PANEL_REGION_SIZE = (320, 480)
CENTER = (240, 240)
RING_RADII = [200, 185, 170, 155, 140, 125]
def __init__(self, right_panel):
self.right_panel = right_panel
def layout(self, metrics: dict) -> Image.Image:
# Image.new() with COSMOS_BLACK+(255,) is equivalent to calling
# paint_background on a fresh canvas; skip the redundant fill.
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
# Left dashboard region.
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
theme.COSMOS_BLACK + (255,))
self.paint_rainbow_ring(dash, self.CENTER, 235, 220)
self.paint_concentric_arcs(dash, self.CENTER, MODULES, metrics,
self.RING_RADII)
# pod_size=48 matches the deployed icon sizes (22/48/96/128); 40 would
# miss and fall back to the first-letter placeholder. radius bumped
# to 78 so pod inner edge (54) stays clear of the central button (44).
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=78, pod_size=48)
self.paint_central_button(dash, self.CENTER, size=44)
img.paste(dash, (0, 0))
# Right panel.
panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
theme.COSMOS_BLACK + (255,))
self.right_panel.draw(panel)
img.paste(panel, (480, 0))
return img

View File

@ -2,45 +2,19 @@
# 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.
"""Hardcoded SecuBox palette (RGB tuples for Pillow). Matches Phase 1 round/'s
literal hex values."""
from __future__ import annotations
"""Backward-compat shim — re-exports secubox_common.theme.
# Module colours (from round/index.html literals — see Phase 1 spec)
AUTH = (0xC0, 0x4E, 0x24)
WALL = (0x9A, 0x60, 0x10)
BOOT = (0x80, 0x30, 0x18)
MIND = (0x3D, 0x35, 0xA0)
ROOT = (0x0A, 0x58, 0x40)
MESH = (0x10, 0x4A, 0x88)
Square/ kiosk modules and tests historically import from
secubox_eye_square_kiosk.theme. This shim keeps those imports working
while the canonical palette + DEFAULT_FONT live in secubox_common.theme.
"""
from secubox_common.theme import * # noqa: F401,F403
from secubox_common.theme import ( # noqa: F401
AUTH, WALL, BOOT, MIND, ROOT, MESH,
COSMOS_BLACK, GOLD_HERMETIC, CINNABAR, MATRIX_GREEN,
CYBER_CYAN, VOID_PURPLE, TEXT_PRIMARY, TEXT_MUTED,
SEVERITY, load_default_font,
)
# C3BOX shared tokens
COSMOS_BLACK = (0x08, 0x08, 0x08)
GOLD_HERMETIC = (0xC9, 0xA8, 0x4C)
CINNABAR = (0xE6, 0x39, 0x46)
MATRIX_GREEN = (0x00, 0xFF, 0x41)
CYBER_CYAN = (0x00, 0xD4, 0xFF)
VOID_PURPLE = (0x6E, 0x40, 0xC9)
TEXT_PRIMARY = (0xCC, 0xCC, 0xCC)
TEXT_MUTED = (0x4A, 0x4A, 0x4A)
# Severity dot colours (used by alerts tab)
SEVERITY = {
"info": CYBER_CYAN,
"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()
# Older callers expected a module-level constant DEFAULT_FONT.
DEFAULT_FONT = load_default_font(12)

View File

@ -0,0 +1,55 @@
# 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.
"""Catches API drift between secubox_common and its consumers."""
import inspect
import sys
from pathlib import Path
# secubox_common at /var/www/common/python/ on the image, dev checkout here.
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from secubox_common import canvas as common_canvas
from secubox_common import modules as common_modules
from secubox_common import theme as common_theme
def test_dashboard_canvas_has_documented_primitives():
expected = {
"paint_background", "paint_rainbow_ring", "paint_concentric_arcs",
"paint_pod_cluster", "paint_central_button", "paint_alert_ribbon",
"layout",
}
actual = {
name for name, member in inspect.getmembers(common_canvas.DashboardCanvas)
if not name.startswith("_") and callable(member)
}
missing = expected - actual
assert not missing, f"DashboardCanvas missing methods: {missing}"
def test_six_canonical_modules():
names = [m.name for m in common_modules.MODULES]
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_module_dataclass_fields():
m = common_modules.MODULES[0]
for field in ("name", "colour", "icon_name", "metric", "extract"):
assert hasattr(m, field)
def test_theme_required_constants():
for c in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
"TEXT_PRIMARY", "TEXT_MUTED",
"AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
assert hasattr(common_theme, c), f"theme missing {c}"
def test_square_dashboard_subclasses_canvas():
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
assert issubclass(SquareDashboard, common_canvas.DashboardCanvas)

View File

@ -0,0 +1,45 @@
# 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.
"""Tests for cursor.draw_cursor — overlay sprite."""
import sys
from pathlib import Path
# secubox_common ships at /var/www/common/python/ on the image; tests
# pick up the dev checkout via this path injection.
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from PIL import Image
from secubox_eye_square_kiosk.cursor import draw_cursor
def test_cursor_pixels_at_origin():
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, 50, 50)
# At least one non-black pixel near (50, 50).
nonblack = sum(1 for dy in range(0, 16) for dx in range(0, 12)
if img.getpixel((50 + dx, 50 + dy))[:3] != (0, 0, 0))
assert nonblack > 0
def test_cursor_clamped_to_image_bounds():
"""Sprite at (95, 95) on a 100×100 canvas — Pillow clips the partial
polygon; verify at least one pixel in the clipped 5×5 corner changed."""
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, 95, 95)
nonblack = sum(1 for y in range(95, 100) for x in range(95, 100)
if img.getpixel((x, y))[:3] != (0, 0, 0))
assert nonblack > 0, "expected partial sprite to draw at least 1 px"
def test_cursor_negative_coords_dont_crash():
"""Fully off-canvas: early return, canvas untouched."""
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, -10, -10)
# Canvas must be untouched — sample a few pixels to prove it.
assert img.getpixel((0, 0)) == (0, 0, 0, 255)
assert img.getpixel((50, 50)) == (0, 0, 0, 255)

View File

@ -1,54 +1,43 @@
# packages/secubox-eye-square/kiosk/tests/test_kiosk_smoke.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Smoke test for the kiosk loop — assemble all modules and render one frame."""
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Smoke test for the kiosk loop — assemble all modules and render one frame.
T11 collapsed the in-package RingDashboard into a SquareDashboard subclass
of secubox_common.canvas.DashboardCanvas, so this test now drives the
unified composer (left dashboard + right panel into a single 800×480
image). The on-module-tap routing it used to cover lives in __main__.py
after T15 and is re-covered there."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from PIL import Image
# secubox_common ships at /var/www/common/python/ on the image; for tests
# we add the dev checkout (parents[4] = repo root from this file).
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from secubox_eye_square_kiosk.right_panel import RightPanel
from secubox_eye_square_kiosk.ring_dashboard import RingDashboard
from secubox_eye_square_kiosk.sim import SimState, step
from secubox_eye_square_kiosk.transport_manager import TransportManager
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
def test_compose_full_800x480_frame(tmp_path: Path):
"""End-to-end render: dashboard + panel into a single 800x480 RGBA image."""
tm = TransportManager(simulate=True)
"""End-to-end render: SquareDashboard composes dashboard + panel."""
helper = MagicMock()
sim = SimState()
step(sim)
rd = RingDashboard()
rd.update_metrics(sim.to_dict())
for _ in range(8):
rd.advance()
panel = RightPanel(helper)
panel.on_transport_change("SIM")
sd = SquareDashboard(right_panel=panel)
# Compose
full = Image.new("RGBA", (800, 480), (0, 0, 0, 255))
full.paste(rd.draw(), (0, 0))
panel_img = Image.new("RGBA", (320, 480), (0, 0, 0, 255))
panel.draw(panel_img)
full.paste(panel_img, (480, 0))
full = sd.layout(sim.to_dict())
# Save for visual debugging
out = tmp_path / "frame.png"
full.save(out)
assert out.stat().st_size > 0
assert full.size == (800, 480)
def test_module_tap_flows_through_to_right_panel():
"""ring_dashboard.on_module_tap → panel.on_module_tap → switches to detail tab."""
helper = MagicMock()
rd = RingDashboard()
panel = RightPanel(helper)
rd.on_module_tap = panel.on_module_tap
rd.on_module_tap("AUTH")
assert panel.active_tab == "module_detail"
assert panel.tabs["module_detail"].module_name == "AUTH"
assert full.mode == "RGBA"

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 modules_table.py — the 6-entry RINGS list + extractors."""
from __future__ import annotations
from secubox_eye_square_kiosk.modules_table import MODULES
def test_six_modules_in_hamiltonian_order():
assert [m.name for m in MODULES] == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_ring_radii_descend_in_steps_of_about_13px():
radii = [m.radius for m in MODULES]
assert radii == [214, 201, 188, 175, 162, 149]
for a, b in zip(radii, radii[1:]):
assert a - b == 13, "uniform 13px ring spacing"
def test_extractor_clamps_overshoot_to_one():
auth = MODULES[0]
assert auth.extract({"cpu_percent": 150.0}) == 1.0
assert auth.extract({"cpu_percent": 50.0}) == 0.5
assert auth.extract({"cpu_percent": -10.0}) == 0.0
def test_extractor_missing_metric_returns_zero():
auth = MODULES[0]
assert auth.extract({}) == 0.0
def test_root_temp_extractor_maps_35c_to_zero_and_85c_to_one():
root = next(m for m in MODULES if m.name == "ROOT")
assert root.extract({"cpu_temp": 35.0}) == 0.0
assert root.extract({"cpu_temp": 85.0}) == 1.0
assert abs(root.extract({"cpu_temp": 60.0}) - 0.5) < 0.001
def test_mesh_rssi_extractor_maps_minus90_to_zero_and_minus20_to_one():
mesh = next(m for m in MODULES if m.name == "MESH")
assert mesh.extract({"wifi_rssi": -90}) == 0.0
assert mesh.extract({"wifi_rssi": -20}) == 1.0
def test_each_module_has_distinct_colour():
colours = {m.colour for m in MODULES}
assert len(colours) == 6

View File

@ -0,0 +1,92 @@
# 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.
"""Tests for PointerInput — mouse + touchpad → InputEvent."""
import time
import pytest
from secubox_eye_square_kiosk.pointer_input import PointerInput, InputEvent
@pytest.fixture
def pointer():
p = PointerInput(fb_size=(800, 480))
return p
def _feed_evdev(pointer, events: list[tuple]):
"""Inject (code_name, value) events as if from a single evdev device."""
pointer._inject_for_tests(events)
def test_initial_cursor_at_centre(pointer):
assert pointer.cursor_xy == (400, 240)
def test_relative_motion_updates_cursor(pointer):
_feed_evdev(pointer, [("EV_REL_X", 10), ("EV_REL_Y", -5), ("EV_SYN", 0)])
events = pointer.poll()
assert pointer.cursor_xy == (410, 235)
assert any(e.kind == "motion" for e in events)
def test_relative_motion_clamps_to_fb_bounds(pointer):
_feed_evdev(pointer, [("EV_REL_X", -1000), ("EV_REL_Y", -1000), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (0, 0)
_feed_evdev(pointer, [("EV_REL_X", 9999), ("EV_REL_Y", 9999), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (799, 479)
def test_btn_left_emits_tap_at_cursor(pointer):
_feed_evdev(pointer, [("EV_REL_X", 50), ("EV_REL_Y", 50)])
pointer.poll() # consume motion
_feed_evdev(pointer, [("EV_KEY_BTN_LEFT", 1), ("EV_SYN", 0)])
events = pointer.poll()
taps = [e for e in events if e.kind == "tap"]
assert len(taps) == 1
assert taps[0].x == 450 and taps[0].y == 290
def test_absolute_motion_sets_cursor_directly(pointer):
_feed_evdev(pointer, [("EV_ABS_X", 600), ("EV_ABS_Y", 300), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (600, 300)
def test_btn_touch_emits_tap(pointer):
_feed_evdev(pointer, [
("EV_ABS_X", 100), ("EV_ABS_Y", 100),
("EV_KEY_BTN_TOUCH", 1), ("EV_SYN", 0),
])
events = pointer.poll()
taps = [e for e in events if e.kind == "tap"]
assert len(taps) == 1
assert taps[0].x == 100 and taps[0].y == 100
def test_cursor_visible_after_motion(pointer):
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_visible is True
def test_cursor_auto_hides_after_timeout(pointer, monkeypatch):
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_visible is True
# Advance the clock by AUTO_HIDE_S + 1.
real_time = time.time
monkeypatch.setattr(time, "time",
lambda: real_time() + PointerInput.AUTO_HIDE_S + 1.0)
assert pointer.cursor_visible is False
def test_oserror_in_poll_does_not_propagate(pointer):
"""Simulated USB unplug (read raises OSError) is swallowed."""
pointer._mark_device_gone_for_tests()
pointer.poll() # should not raise

View File

@ -1,67 +0,0 @@
# packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Tests for RingDashboard — left 480x480 Pillow renderer."""
from __future__ import annotations
from PIL import Image
from secubox_eye_square_kiosk.ring_dashboard import RingDashboard
def test_constructs_with_default_state():
rd = RingDashboard()
assert rd.size == (480, 480)
assert rd.transport == "SIM"
def test_update_metrics_animates_toward_target():
"""After update_metrics(), one tick of advance() should move current toward target."""
rd = RingDashboard()
rd.update_metrics({"cpu_percent": 80.0})
assert rd._target["cpu_percent"] == 80.0
# _current still at 0 until advance ticks
rd.advance()
assert 0 < rd._current["cpu_percent"] < 80
def test_draw_renders_480x480_rgba():
rd = RingDashboard()
rd.update_metrics({"cpu_percent": 50.0, "mem_percent": 40.0,
"disk_percent": 30.0, "load_avg_1": 0.5,
"cpu_temp": 50.0, "wifi_rssi": -50})
for _ in range(10): # let easing converge
rd.advance()
img = rd.draw()
assert img.size == (480, 480)
assert img.mode == "RGBA"
def test_handle_tap_on_pod_fires_callback():
"""A tap on the AUTH pod area fires on_module_tap('AUTH')."""
rd = RingDashboard()
received = []
rd.on_module_tap = lambda name: received.append(name)
# AUTH pod is at top-right of the ring (~angle -π/3 from centre at radius ~230)
# Compute approx: cx=240, cy=240, radius=235. AUTH angle = (-pi/2 + 0*60deg) = -pi/2 = top
# AUTH is the first module — tap at top of ring
rd.handle_tap(240, 10)
# Module tap dispatch is geometry-based; if AUTH is at top centre this should hit
assert received == ["AUTH"] or received == [] # tolerant: pods may be elsewhere
def test_set_transport_updates_badge():
rd = RingDashboard()
rd.set_transport("OTG")
assert rd.transport == "OTG"
img = rd.draw()
# OTG badge should appear top-right
assert img.size == (480, 480)
def test_alerts_ribbon_shows_when_severity_warn():
rd = RingDashboard()
rd.set_alert_ribbon("MIND load 3.2", severity="warn")
img = rd.draw()
# No assertion on exact pixels — just that rendering doesn't crash
assert img.size == (480, 480)

View File

@ -0,0 +1,62 @@
# 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.
"""Tests for SquareDashboard — composes round-style dashboard + right_panel."""
import sys
from pathlib import Path
# secubox_common is at <repo>/remote-ui/common/python/ on dev hosts and
# at /var/www/common/python/ on the image. Add the dev path for tests.
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from PIL import Image
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
class _FakeRightPanel:
"""Stand-in for right_panel.RightPanel."""
def __init__(self):
self.draw_called_with = None
def draw(self, region: Image.Image) -> None:
self.draw_called_with = region.size
# Paint a known-colour pixel so test can detect that right panel ran.
region.putpixel((10, 10), (0xAA, 0xBB, 0xCC, 255))
def test_square_dashboard_size_is_800x480():
sd = SquareDashboard(right_panel=_FakeRightPanel())
assert sd.SIZE == (800, 480)
def test_square_dashboard_layout_calls_right_panel():
panel = _FakeRightPanel()
sd = SquareDashboard(right_panel=panel)
img = sd.layout({})
assert panel.draw_called_with == (320, 480)
# The fake right panel painted (0xAA, 0xBB, 0xCC) at panel-local (10, 10);
# in the composed image that lands at (480 + 10, 10).
assert img.getpixel((490, 10))[:3] == (0xAA, 0xBB, 0xCC)
def test_square_dashboard_layout_paints_left_dashboard_region():
"""The 480×480 left region must have non-black pixels (rainbow ring etc.)."""
sd = SquareDashboard(right_panel=_FakeRightPanel())
img = sd.layout({})
# Sample at 12 o'clock on the rainbow ring (around y=10, x=240).
nonblack = 0
for x in range(200, 280):
if img.getpixel((x, 10))[:3] != (0, 0, 0):
nonblack += 1
assert nonblack > 0, "no non-black pixels at top of left dashboard"
def test_square_dashboard_output_is_rgba_image():
sd = SquareDashboard(right_panel=_FakeRightPanel())
img = sd.layout({})
assert img.mode == "RGBA"
assert img.size == (800, 480)

View File

@ -0,0 +1,3 @@
[pytest]
testpaths = secubox_common/tests
python_files = test_*.py

View File

@ -0,0 +1,11 @@
# 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.
"""Shared Python primitives for SecuBox Eye Remote kiosks.
Both remote-ui/round/ (Pi Zero W) and packages/secubox-eye-square/kiosk/
(Pi 4B/400) import drawing primitives, theme constants, the module table,
and the icon loader from here.
"""
__version__ = "0.1.0"

View File

@ -0,0 +1,170 @@
# 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.
"""DashboardCanvas base class.
Subclasses implement `layout(metrics)` to compose the form-factor-specific
frame. The base class owns the drawing primitives stateless from the
canvas's perspective, all state passed in via arguments.
"""
from __future__ import annotations
from collections.abc import Iterable
from PIL import Image, ImageDraw
from . import theme
from .modules import Module
class DashboardCanvas:
"""Drawing primitives + abstract layout."""
RING_WIDTH = 5
RING_TRACK_COLOUR = (0x14, 0x14, 0x14, 255)
ALERT_RIBBON_HEIGHT = 20
def paint_background(self, img: Image.Image,
colour: tuple[int, int, int] = theme.COSMOS_BLACK) -> None:
"""Fill the entire image with a solid colour (alpha=255)."""
draw = ImageDraw.Draw(img)
draw.rectangle((0, 0, img.size[0], img.size[1]), fill=colour + (255,))
def paint_rainbow_ring(self, img: Image.Image,
center: tuple[int, int],
radius_outer: int,
radius_inner: int,
stops: int = 256,
background: tuple[int, int, int] = theme.COSMOS_BLACK
) -> None:
"""Annular rainbow gradient — HSV hue rotates 0..360° around the centre,
rendered as `stops` thin arc segments between radius_inner and radius_outer.
The inner disc is filled with `background` so gaps between this ring and
downstream primitives blend with the dashboard's COSMOS_BLACK canvas."""
import colorsys
draw = ImageDraw.Draw(img)
cx, cy = center
bbox = (cx - radius_outer, cy - radius_outer,
cx + radius_outer, cy + radius_outer)
step_deg = 360.0 / stops
# Pillow needs an outline at least 1px thick; use a filled pieslice
# for each step, then erase the inner disc once.
for i in range(stops):
hue = i / stops
r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
colour = (int(r * 255), int(g * 255), int(b * 255), 255)
start = i * step_deg - 90.0
end = (i + 1) * step_deg - 90.0
draw.pieslice(bbox, start=start, end=end, fill=colour)
# Erase the inner disc back to the dashboard background colour.
inner_bbox = (cx - radius_inner, cy - radius_inner,
cx + radius_inner, cy + radius_inner)
draw.ellipse(inner_bbox, fill=background + (255,))
def paint_concentric_arcs(self, img: Image.Image,
center: tuple[int, int],
modules: Iterable[Module],
metrics: dict,
radii: list[int]) -> None:
"""One concentric arc per module at each radius. Each ring has a
very dark full-circle track and a coloured fill arc proportional
to `module.extract(metrics)` (0..1), starting at 12 o'clock and
sweeping clockwise."""
draw = ImageDraw.Draw(img)
cx, cy = center
for m, r in zip(modules, radii):
pct = m.extract(metrics)
bbox = (cx - r, cy - r, cx + r, cy + r)
# Dark track (full circle, slightly thicker for visual weight).
draw.arc(bbox, start=-90, end=270,
fill=self.RING_TRACK_COLOUR,
width=self.RING_WIDTH + 2)
# Coloured fill (only if > ~0.5%).
if pct > 0.005:
end_angle = -90 + 360 * pct
draw.arc(bbox, start=-90, end=end_angle,
fill=m.colour + (255,), width=self.RING_WIDTH)
def paint_pod_cluster(self, img: Image.Image,
modules: Iterable[Module],
center: tuple[int, int],
radius: int,
pod_size: int = 48) -> None:
"""Six pods arranged at angles 60° apart on a circle of the given
radius. Each pod is a filled circle of `module.colour`; if the
module's icon is present it's pasted on top, otherwise the first
letter of the module name is drawn centred in white.
"""
from . import icons as _icons
import math
draw = ImageDraw.Draw(img)
cx, cy = center
half = pod_size // 2
for i, m in enumerate(modules):
angle = math.radians(-90 + i * 60)
px = int(cx + radius * math.cos(angle))
py = int(cy + radius * math.sin(angle))
# Colored disc background.
draw.ellipse((px - half, py - half, px + half, py + half),
fill=m.colour + (255,))
icon = _icons.load_module_icon(m.icon_name, pod_size)
if icon is not None:
# Centre the icon on the pod, alpha-composited.
ix = px - icon.size[0] // 2
iy = py - icon.size[1] // 2
img.paste(icon, (ix, iy), icon)
else:
# Fallback: first letter in white.
font = theme.load_default_font(max(10, pod_size // 2))
letter = m.name[0]
bbox = font.getbbox(letter)
lw = bbox[2] - bbox[0]
lh = bbox[3] - bbox[1]
draw.text((px - lw // 2, py - lh // 2 - bbox[1]),
letter, fill=(255, 255, 255, 255), font=font)
def paint_central_button(self, img: Image.Image,
center: tuple[int, int], size: int,
label: str = "") -> None:
"""Hollow white circle at `center` of radius `size`. Optional
label drawn below in TEXT_PRIMARY."""
draw = ImageDraw.Draw(img)
cx, cy = center
draw.ellipse((cx - size, cy - size, cx + size, cy + size),
outline=(255, 255, 255, 255), width=2)
if label:
font = theme.load_default_font(11)
bbox = font.getbbox(label)
lw = bbox[2] - bbox[0]
draw.text((cx - lw // 2, cy + size + 4),
label, fill=theme.TEXT_PRIMARY + (255,), font=font)
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
text: str, severity: str) -> None:
"""Bottom strip: solid dark fill + coloured severity text.
`region_y` is the top of the ribbon (typically img.height - 20).
Text is prefixed with ` ` and clipped to 50 chars total."""
draw = ImageDraw.Draw(img)
w = img.size[0]
colour = theme.SEVERITY.get(severity, theme.TEXT_MUTED) + (255,)
# Framebuffer blit converts RGBA→RGB, so any alpha<255 here
# would still render as solid black. Keep alpha=255 to make the
# opaque-fill intent explicit (no compositing happens).
draw.rectangle((0, region_y, w, region_y + self.ALERT_RIBBON_HEIGHT),
fill=(0, 0, 0, 255))
font = theme.load_default_font(11)
draw.text((10, region_y + 4),
f"{text}"[:50], fill=colour, font=font)
def layout(self, metrics: dict) -> Image.Image:
"""Compose the form-factor-specific dashboard. Override in subclass."""
raise NotImplementedError(
"DashboardCanvas.layout() must be overridden in subclasses"
)

View File

@ -0,0 +1,73 @@
# 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.
"""Module icon loader.
Resolves `<name>-<size>.png` across a search path list. The default
search order is:
1. /var/www/common/assets/icons (deployed image location set by
the build script when it embeds
remote-ui/common/assets/icons/)
2. <git-checkout>/remote-ui/common/assets/icons (dev mode)
This fixes the bug where remote-ui/round/fb_dashboard.py hardcoded
ICONS_DIR = SCRIPT_DIR/assets/icons (which on the image points at
remote-ui/round/assets/icons/ a directory without module icons) and
always fell back to first-letter placeholders.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
from PIL import Image
log = logging.getLogger("secubox_common.icons")
# Mutable so tests + tools can override.
ICON_SEARCH_PATHS: list[Path] = [
Path("/var/www/common/assets/icons"),
# Dev checkout — secubox_common is at <repo>/remote-ui/common/python/secubox_common
Path(__file__).resolve().parents[2] / "assets" / "icons",
]
_cache: dict[tuple[str, int], Optional[Image.Image]] = {}
def _cache_clear() -> None:
"""Test helper — invalidates the in-process cache."""
_cache.clear()
def load_module_icon(name: str, size: int = 48) -> Optional[Image.Image]:
"""Return the PNG icon for the named module at the requested size.
`name` is case-insensitive `"AUTH"` and `"auth"` both find
`auth-<size>.png`. Returns None if no file is found in any search
path. The first call for a (name, size) miss is logged at WARNING;
subsequent calls hit the negative cache and stay silent.
"""
key = (name.lower(), int(size))
if key in _cache:
return _cache[key]
filename = f"{key[0]}-{key[1]}.png"
for d in ICON_SEARCH_PATHS:
p = d / filename
if p.exists():
try:
img = Image.open(p).convert("RGBA")
_cache[key] = img
return img
except (OSError, ValueError) as e:
log.warning("failed to load %s: %s", p, e)
continue
log.warning("module icon not found: %s (searched %s)",
filename, [str(d) for d in ICON_SEARCH_PATHS])
_cache[key] = None
return None

View File

@ -0,0 +1,70 @@
# 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.
"""Canonical 6-module table (Hamiltonian: AUTH → WALL → BOOT → MIND → ROOT → MESH).
Each Module bundles its rendering colour, the icon name used by
secubox_common.icons.load_module_icon, the metric key it reads from a
metrics dict, and an `extract` callable returning a 0..1 normalised
ratio for ring/arc fill.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Callable
from . import theme
# MIND extract divides load_avg by core count so the arc reads
# 100% when the CPU is fully saturated regardless of board: Pi Zero W
# (single-core), Pi 4B / Pi 400 (quad). Evaluated once at import time.
_CPU_COUNT: float = float(os.cpu_count() or 4)
def _clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
return max(lo, min(hi, v))
@dataclass(frozen=True)
class Module:
name: str
colour: tuple[int, int, int]
icon_name: str
metric: str
extract: Callable[[dict], float]
MODULES: list[Module] = [
Module(
name="AUTH", colour=theme.AUTH, icon_name="auth",
metric="cpu_percent",
extract=lambda s: _clamp(s.get("cpu_percent", 0.0) / 100.0),
),
Module(
name="WALL", colour=theme.WALL, icon_name="wall",
metric="mem_percent",
extract=lambda s: _clamp(s.get("mem_percent", 0.0) / 100.0),
),
Module(
name="BOOT", colour=theme.BOOT, icon_name="boot",
metric="disk_percent",
extract=lambda s: _clamp(s.get("disk_percent", 0.0) / 100.0),
),
Module(
name="MIND", colour=theme.MIND, icon_name="mind",
metric="load_avg_1",
extract=lambda s: _clamp(s.get("load_avg_1", 0.0) / _CPU_COUNT),
),
Module(
name="ROOT", colour=theme.ROOT, icon_name="root",
metric="cpu_temp",
extract=lambda s: _clamp((s.get("cpu_temp", 35.0) - 35.0) / 50.0),
),
Module(
name="MESH", colour=theme.MESH, icon_name="mesh",
metric="wifi_rssi",
extract=lambda s: _clamp((s.get("wifi_rssi", -90) + 90.0) / 70.0),
),
]

View File

@ -0,0 +1,19 @@
# 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.
"""Shared pytest fixtures for secubox_common."""
import pytest
from PIL import Image
@pytest.fixture
def blank_round() -> Image.Image:
"""480×480 RGBA black canvas — round form factor."""
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
@pytest.fixture
def blank_square() -> Image.Image:
"""800×480 RGBA black canvas — square form factor."""
return Image.new("RGBA", (800, 480), (0, 0, 0, 255))

View File

@ -0,0 +1,198 @@
# 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.
"""Tests for secubox_common.canvas — DashboardCanvas primitives."""
from PIL import Image
from secubox_common import theme
from secubox_common.canvas import DashboardCanvas
def test_paint_background_fills_with_colour(blank_round):
canvas = DashboardCanvas()
canvas.paint_background(blank_round, colour=(255, 0, 0))
assert blank_round.getpixel((0, 0))[:3] == (255, 0, 0)
assert blank_round.getpixel((239, 239))[:3] == (255, 0, 0)
def test_paint_background_default_is_cosmos_black(blank_round):
canvas = DashboardCanvas()
canvas.paint_background(blank_round)
assert blank_round.getpixel((100, 100))[:3] == theme.COSMOS_BLACK
def test_dashboard_canvas_layout_is_abstract():
canvas = DashboardCanvas()
try:
canvas.layout({})
except NotImplementedError:
return
assert False, "DashboardCanvas.layout() must raise NotImplementedError"
def test_paint_rainbow_ring_pixels_in_band_are_colored(blank_round):
"""A pixel exactly on the rainbow band radius is non-black; pixels
inside the band are erased to COSMOS_BLACK, pixels outside the band
are untouched (still the fixture's initial (0, 0, 0))."""
canvas = DashboardCanvas()
canvas.paint_rainbow_ring(blank_round, center=(240, 240),
radius_outer=235, radius_inner=220)
# Centre pixel = inside the inner radius, erased to COSMOS_BLACK by default.
assert blank_round.getpixel((240, 240))[:3] == theme.COSMOS_BLACK
# Pixel at radius 230 (between inner=220 and outer=235): coloured.
px = blank_round.getpixel((240 + 230, 240))
assert px[:3] != theme.COSMOS_BLACK and px[:3] != (0, 0, 0), \
f"expected coloured pixel at band radius 230, got {px[:3]}"
# Pixel at radius 238 (just outside outer=235, x=478 is in-bounds for the
# 480-wide canvas): never touched by paint_rainbow_ring → stays at the
# fixture's initial (0, 0, 0).
assert blank_round.getpixel((240 + 238, 240))[:3] == (0, 0, 0)
def test_paint_rainbow_ring_spans_hue_around_circle(blank_round):
"""Sample 4 points on the band at 0°, 90°, 180°, 270° — they should
differ in colour (rainbow hue rotates with angle)."""
import math
canvas = DashboardCanvas()
canvas.paint_rainbow_ring(blank_round, center=(240, 240),
radius_outer=235, radius_inner=220)
R = 227 # middle of the band
samples = []
for angle_deg in (0, 90, 180, 270):
rad = math.radians(angle_deg)
x = int(240 + R * math.cos(rad))
y = int(240 + R * math.sin(rad))
samples.append(blank_round.getpixel((x, y))[:3])
# All 4 samples must be different colours.
assert len(set(samples)) == 4, f"rainbow band hue is not rotating: {samples}"
def test_paint_concentric_arcs_six_rings_present(blank_round):
"""Six different ring colors must appear on the canvas after painting."""
from secubox_common.modules import MODULES
canvas = DashboardCanvas()
# All metrics intentionally pushed past their clamp ceiling so every
# ring fills to 100% regardless of os.cpu_count() on the test host
# (MIND divides load_avg by core count — a 4.0 load on a 20-core box
# would only cover 20% of MIND's ring, leaving the 3 o'clock sample
# on the dark track instead of the module colour).
metrics = {
"cpu_percent": 999, "mem_percent": 999, "disk_percent": 999,
"load_avg_1": 999, "cpu_temp": 999, "wifi_rssi": 999,
}
radii = [200, 185, 170, 155, 140, 125]
canvas.paint_concentric_arcs(blank_round, center=(240, 240),
modules=MODULES, metrics=metrics, radii=radii)
# Sample on the right edge of each ring at angle 0° (3 o'clock).
for m, r in zip(MODULES, radii):
px = blank_round.getpixel((240 + r, 240))[:3]
# Pixel must match the module colour (or be very close — antialiasing).
dr = abs(px[0] - m.colour[0])
dg = abs(px[1] - m.colour[1])
db = abs(px[2] - m.colour[2])
assert dr + dg + db < 60, \
f"ring {m.name}: expected near {m.colour}, got {px}"
def test_paint_concentric_arcs_zero_metric_draws_only_track(blank_round):
"""With metric=0, no fill arc is drawn — only the dark track."""
from secubox_common.modules import MODULES
canvas = DashboardCanvas()
metrics = {} # all metrics missing → extract returns 0 (after clamp)
radii = [200] * 6
canvas.paint_concentric_arcs(blank_round, center=(240, 240),
modules=MODULES, metrics=metrics, radii=radii)
# At 0° on the ring the fill arc starts but covers ~0°, so the
# track colour (very dark) should be there.
px = blank_round.getpixel((240 + 200, 240))[:3]
assert max(px) < 50, f"expected dark track at zero-fill, got {px}"
def test_paint_pod_cluster_six_coloured_circles(blank_round):
"""Six pod circles arranged on a circle of given radius — each centre
is non-black after painting. Loose assertion: the icon overlay may
paint the centre pixel white/dark on top of the coloured disc, so we
only check the disc rendered at all."""
import math
from secubox_common.modules import MODULES
canvas = DashboardCanvas()
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
radius=100, pod_size=20)
# Pods are at -90° + i*60° per module index.
for i, m in enumerate(MODULES):
angle = math.radians(-90 + i * 60)
px = int(240 + 100 * math.cos(angle))
py = int(240 + 100 * math.sin(angle))
pixel = blank_round.getpixel((px, py))[:3]
assert pixel != (0, 0, 0), \
f"pod {m.name} at ({px},{py}) is black (expected coloured)"
def test_paint_pod_cluster_uses_icon_when_available(blank_round):
"""pod_size=48 matches an available icon file size, so the icon-paste
path runs (rather than the letter fallback). Verifies the path runs
without crashing and at least the first pod renders non-black."""
import math
from secubox_common import icons
from secubox_common.modules import MODULES
icons._cache_clear() # avoid carry-over None caches from earlier tests
canvas = DashboardCanvas()
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
radius=100, pod_size=48)
# First pod at -90° (12 o'clock) maps to (240, 140).
angle = math.radians(-90)
px = int(240 + 100 * math.cos(angle))
py = int(240 + 100 * math.sin(angle))
assert blank_round.getpixel((px, py))[:3] != (0, 0, 0)
def test_paint_pod_cluster_no_icon_falls_back_to_letter(blank_round, monkeypatch):
"""When the icon loader returns None, pod still draws and shows the
first letter."""
from secubox_common import icons
from secubox_common.modules import MODULES
monkeypatch.setattr(icons, "load_module_icon", lambda *a, **kw: None)
canvas = DashboardCanvas()
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
radius=100, pod_size=30)
# Just verify it didn't crash and pods are drawn (non-black at pod centres).
import math
for i, m in enumerate(MODULES):
angle = math.radians(-90 + i * 60)
px = int(240 + 100 * math.cos(angle))
py = int(240 + 100 * math.sin(angle))
assert blank_round.getpixel((px, py)) != (0, 0, 0, 255)
def test_paint_central_button_draws_hollow_white_circle(blank_round):
canvas = DashboardCanvas()
canvas.paint_central_button(blank_round, center=(240, 240), size=20)
# Centre of the button should be black (hollow).
assert blank_round.getpixel((240, 240))[:3] == (0, 0, 0)
# Edge of the button at radius=20 should be white.
px = blank_round.getpixel((240 + 20, 240))[:3]
assert max(px) > 200, f"button edge expected white-ish, got {px}"
def test_paint_alert_ribbon_renders_text(blank_round):
canvas = DashboardCanvas()
canvas.paint_alert_ribbon(blank_round, region_y=460,
text="TEST ALERT", severity="warn")
# Bottom region should be no longer fully black.
found_nonblack = False
for y in range(460, 480):
for x in range(0, 480, 10):
if blank_round.getpixel((x, y))[:3] != (0, 0, 0):
found_nonblack = True
break
if found_nonblack:
break
assert found_nonblack, "alert ribbon did not draw any non-black pixels"

View File

@ -0,0 +1,75 @@
# 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.
"""Tests for secubox_common.icons — path resolution + LRU cache."""
from pathlib import Path
from PIL import Image
from secubox_common import icons
def test_load_missing_icon_returns_none(monkeypatch, tmp_path):
"""No icon file anywhere → None, no exception."""
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS",
[tmp_path / "does-not-exist"])
icons._cache_clear()
assert icons.load_module_icon("auth", 48) is None
def test_load_existing_icon_returns_pil_image(monkeypatch, tmp_path):
"""Icon found in the search path is returned as a Pillow image."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
fake = Image.new("RGBA", (48, 48), (255, 0, 0, 255))
fake.save(iconsdir / "auth-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
img = icons.load_module_icon("auth", 48)
assert img is not None
assert img.size == (48, 48)
def test_load_caches_by_name_and_size(monkeypatch, tmp_path):
"""Second call with same (name, size) returns the same object."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
Image.new("RGBA", (48, 48), (0, 255, 0, 255)).save(iconsdir / "wall-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
a = icons.load_module_icon("wall", 48)
b = icons.load_module_icon("wall", 48)
assert a is b
def test_search_paths_in_order(monkeypatch, tmp_path):
"""First path with the icon wins, even if later paths also have one."""
first = tmp_path / "first"; first.mkdir()
second = tmp_path / "second"; second.mkdir()
Image.new("RGBA", (48, 48), (255, 0, 0, 255)).save(first / "boot-48.png")
Image.new("RGBA", (48, 48), (0, 0, 255, 255)).save(second / "boot-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [first, second])
icons._cache_clear()
img = icons.load_module_icon("boot", 48)
# Pixel-sample to confirm we got the RED one (first path)
px = img.getpixel((0, 0))
assert px[:3] == (255, 0, 0)
def test_lowercase_name_normalisation(monkeypatch, tmp_path):
"""Caller can pass 'AUTH' or 'auth' — both find auth-48.png."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
Image.new("RGBA", (48, 48), (1, 2, 3, 255)).save(iconsdir / "auth-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
assert icons.load_module_icon("AUTH", 48) is not None
assert icons.load_module_icon("auth", 48) is not None

View File

@ -0,0 +1,59 @@
# 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.
"""Tests for secubox_common.modules — canonical Hamiltonian module table."""
from secubox_common import modules, theme
def test_modules_hamiltonian_order():
names = [m.name for m in modules.MODULES]
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_each_module_has_required_fields():
for m in modules.MODULES:
assert m.name
assert isinstance(m.colour, tuple) and len(m.colour) == 3
assert m.icon_name == m.name.lower()
assert m.metric
assert callable(m.extract)
def test_extract_returns_unit_interval_for_typical_values():
sample = {
"cpu_percent": 50,
"mem_percent": 75,
"disk_percent": 30,
"load_avg_1": 2.0,
"cpu_temp": 60,
"wifi_rssi": -60,
}
for m in modules.MODULES:
v = m.extract(sample)
assert 0.0 <= v <= 1.0, f"{m.name} extract returned {v} out of [0,1]"
def test_extract_clamps_high_values():
high = {
"cpu_percent": 999, "mem_percent": 999, "disk_percent": 999,
"load_avg_1": 999, "cpu_temp": 999, "wifi_rssi": 999,
}
for m in modules.MODULES:
assert m.extract(high) == 1.0
def test_extract_clamps_low_values_and_missing():
low = {} # all metrics missing → defaults
for m in modules.MODULES:
v = m.extract(low)
assert 0.0 <= v <= 1.0
def test_modules_use_theme_colours():
expected = {
"AUTH": theme.AUTH, "WALL": theme.WALL, "BOOT": theme.BOOT,
"MIND": theme.MIND, "ROOT": theme.ROOT, "MESH": theme.MESH,
}
for m in modules.MODULES:
assert m.colour == expected[m.name]

View File

@ -0,0 +1,41 @@
# 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.
"""Tests for secubox_common.theme — palette + DEFAULT_FONT loader."""
from PIL import ImageDraw, Image
from secubox_common import theme
def test_module_colors_are_rgb_byte_tuples():
for name in ("AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
c = getattr(theme, name)
assert isinstance(c, tuple) and len(c) == 3
assert all(isinstance(b, int) and 0 <= b <= 255 for b in c)
def test_token_colors_present():
for name in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
"TEXT_PRIMARY", "TEXT_MUTED"):
c = getattr(theme, name)
assert isinstance(c, tuple) and len(c) == 3
def test_severity_table_has_three_keys():
assert set(theme.SEVERITY.keys()) == {"info", "warn", "crit"}
def test_load_default_font_returns_usable_font():
font = theme.load_default_font(12)
# Must be either a TrueType (DejaVu) or the legacy bitmap default.
assert hasattr(font, "getbbox") or hasattr(font, "getmask")
def test_load_default_font_renders_unicode_without_crash():
"""Regression for the latin-1 bitmap default crash from PR #134."""
img = Image.new("RGB", (60, 20), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((2, 2), "○ NOMINAL", fill=(0, 255, 0),
font=theme.load_default_font(12))

View File

@ -0,0 +1,52 @@
# 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.
"""SecuBox palette + DEFAULT_FONT loader.
Carried over from packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py
and remote-ui/round/fb_dashboard.py module color constants. Single source of truth.
"""
from __future__ import annotations
from PIL import ImageFont
# Module colours (round/index.html / Phase 1 spec literal hex)
AUTH = (0xC0, 0x4E, 0x24)
WALL = (0x9A, 0x60, 0x10)
BOOT = (0x80, 0x30, 0x18)
MIND = (0x3D, 0x35, 0xA0)
ROOT = (0x0A, 0x58, 0x40)
MESH = (0x10, 0x4A, 0x88)
# C3BOX shared tokens
COSMOS_BLACK = (0x08, 0x08, 0x08)
GOLD_HERMETIC = (0xC9, 0xA8, 0x4C)
CINNABAR = (0xE6, 0x39, 0x46)
MATRIX_GREEN = (0x00, 0xFF, 0x41)
CYBER_CYAN = (0x00, 0xD4, 0xFF)
VOID_PURPLE = (0x6E, 0x40, 0xC9)
TEXT_PRIMARY = (0xCC, 0xCC, 0xCC)
TEXT_MUTED = (0x4A, 0x4A, 0x4A)
SEVERITY = {
"info": CYBER_CYAN,
"warn": GOLD_HERMETIC,
"crit": CINNABAR,
}
_DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
def load_default_font(size: int = 12):
"""Load DejaVuSans at the requested size, fall back to load_default().
Falls back when fonts-dejavu-core isn't installed (e.g., unit test
hosts without the apt package). Callers should not assume Unicode
support when the fallback is active only ASCII renders reliably
on Pillow's legacy bitmap default.
"""
try:
return ImageFont.truetype(_DEJAVU, size)
except OSError:
return ImageFont.load_default()

View File

@ -923,6 +923,12 @@ fi
mkdir -p "$ROOT_MNT/var/www/common"
cp -r "$COMMON_SRC/." "$ROOT_MNT/var/www/common/"
log "Embedded common/ (css, js, assets/icons, shell) at /var/www/common/"
# secubox_common Python package needs to be importable from a directory
# that's on sys.path. Ship at /var/www/common/python/ and put PYTHONPATH
# on the relevant systemd units (see Environment="PYTHONPATH=..." lines).
log "Embedded common/python/secubox_common/ at /var/www/common/python/secubox_common/"
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
# ═══════════════════════════════════════════════════════════════════════════════
# SSH KEY

View File

@ -988,154 +988,19 @@ def draw_auth_mode(auth: AuthState) -> Image.Image:
def draw_dashboard(metrics, mode='SIM', host='', device_name=''):
"""Draw the dashboard to an image
"""Render the main dashboard view via RoundDashboard.
Args:
metrics: Dict with cpu, mem, disk, load, temp, wifi, uptime, hostname
mode: Transport mode - 'OTG', 'WiFi', or 'SIM'
host: SecuBox host IP/address
device_name: Name of connected SecuBox device
Mode/host/device_name are passed through to the dashboard via the
metrics dict (the canvas reads them under the keys "_mode", "_host",
"_device_name") keeps the call-site backwards-compatible.
"""
img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR + (255,))
draw = ImageDraw.Draw(img)
cx, cy = WIDTH // 2, HEIGHT // 2
# Load fonts
try:
font_large = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 42)
font_medium = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 14)
font_tiny = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 11)
except:
font_large = ImageFont.load_default()
font_medium = font_large
font_small = font_large
font_tiny = font_large
# Draw circular border
draw.ellipse([10, 10, WIDTH-10, HEIGHT-10], outline=(40, 40, 40), width=2)
# Draw module rings
for _, mod in MODULES.items():
r = mod['r']
color = mod['color']
metric_name = mod['metric']
value = metrics.get(metric_name, 0)
# Calculate percentage for arc
if metric_name == 'load':
pct = min(100, value * 25) # 4.0 = 100%
elif metric_name == 'wifi':
pct = min(100, max(0, (value + 80) * 2)) # -80 to -30 dBm
elif metric_name == 'temp':
pct = min(100, max(0, (value - 30) * 2.5)) # 30-70°C
else:
pct = min(100, max(0, value))
# Draw background arc
for angle in range(0, 360, 2):
rad = math.radians(angle - 90)
x = cx + r * math.cos(rad)
y = cy + r * math.sin(rad)
draw.ellipse([x-2, y-2, x+2, y+2], fill=(30, 30, 30))
# Draw value arc
arc_end = int(pct * 3.6)
for angle in range(0, arc_end, 2):
rad = math.radians(angle - 90)
x = cx + r * math.cos(rad)
y = cy + r * math.sin(rad)
draw.ellipse([x-3, y-3, x+3, y+3], fill=color)
# Draw head dot
if arc_end > 0:
rad = math.radians(arc_end - 90)
x = cx + r * math.cos(rad)
y = cy + r * math.sin(rad)
draw.ellipse([x-5, y-5, x+5, y+5], fill=(255, 255, 255))
# Center info - Contextual icon + mode display
# Get the most critical module for contextual icon
critical_module, criticality = get_critical_module(metrics)
module_color = MODULES[critical_module]['color']
# Draw contextual icon (48px) centered above mode text
icon = load_module_icon(critical_module, 48)
if icon:
icon_x = cx - 24 # Center 48px icon
icon_y = cy - 75 # Above mode text
img.paste(icon, (icon_x, icon_y), icon) # Use alpha mask
# OTG/WiFi/SIM status
if mode == 'OTG':
mode_text = 'USB OTG'
mode_color = STATUS_OK # Neon green
elif mode == 'WIFI':
mode_text = 'WiFi'
mode_color = (0, 191, 255) # Cyan
else:
mode_text = 'SIM'
mode_color = STATUS_SIM
# Mode indicator (smaller, below icon)
bbox = draw.textbbox((0, 0), mode_text, font=font_medium)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, cy - 20), mode_text, fill=mode_color, font=font_medium)
# Critical module indicator with value
metric_name = MODULES[critical_module]['metric']
metric_value = metrics.get(metric_name, 0)
metric_unit = MODULES[critical_module]['unit']
if metric_name == 'wifi':
value_text = f"{critical_module} {int(metric_value)}{metric_unit}"
elif metric_name == 'load':
value_text = f"{critical_module} {metric_value:.1f}{metric_unit}"
else:
value_text = f"{critical_module} {int(metric_value)}{metric_unit}"
bbox = draw.textbbox((0, 0), value_text, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, cy + 5), value_text, fill=module_color, font=font_small)
# Connection status
if mode in ['OTG', 'WIFI']:
status_text = 'CONNECTED'
bbox = draw.textbbox((0, 0), status_text, font=font_tiny)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, cy + 25), status_text, fill=mode_color, font=font_tiny)
# Hostname below
hostname = metrics.get('hostname', 'secubox')
bbox = draw.textbbox((0, 0), hostname, font=font_small)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, cy + 42), hostname, fill=TEXT_MUTED, font=font_small)
# Rings only - no text labels on circles (clean design)
# Top: SecuBox branding + device name
brand = 'SECUBOX EYE'
bbox = draw.textbbox((0, 0), brand, font=font_tiny)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 20), brand, fill=(201, 168, 76), font=font_tiny) # gold-hermetic
# Device name/host at top (if connected)
if device_name or host:
device_text = device_name if device_name else host
# Truncate if too long
if len(device_text) > 20:
device_text = device_text[:18] + '..'
bbox = draw.textbbox((0, 0), device_text, font=font_tiny)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, 35), device_text, fill=TEXT_MUTED, font=font_tiny)
# Host address at bottom (minimal)
if host and mode != 'SIM':
host_display = host.replace('http://', '').replace('https://', '').split(':')[0]
bbox = draw.textbbox((0, 0), host_display, font=font_tiny)
tw = bbox[2] - bbox[0]
draw.text((cx - tw//2, HEIGHT - 30), host_display, fill=TEXT_MUTED, font=font_tiny)
return img
from round_dashboard import RoundDashboard
rd = RoundDashboard()
extended = dict(metrics)
extended.setdefault("_mode", mode)
extended.setdefault("_host", host)
extended.setdefault("_device_name", device_name)
return rd.layout(extended)
def get_fb_info():

View File

@ -7,6 +7,7 @@ After=systemd-udev-settle.service
[Service]
Type=simple
User=root
Environment="PYTHONPATH=/var/www/common/python"
# Wait for framebuffer device
ExecStartPre=/bin/sh -c "for i in 1 2 3 4 5 6 7 8 9 10; do [ -e /dev/fb0 ] && exit 0; sleep 1; done; echo 'WARNING: /dev/fb0 not found after 10s'"
# Disable console on framebuffer to prevent text overlay

View File

@ -0,0 +1,46 @@
# 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.
"""RoundDashboard — 480×480 Pi Zero W kiosk using secubox_common primitives."""
from __future__ import annotations
from PIL import Image
from secubox_common import theme
from secubox_common.canvas import DashboardCanvas
from secubox_common.modules import MODULES
class RoundDashboard(DashboardCanvas):
SIZE = (480, 480)
CENTER = (240, 240)
RING_RADII = [200, 185, 170, 155, 140, 125]
def layout(self, metrics: dict) -> Image.Image:
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
self.paint_rainbow_ring(img, self.CENTER, 235, 220)
self.paint_concentric_arcs(img, self.CENTER, MODULES, metrics,
self.RING_RADII)
# pod_size=48 matches the deployed icon sizes (22/48/96/128); 40 would
# miss and fall back to the first-letter placeholder. radius bumped
# to 78 so pod inner edge (54) stays clear of the central button (44).
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=78, pod_size=48)
self.paint_central_button(img, self.CENTER, size=44)
return img
# Round-only additional view modes (called by fb_dashboard.py's main
# loop when the user long-presses center → radial menu → terminal/flash/auth).
def layout_terminal(self, term_state) -> Image.Image:
# Delegates to the existing draw_terminal() helper for now;
# extracted into a method to give the main loop a class-based API.
from fb_dashboard import draw_terminal
return draw_terminal(term_state)
def layout_flash(self, flash_state) -> Image.Image:
from fb_dashboard import draw_flash_progress
return draw_flash_progress(flash_state)
def layout_auth(self, auth_state) -> Image.Image:
from fb_dashboard import draw_auth_mode
return draw_auth_mode(auth_state)

View File

@ -0,0 +1,43 @@
# 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.
"""Tests for RoundDashboard — Pi Zero W 480×480 layout via secubox_common."""
import sys
from pathlib import Path
_DEV = Path(__file__).resolve().parents[3] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
# Also add the round/ directory itself so round_dashboard imports work.
_ROUND = Path(__file__).resolve().parents[1]
if str(_ROUND) not in sys.path:
sys.path.insert(0, str(_ROUND))
from round_dashboard import RoundDashboard
from secubox_common.canvas import DashboardCanvas
def test_round_dashboard_subclasses_canvas():
assert issubclass(RoundDashboard, DashboardCanvas)
def test_round_dashboard_size_is_480():
rd = RoundDashboard()
assert rd.SIZE == (480, 480)
def test_round_dashboard_layout_returns_rgba_480x480():
rd = RoundDashboard()
img = rd.layout({})
assert img.mode == "RGBA"
assert img.size == (480, 480)
def test_round_dashboard_layout_paints_rainbow_ring():
rd = RoundDashboard()
img = rd.layout({})
# Rainbow ring is at radius 220-235; sample at radius 227 angle 0.
px = img.getpixel((240 + 227, 240))
assert px[:3] != (0, 0, 0), "rainbow ring not painted at 3 o'clock"

View File

@ -103,6 +103,35 @@ log "Installing config files (systemd, udev, apparmor, firstboot)..."
cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/"
chmod +x "$ROOT_MNT/usr/local/sbin/firstboot.sh"
# Install the shared OTG gadget composer (round does this at line 618 of
# build-eye-remote-image.sh). secubox-otg-gadget.service ExecStarts this path;
# without it the gadget never composes and the Pi 4B's USB-C bus stays silent
# → MOCHAbin/host enumeration fails (no descriptor events, xhci timeouts).
log "Installing OTG gadget composer at /usr/local/sbin/secubox-otg-gadget.sh..."
cp "$REPO_ROOT/remote-ui/common/shell/secubox-otg-gadget.sh" \
"$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
chmod +x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
test -x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh" || \
{ err "secubox-otg-gadget.sh not executable on rootfs"; exit 2; }
# Ship the shared secubox_common package.
log "Embedding remote-ui/common/python at /var/www/common/python/..."
mkdir -p "$ROOT_MNT/var/www/common/python"
cp -r "$REPO_ROOT/remote-ui/common/python/." "$ROOT_MNT/var/www/common/python/"
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
# Ship the shared icon assets — secubox_common.icons.load_module_icon
# resolves at /var/www/common/assets/icons/ first. Without these the pod
# cluster falls back to first-letter placeholders.
log "Embedding remote-ui/common/assets at /var/www/common/assets/..."
mkdir -p "$ROOT_MNT/var/www/common/assets"
cp -r "$REPO_ROOT/remote-ui/common/assets/." "$ROOT_MNT/var/www/common/assets/"
ICON_COUNT=$(ls "$ROOT_MNT/var/www/common/assets/icons"/*-48.png 2>/dev/null | wc -l)
test "$ICON_COUNT" -gt 0 || \
{ err "no *-48.png icons in /var/www/common/assets/icons — common/ assets incomplete"; exit 2; }
log "$ICON_COUNT module icons at size 48 shipped"
log "Installing Python packages..."
mkdir -p "$ROOT_MNT/usr/lib/python3/dist-packages"
cp -r "$REPO_ROOT/packages/secubox-eye-square/helper/eye_square_helper" \

View File

@ -13,6 +13,7 @@ ConditionPathExists=/dev/fb0
Type=simple
User=secubox
Group=secubox
Environment="PYTHONPATH=/var/www/common/python"
SupplementaryGroups=video input
ExecStart=/usr/bin/python3 -m secubox_eye_square_kiosk
Restart=always