mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 14:10:33 +00:00
Compare commits
35 Commits
7fafdd9d7c
...
8996847745
| Author | SHA1 | Date | |
|---|---|---|---|
| 8996847745 | |||
| f4acd5a9ca | |||
| 387fabb4f3 | |||
| 5546169971 | |||
| c9539f8ba6 | |||
| b57891941b | |||
| 39769c318b | |||
| eeb455b673 | |||
| 4aa36f104e | |||
| 626e68904f | |||
| 4e03649954 | |||
| d2ec163912 | |||
| c214c946c2 | |||
| 50a687654c | |||
| 75991ec5b0 | |||
| 238304f351 | |||
| 4c176f8be2 | |||
| 153023b390 | |||
| 26e7f45285 | |||
| b15497d668 | |||
| 8922b2f59e | |||
| f3fba94bc4 | |||
| f1e2375e57 | |||
| 1cc3f8ee8e | |||
| ea7c278126 | |||
| 43a994fde0 | |||
| 74c5076b60 | |||
| c31c61bf3b | |||
| 8809104760 | |||
| 50e4c05a67 | |||
| 33fb2a20b0 | |||
| 0f8498a37a | |||
| 847af3e563 | |||
| 7b0f5306f5 | |||
| f552411f96 |
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
45
packages/secubox-eye-square/kiosk/tests/test_cursor.py
Normal file
45
packages/secubox-eye-square/kiosk/tests/test_cursor.py
Normal 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)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
3
remote-ui/common/python/pytest.ini
Normal file
3
remote-ui/common/python/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
testpaths = secubox_common/tests
|
||||
python_files = test_*.py
|
||||
11
remote-ui/common/python/secubox_common/__init__.py
Normal file
11
remote-ui/common/python/secubox_common/__init__.py
Normal 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"
|
||||
170
remote-ui/common/python/secubox_common/canvas.py
Normal file
170
remote-ui/common/python/secubox_common/canvas.py
Normal 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"
|
||||
)
|
||||
73
remote-ui/common/python/secubox_common/icons.py
Normal file
73
remote-ui/common/python/secubox_common/icons.py
Normal 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
|
||||
70
remote-ui/common/python/secubox_common/modules.py
Normal file
70
remote-ui/common/python/secubox_common/modules.py
Normal 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),
|
||||
),
|
||||
]
|
||||
19
remote-ui/common/python/secubox_common/tests/conftest.py
Normal file
19
remote-ui/common/python/secubox_common/tests/conftest.py
Normal 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))
|
||||
198
remote-ui/common/python/secubox_common/tests/test_canvas.py
Normal file
198
remote-ui/common/python/secubox_common/tests/test_canvas.py
Normal 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"
|
||||
75
remote-ui/common/python/secubox_common/tests/test_icons.py
Normal file
75
remote-ui/common/python/secubox_common/tests/test_icons.py
Normal 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
|
||||
59
remote-ui/common/python/secubox_common/tests/test_modules.py
Normal file
59
remote-ui/common/python/secubox_common/tests/test_modules.py
Normal 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]
|
||||
41
remote-ui/common/python/secubox_common/tests/test_theme.py
Normal file
41
remote-ui/common/python/secubox_common/tests/test_theme.py
Normal 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))
|
||||
52
remote-ui/common/python/secubox_common/theme.py
Normal file
52
remote-ui/common/python/secubox_common/theme.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
46
remote-ui/round/round_dashboard.py
Normal file
46
remote-ui/round/round_dashboard.py
Normal 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)
|
||||
43
remote-ui/round/tests/test_round_dashboard.py
Normal file
43
remote-ui/round/tests/test_round_dashboard.py
Normal 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"
|
||||
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user