23-task TDD plan for extracting secubox_common from round/fb_dashboard.py and square/Phase-3 kiosk, adding pointer input on Pi 4B/400, and shipping both form factors as one PR. Gate: PR #134 (framebuffer numpy+center-pad) must merge before Task 1. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 KiB
Converged Dashboard Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extract a shared secubox_common Python package under remote-ui/common/python/ containing theme, modules, icons, and DashboardCanvas drawing primitives; refactor remote-ui/round/fb_dashboard.py and packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ to consume it; add pointer_input.py + cursor.py (mouse/touchpad + auto-hide sprite) on square/ only; ship secubox_common to /var/www/common/python/ on both images via systemd PYTHONPATH. Fixes round/'s missing-module-icon bug by construction.
Architecture: OO layout classes. DashboardCanvas base in common/python/secubox_common/canvas.py owns stateless drawing primitives. RoundDashboard(DashboardCanvas) paints a 480×480 round canvas (plus its 3 extra view modes for terminal/flash/auth). SquareDashboard(DashboardCanvas) paints the left 480×480 region of the 800×480 canvas using the same primitives, then composes the right_panel.draw(...) into the remaining 320×480. Both kiosks become thin event loops on top of the same drawing layer.
Tech Stack: Python 3.11, Pillow (PIL), numpy (RGB565 packing), python-evdev (touch + pointer), pytest, systemd, mmap to /dev/fb0.
Spec: docs/superpowers/specs/2026-05-14-converged-dashboard-design.md (master b5e44e72)
Tracking issue: #135
Prerequisite: PR #134 must merge first — it ships framebuffer.py with numpy + size auto-detect that the converged square/ kiosk depends on.
File map
NEW
remote-ui/common/python/secubox_common/
├── __init__.py # version + imports re-export
├── theme.py # palette + DEFAULT_FONT loader
├── modules.py # Module dataclass + MODULES list
├── icons.py # load_module_icon(name, size)
├── canvas.py # DashboardCanvas base class
└── tests/
├── __init__.py
├── conftest.py # shared fixtures
├── test_theme.py
├── test_modules.py
├── test_icons.py
└── test_canvas.py
remote-ui/round/
├── round_dashboard.py # class RoundDashboard(DashboardCanvas)
└── tests/test_round_dashboard.py
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/
├── square_dashboard.py # class SquareDashboard(DashboardCanvas)
├── pointer_input.py # mouse/touchpad → InputEvent
└── cursor.py # arrow sprite
packages/secubox-eye-square/kiosk/tests/
├── test_square_dashboard.py
├── test_pointer_input.py
├── test_cursor.py
└── test_common_api_stable.py # round/ + square/ both import canvas correctly
MODIFIED
remote-ui/round/fb_dashboard.py— replace inline drawing withRoundDashboard.layout(metrics)and.layout_terminal/_flash/_auth(state)calls; keep main loop + metrics sources.remote-ui/round/build-eye-remote-image.sh— shipremote-ui/common/python/to/var/www/common/python/; verifypython3-numpyis in apt-install (it already is for round/). AddEnvironment="PYTHONPATH=/var/www/common/python"to relevant systemd units.remote-ui/round/files/etc/systemd/system/secubox-fb-dashboard.service— addEnvironment="PYTHONPATH=/var/www/common/python".packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py— useSquareDashboard+PointerInput+ cursor overlay; dispatch taps from both touch + pointer.packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py— keep file but make it a thin re-export fromsecubox_common.themefor backward compatibility with tests / other callers.packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/modules_table.py— re-export fromsecubox_common.modules.remote-ui/square/build-eye-square-image.sh— shipremote-ui/common/python/to/var/www/common/python/; PYTHONPATH already needed.remote-ui/square/files/etc/systemd/system/secubox-square-kiosk.service— addEnvironment="PYTHONPATH=/var/www/common/python".
DELETED
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py— superseded bysquare_dashboard.py.packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py— superseded bytest_square_dashboard.py.
(Keep modules_table.py and theme.py as compatibility re-export shims so any callers in the codebase that import them via the old path keep working.)
Task 1: Worktree + branch setup
Files:
-
Worktree:
~/CyberMindStudio/secubox-deb-worktrees/135-converged-dashboard/ -
Branch:
feature/135-converged-dashboard -
Step 1: Verify prerequisite PR #134 is merged
cd /home/reepost/CyberMindStudio/secubox-deb/secubox-deb
git fetch origin master
gh pr view 134 --json state --jq .state
Expected: MERGED. If OPEN, stop and ask the user — this plan depends on PR #134's framebuffer.py.
- Step 2: Pull latest master
git checkout master
git pull --ff-only origin master
- Step 3: Create worktree via agent-worktree.sh
./scripts/agent-worktree.sh start --issue 135
Expected output ends with: next: cd /home/reepost/CyberMindStudio/secubox-deb-worktrees/135-converged-dashboard
- Step 4: Switch into the worktree
cd /home/reepost/CyberMindStudio/secubox-deb-worktrees/135-converged-dashboard
git status
Expected: On branch feature/135-converged-dashboard, working tree clean.
- Step 5: Commit the empty marker (optional, skip if worktree is clean)
(No-op if there's nothing to commit; this task has no code.)
Task 2: secubox_common package skeleton
Files:
-
Create:
remote-ui/common/python/secubox_common/__init__.py -
Create:
remote-ui/common/python/secubox_common/tests/__init__.py -
Create:
remote-ui/common/python/secubox_common/tests/conftest.py -
Create:
remote-ui/common/python/pytest.ini -
Step 1: Create the package directory with init.py
remote-ui/common/python/secubox_common/__init__.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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"
- Step 2: Create tests/init.py (empty)
remote-ui/common/python/secubox_common/tests/__init__.py:
- Step 3: Create pytest config
remote-ui/common/python/pytest.ini:
[pytest]
testpaths = secubox_common/tests
python_files = test_*.py
- Step 4: Create conftest.py for shared fixtures
remote-ui/common/python/secubox_common/tests/conftest.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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))
- Step 5: Verify pytest discovers the empty test dir
cd remote-ui/common/python && python3 -m pytest --collect-only
Expected: no tests collected (we haven't written any tests yet).
- Step 6: Commit
git add remote-ui/common/python/
git commit -m "feat(common): seed secubox_common package skeleton (ref #135)"
Task 3: theme.py — palette + DEFAULT_FONT
Files:
-
Create:
remote-ui/common/python/secubox_common/theme.py -
Create:
remote-ui/common/python/secubox_common/tests/test_theme.py -
Step 1: Write the failing tests
remote-ui/common/python/secubox_common/tests/test_theme.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Tests for secubox_common.theme — palette + DEFAULT_FONT loader."""
from PIL import ImageDraw, ImageFont, 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))
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_theme.py -v
Expected: ModuleNotFoundError: No module named 'secubox_common.theme'.
- Step 3: Implement theme.py
remote-ui/common/python/secubox_common/theme.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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()
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_theme.py -v
Expected: 5 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/theme.py \
remote-ui/common/python/secubox_common/tests/test_theme.py
git commit -m "feat(common): theme.py — palette constants + DEFAULT_FONT loader (ref #135)"
Task 4: modules.py — Module dataclass + MODULES list
Files:
-
Create:
remote-ui/common/python/secubox_common/modules.py -
Create:
remote-ui/common/python/secubox_common/tests/test_modules.py -
Step 1: Write the failing tests
remote-ui/common/python/secubox_common/tests/test_modules.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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]
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_modules.py -v
Expected: ModuleNotFoundError: No module named 'secubox_common.modules'.
- Step 3: Implement modules.py
remote-ui/common/python/secubox_common/modules.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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
from dataclasses import dataclass
from typing import Callable
from . import theme
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) / 4.0),
),
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),
),
]
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_modules.py -v
Expected: 6 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/modules.py \
remote-ui/common/python/secubox_common/tests/test_modules.py
git commit -m "feat(common): modules.py — canonical Hamiltonian module table (ref #135)"
Task 5: icons.py — module icon loader with path resolution
Files:
-
Create:
remote-ui/common/python/secubox_common/icons.py -
Create:
remote-ui/common/python/secubox_common/tests/test_icons.py -
Step 1: Write the failing tests
remote-ui/common/python/secubox_common/tests/test_icons.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Tests for secubox_common.icons — path resolution + LRU cache."""
from pathlib import Path
from unittest.mock import patch
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
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_icons.py -v
Expected: ModuleNotFoundError: No module named 'secubox_common.icons'.
- Step 3: Implement icons.py
remote-ui/common/python/secubox_common/icons.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_icons.py -v
Expected: 5 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/icons.py \
remote-ui/common/python/secubox_common/tests/test_icons.py
git commit -m "feat(common): icons.py — module icon loader with multi-path resolution (closes round/ ICONS_DIR bug, ref #135)"
Task 6: canvas.py base + paint_background
Files:
-
Create:
remote-ui/common/python/secubox_common/canvas.py -
Create:
remote-ui/common/python/secubox_common/tests/test_canvas.py -
Step 1: Write the failing tests for canvas + paint_background
remote-ui/common/python/secubox_common/tests/test_canvas.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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"
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: ModuleNotFoundError: No module named 'secubox_common.canvas'.
- Step 3: Implement canvas.py base + paint_background
remote-ui/common/python/secubox_common/canvas.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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 PIL import Image, ImageDraw
from . import theme
class DashboardCanvas:
"""Drawing primitives + abstract layout."""
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 layout(self, metrics: dict) -> Image.Image:
"""Compose the form-factor-specific dashboard. Override in subclass."""
raise NotImplementedError(
"DashboardCanvas.layout() must be overridden in subclasses"
)
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: 3 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/canvas.py \
remote-ui/common/python/secubox_common/tests/test_canvas.py
git commit -m "feat(common): canvas.py — DashboardCanvas base + paint_background (ref #135)"
Task 7: paint_rainbow_ring
Files:
-
Modify:
remote-ui/common/python/secubox_common/canvas.py -
Modify:
remote-ui/common/python/secubox_common/tests/test_canvas.py -
Step 1: Append failing test for paint_rainbow_ring
Append to secubox_common/tests/test_canvas.py:
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 and outside the band remain black."""
canvas = DashboardCanvas()
canvas.paint_rainbow_ring(blank_round, center=(240, 240),
radius_outer=235, radius_inner=220)
# Centre pixel = inside the inner radius, should still be black.
assert blank_round.getpixel((240, 240))[:3] == (0, 0, 0)
# Pixel at radius 230 (between inner=220 and outer=235): coloured.
px = blank_round.getpixel((240 + 230, 240))
assert px[:3] != (0, 0, 0), \
f"expected coloured pixel at band radius 230, got {px[:3]}"
# Pixel at radius 250 (outside outer=235): still black.
px = blank_round.getpixel((240 + 250 if 240 + 250 < 480 else 479, 240))
if 240 + 250 < 480:
assert px[: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}"
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py::test_paint_rainbow_ring_pixels_in_band_are_colored -v
Expected: AttributeError: 'DashboardCanvas' object has no attribute 'paint_rainbow_ring'.
- Step 3: Implement paint_rainbow_ring
Add to secubox_common/canvas.py inside DashboardCanvas:
def paint_rainbow_ring(self, img: Image.Image,
center: tuple[int, int],
radius_outer: int,
radius_inner: int,
stops: int = 256) -> None:
"""Annular rainbow gradient — HSV hue rotates 0..360° around the centre,
rendered as `stops` thin arc segments between radius_inner and radius_outer."""
import colorsys
import math
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 transparent / background.
inner_bbox = (cx - radius_inner, cy - radius_inner,
cx + radius_inner, cy + radius_inner)
draw.ellipse(inner_bbox, fill=(0, 0, 0, 255))
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: 5 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/canvas.py \
remote-ui/common/python/secubox_common/tests/test_canvas.py
git commit -m "feat(common): canvas — paint_rainbow_ring (HSV hue rotation) (ref #135)"
Task 8: paint_concentric_arcs
Files:
-
Modify:
remote-ui/common/python/secubox_common/canvas.py -
Modify:
remote-ui/common/python/secubox_common/tests/test_canvas.py -
Step 1: Append failing test for paint_concentric_arcs
Append to secubox_common/tests/test_canvas.py:
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()
metrics = {
"cpu_percent": 100, "mem_percent": 100, "disk_percent": 100,
"load_avg_1": 4.0, "cpu_temp": 85, "wifi_rssi": -20,
}
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}"
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py::test_paint_concentric_arcs_six_rings_present -v
Expected: AttributeError: 'DashboardCanvas' object has no attribute 'paint_concentric_arcs'.
- Step 3: Implement paint_concentric_arcs
Add to secubox_common/canvas.py inside DashboardCanvas:
RING_WIDTH = 5
RING_TRACK_COLOUR = (0x14, 0x14, 0x14, 255)
def paint_concentric_arcs(self, img: Image.Image,
center: tuple[int, int],
modules,
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)
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: 7 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/canvas.py \
remote-ui/common/python/secubox_common/tests/test_canvas.py
git commit -m "feat(common): canvas — paint_concentric_arcs (per-module ring fill) (ref #135)"
Task 9: paint_pod_cluster
Files:
-
Modify:
remote-ui/common/python/secubox_common/canvas.py -
Modify:
remote-ui/common/python/secubox_common/tests/test_canvas.py -
Step 1: Append failing test for paint_pod_cluster
Append to secubox_common/tests/test_canvas.py:
def test_paint_pod_cluster_six_coloured_circles(blank_round):
"""Six pod circles, each in its module colour, arranged on a circle
of given radius."""
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]
# Pod centre should be coloured by module.colour (icon overlay may
# be transparent, but the colored disc shows through; tolerance for
# icon center pixel).
dr = abs(pixel[0] - m.colour[0])
dg = abs(pixel[1] - m.colour[1])
db = abs(pixel[2] - m.colour[2])
# Loose tolerance — icon may dominate centre. Accept any non-black.
assert pixel != (0, 0, 0), \
f"pod {m.name} at ({px},{py}) is black (expected coloured)"
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)
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py::test_paint_pod_cluster_six_coloured_circles -v
Expected: AttributeError: 'DashboardCanvas' object has no attribute 'paint_pod_cluster'.
- Step 3: Implement paint_pod_cluster
Add to secubox_common/canvas.py inside DashboardCanvas:
def paint_pod_cluster(self, img: Image.Image, modules, 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)
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: 9 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/canvas.py \
remote-ui/common/python/secubox_common/tests/test_canvas.py
git commit -m "feat(common): canvas — paint_pod_cluster (icon or letter on coloured disc) (ref #135)"
Task 10: paint_central_button + paint_alert_ribbon
Files:
-
Modify:
remote-ui/common/python/secubox_common/canvas.py -
Modify:
remote-ui/common/python/secubox_common/tests/test_canvas.py -
Step 1: Append failing tests
Append to secubox_common/tests/test_canvas.py:
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"
- Step 2: Run tests to verify failure
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py::test_paint_central_button_draws_hollow_white_circle -v
Expected: AttributeError: 'DashboardCanvas' object has no attribute 'paint_central_button'.
- Step 3: Implement paint_central_button + paint_alert_ribbon
Add to secubox_common/canvas.py inside DashboardCanvas:
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)
ALERT_RIBBON_HEIGHT = 20
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
text: str, severity: str) -> None:
"""Bottom strip: dark semi-transparent fill + coloured text.
`region_y` is the top of the ribbon (typically img.height - 20)."""
draw = ImageDraw.Draw(img)
w = img.size[0]
colour = theme.SEVERITY.get(severity, theme.TEXT_MUTED) + (255,)
draw.rectangle((0, region_y, w, region_y + self.ALERT_RIBBON_HEIGHT),
fill=(0, 0, 0, 200))
font = theme.load_default_font(11)
draw.text((10, region_y + 4),
f"▲ {text}"[:50], fill=colour, font=font)
- Step 4: Run tests to verify pass
cd remote-ui/common/python && python3 -m pytest secubox_common/tests/test_canvas.py -v
Expected: 11 passed.
- Step 5: Commit
git add remote-ui/common/python/secubox_common/canvas.py \
remote-ui/common/python/secubox_common/tests/test_canvas.py
git commit -m "feat(common): canvas — paint_central_button + paint_alert_ribbon (ref #135)"
Task 11: SquareDashboard subclass
Files:
-
Create:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/square_dashboard.py -
Create:
packages/secubox-eye-square/kiosk/tests/test_square_dashboard.py -
Delete:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py -
Delete:
packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py -
Step 1: Write failing tests for SquareDashboard
packages/secubox-eye-square/kiosk/tests/test_square_dashboard.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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)
- Step 2: Run tests to verify failure
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_square_dashboard.py -v
Expected: ModuleNotFoundError: No module named 'secubox_eye_square_kiosk.square_dashboard'.
- Step 3: Implement SquareDashboard
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/square_dashboard.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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:
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)
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=70, pod_size=40)
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
- Step 4: Run tests to verify pass
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_square_dashboard.py -v
Expected: 4 passed.
- Step 5: Delete old ring_dashboard files
rm packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py
rm packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py
- Step 6: Commit
git add packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/square_dashboard.py \
packages/secubox-eye-square/kiosk/tests/test_square_dashboard.py
git rm packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py \
packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py
git commit -m "feat(square): SquareDashboard via secubox_common; drop ring_dashboard.py (ref #135)"
Task 12: PointerInput
Files:
-
Create:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/pointer_input.py -
Create:
packages/secubox-eye-square/kiosk/tests/test_pointer_input.py -
Step 1: Write failing tests
packages/secubox-eye-square/kiosk/tests/test_pointer_input.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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
- Step 2: Run tests to verify failure
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_pointer_input.py -v
Expected: ModuleNotFoundError: No module named 'secubox_eye_square_kiosk.pointer_input'.
- Step 3: Implement PointerInput
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/pointer_input.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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 logging
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
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.
import os, fcntl
flags = fcntl.fcntl(dev.fd, fcntl.F_GETFL)
fcntl.fcntl(dev.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
devices.append(dev)
except (OSError, PermissionError):
continue
log.warning("pointer devices found: %s", [d.path for d in devices])
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()
elif ev.code == ecodes.REL_Y:
self._y = self._clamp_y(self._y + ev.value)
self._touch_motion()
elif ev.type == ecodes.EV_ABS:
if ev.code == ecodes.ABS_X:
self._x = self._clamp_x(ev.value)
self._touch_motion()
elif ev.code == ecodes.ABS_Y:
self._y = self._clamp_y(ev.value)
self._touch_motion()
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 = []
- Step 4: Run tests to verify pass
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_pointer_input.py -v
Expected: 9 passed.
- Step 5: Commit
git add packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/pointer_input.py \
packages/secubox-eye-square/kiosk/tests/test_pointer_input.py
git commit -m "feat(square): pointer_input — mouse + touchpad via python-evdev (ref #135)"
Task 13: cursor sprite
Files:
-
Create:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/cursor.py -
Create:
packages/secubox-eye-square/kiosk/tests/test_cursor.py -
Step 1: Write failing tests
packages/secubox-eye-square/kiosk/tests/test_cursor.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Tests for cursor.draw_cursor — overlay sprite."""
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():
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, 95, 95)
# Must not raise. Sprite is partially drawn within bounds.
def test_cursor_negative_coords_dont_crash():
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, -10, -10) # off-canvas — should be a no-op
- Step 2: Run tests to verify failure
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_cursor.py -v
Expected: ModuleNotFoundError: No module named 'secubox_eye_square_kiosk.cursor'.
- Step 3: Implement cursor.draw_cursor
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/cursor.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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
# 12×16 arrow: gold outline (GOLD_HERMETIC) + black fill. Hand-drawn
# polygon, top-left origin.
_OUTLINE = (0xC9, 0xA8, 0x4C, 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 + 12 < 0 or y + 16 < 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)
- Step 4: Run tests to verify pass
cd packages/secubox-eye-square/kiosk && python3 -m pytest tests/test_cursor.py -v
Expected: 3 passed.
- Step 5: Commit
git add packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/cursor.py \
packages/secubox-eye-square/kiosk/tests/test_cursor.py
git commit -m "feat(square): cursor.py — 12×16 arrow sprite for pointer overlay (ref #135)"
Task 14: theme.py + modules_table.py compat shims
Files:
- Modify:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py - Modify:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/modules_table.py
These shims keep from secubox_eye_square_kiosk.theme import COSMOS_BLACK working in the existing tabs/* code without forcing a sweeping import rewrite in this PR.
- Step 1: Replace square/'s theme.py with a re-export shim
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Backward-compat shim — re-exports secubox_common.theme.
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,
)
# Older callers expected a module-level constant DEFAULT_FONT.
DEFAULT_FONT = load_default_font(12)
- Step 2: Replace square/'s modules_table.py with a re-export shim
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/modules_table.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Backward-compat shim — re-exports secubox_common.modules."""
from secubox_common.modules import Module, MODULES # noqa: F401
- Step 3: Run all square/ kiosk tests to verify nothing broke
cd packages/secubox-eye-square/kiosk && PYTHONPATH=../../../remote-ui/common/python python3 -m pytest tests/ -v
Expected: all tests pass (existing 71 plus new SquareDashboard / pointer / cursor tests).
- Step 4: Commit
git add packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py \
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/modules_table.py
git commit -m "refactor(square): theme + modules_table become re-export shims for secubox_common (ref #135)"
Task 15: Square/ kiosk main.py wires SquareDashboard + PointerInput + cursor
Files:
-
Modify:
packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py -
Step 1: Read current main.py to capture context
cat packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py
- Step 2: Replace main.py with the converged version
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>
"""SecuBox Eye Square kiosk — event loop driver (converged)."""
from __future__ import annotations
import logging
import os
import sys
import time
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 .sim import SimState, step
from .square_dashboard import SquareDashboard
from .touch_input import TouchInput
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"
)
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 (converged)")
helper = HelperClient(HELPER_SOCK)
tm = TransportManager(simulate=False)
tm.probe()
sim = SimState()
panel = RightPanel(helper)
dashboard = SquareDashboard(right_panel=panel)
tm.on_transport_change = lambda active: panel.on_transport_change(active)
try:
fb = FrameBuffer(FB_PATH)
except OSError as e:
log.error("Cannot open framebuffer %s: %s", FB_PATH, e)
return 1
touch = TouchInput()
pointer = PointerInput(fb_size=(fb.width, fb.height))
last_probe = 0.0
last_metrics = 0.0
frame_period = 1.0 / TARGET_FPS
try:
while True:
now = time.time()
# Periodic transport probe + metrics refresh.
if now - last_probe > PROBE_INTERVAL_S:
tm.probe()
last_probe = now
if now - last_metrics > METRICS_INTERVAL_S:
metrics = tm.fetch_metrics()
if metrics is None:
step(sim, refresh_interval_s=METRICS_INTERVAL_S)
metrics = sim.to_dict()
last_metrics = now
# Input poll + dispatch.
for tap in touch.poll():
_dispatch_tap(tap.x, tap.y, panel, dashboard)
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)
except KeyboardInterrupt:
log.info("Shutting down kiosk")
finally:
fb.close()
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())
- Step 3: Run all square/ kiosk tests
cd packages/secubox-eye-square/kiosk && PYTHONPATH=../../../remote-ui/common/python python3 -m pytest tests/ -v
Expected: all tests pass.
- Step 4: Commit
git add packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py
git commit -m "feat(square): __main__ wires SquareDashboard + PointerInput + cursor overlay (ref #135)"
Task 16: Cross-form-factor API stability test
Files:
-
Create:
packages/secubox-eye-square/kiosk/tests/test_common_api_stable.py -
Step 1: Write the test
packages/secubox-eye-square/kiosk/tests/test_common_api_stable.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""Catches API drift between secubox_common and its consumers."""
import inspect
import pytest
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)
- Step 2: Run the test
cd packages/secubox-eye-square/kiosk && PYTHONPATH=../../../remote-ui/common/python python3 -m pytest tests/test_common_api_stable.py -v
Expected: 5 passed.
- Step 3: Commit
git add packages/secubox-eye-square/kiosk/tests/test_common_api_stable.py
git commit -m "test(square): cross-form-factor API stability check (ref #135)"
Task 17: RoundDashboard subclass
Files:
-
Create:
remote-ui/round/round_dashboard.py -
Create:
remote-ui/round/tests/test_round_dashboard.py -
Step 1: Write failing tests
remote-ui/round/tests/test_round_dashboard.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
"""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))
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"
- Step 2: Run tests to verify failure
cd remote-ui/round && PYTHONPATH=../common/python python3 -m pytest tests/test_round_dashboard.py -v
Expected: ModuleNotFoundError: No module named 'round_dashboard'.
- Step 3: Implement RoundDashboard
remote-ui/round/round_dashboard.py:
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""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)
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=70, pod_size=40)
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)
- Step 4: Run tests to verify pass
cd remote-ui/round && PYTHONPATH=../common/python python3 -m pytest tests/test_round_dashboard.py -v
Expected: 4 passed.
- Step 5: Commit
git add remote-ui/round/round_dashboard.py \
remote-ui/round/tests/test_round_dashboard.py
git commit -m "feat(round): RoundDashboard via secubox_common (ref #135)"
Task 18: Refactor fb_dashboard.py to delegate to RoundDashboard
Files:
-
Modify:
remote-ui/round/fb_dashboard.py(heavy refactor — replace inlinedraw_dashboard()body withRoundDashboard.layout(metrics)) -
Step 1: Read the current draw_dashboard() function
sed -n '/^def draw_dashboard/,/^def get_fb_info/p' remote-ui/round/fb_dashboard.py | head -60
Capture the function for context — its parameters are (metrics, mode='SIM', host='', device_name='').
- Step 2: Replace the body of draw_dashboard with a delegation
Edit remote-ui/round/fb_dashboard.py — replace the entire def draw_dashboard(metrics, mode='SIM', host='', device_name=''): function body with:
def draw_dashboard(metrics, mode='SIM', host='', device_name=''):
"""Render the main dashboard view via RoundDashboard.
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.
"""
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)
- Step 3: Remove now-dead helper code in fb_dashboard.py
Inside fb_dashboard.py, the following module-level helpers are now unused by the dashboard path (they were inlined into the old draw_dashboard()):
load_module_icon— replaced bysecubox_common.icons.load_module_icon. Delete this function.ICONS_DIR,_icon_cache— same. Delete.
Update any remaining callers (the terminal / flash / auth draw_* functions still use Pillow primitives, leave them alone for this PR; they'll be migrated in a followup that's tracked separately).
- Step 4: Run round/ tests
cd remote-ui/round && PYTHONPATH=../common/python python3 -m pytest tests/ -v
Expected: all tests pass. Pre-existing tests in remote-ui/round/tests/test_touch_handler.py, test_failover.py, etc. are unaffected by this refactor — they don't test draw_dashboard() directly.
- Step 5: Commit
git add remote-ui/round/fb_dashboard.py
git commit -m "refactor(round): fb_dashboard.draw_dashboard delegates to RoundDashboard (ref #135)"
Task 19: Build scripts ship secubox_common to /var/www/common/python/
Files:
-
Modify:
remote-ui/round/build-eye-remote-image.sh -
Modify:
remote-ui/square/build-eye-square-image.sh -
Step 1: Find where round/ already embeds common/
grep -n "var/www/common\|COMMON_SRC" remote-ui/round/build-eye-remote-image.sh
Expected: a section near line 916-925 that does mkdir -p $ROOT_MNT/var/www/common; cp -r "$COMMON_SRC/." "$ROOT_MNT/var/www/common/". Confirm it already copies assets/icons and other static.
- Step 2: Add secubox_common.python check to that section
Edit remote-ui/round/build-eye-remote-image.sh — locate the line that does cp -r "$COMMON_SRC/." "$ROOT_MNT/var/www/common/" and right after it, add:
# 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" || \
{ log "ERR: secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
- Step 3: Apply the same check to square/'s build script
Edit remote-ui/square/build-eye-square-image.sh — find where cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/" happens. Just below, add:
# 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; }
- Step 4: Commit
git add remote-ui/round/build-eye-remote-image.sh \
remote-ui/square/build-eye-square-image.sh
git commit -m "build(images): ship secubox_common to /var/www/common/python/ on both images (ref #135)"
Task 20: systemd units add PYTHONPATH
Files:
-
Modify:
remote-ui/round/files/etc/systemd/system/secubox-fb-dashboard.service(or wherever the round kiosk service is) -
Modify:
remote-ui/square/files/etc/systemd/system/secubox-square-kiosk.service -
Step 1: Find the round/ kiosk service file
grep -rln "ExecStart.*fb_dashboard\|ExecStart.*secubox.*dashboard" remote-ui/round/files/etc/systemd/system/
Note the file name(s) returned — call it <round-unit>.
- Step 2: Add Environment=PYTHONPATH to round/ unit
In remote-ui/round/files/etc/systemd/system/<round-unit> under [Service], add:
Environment="PYTHONPATH=/var/www/common/python"
(Add as a new line; no replacement.)
- Step 3: Add Environment=PYTHONPATH to square/ kiosk unit
In remote-ui/square/files/etc/systemd/system/secubox-square-kiosk.service under [Service], add:
Environment="PYTHONPATH=/var/www/common/python"
- Step 4: Commit
git add remote-ui/round/files/etc/systemd/system/<round-unit> \
remote-ui/square/files/etc/systemd/system/secubox-square-kiosk.service
git commit -m "build(systemd): PYTHONPATH=/var/www/common/python for both kiosks (ref #135)"
Task 21: Full test suite sweep
Files: — (no files modified; running existing tests)
- Step 1: Run secubox_common tests
cd remote-ui/common/python && python3 -m pytest -v
Expected: ~30 tests pass.
- Step 2: Run square/ kiosk tests
cd packages/secubox-eye-square/kiosk && PYTHONPATH=../../../remote-ui/common/python python3 -m pytest tests/ -v
Expected: ~75-80 tests pass.
- Step 3: Run round/ tests
cd remote-ui/round && PYTHONPATH=../common/python python3 -m pytest tests/ -v
Expected: existing round/ tests pass + new test_round_dashboard.py passes.
- Step 4: If anything fails, fix and re-run before moving on
Treat the test run as one atomic gate. If a regression surfaces, fix in this task before continuing to the hardware bench.
- Step 5: Commit test fixes if any
git add <changed files>
git commit -m "fix(test): post-sweep regression fixes (ref #135)"
(If nothing to commit, skip.)
Task 22: Build images locally + hardware bench
Files: — (build artifacts, not committed)
This task is gated on hardware availability and is manual. Steps are the bench protocol.
- Step 1: Build the square/ image
cd remote-ui/square
sudo ./build-eye-square-image.sh -o /tmp 2>&1 | tee /tmp/square-build.log
Expected output ends with Built: /tmp/secubox-eye-square_0.2.0_arm64.img.xz.
- Step 2: Build the round/ image via CI
Push the branch — build-eye-remote.yml runs automatically. Wait for gh run watch to return green.
gh run list --workflow build-eye-remote.yml --branch feature/135-converged-dashboard --limit 1 --json databaseId,status,conclusion --jq '.[0]'
When conclusion: SUCCESS, download the artifact:
gh run download <id> --name secubox-eye-remote-2.2.1 -D /tmp/round-image/
- Step 3: Flash + boot the square/ image on a Pi 4B
User pops uSD into laptop. Flash:
sudo bash -c 'xzcat /tmp/secubox-eye-square_0.2.0_arm64.img.xz | dd of=/dev/mmcblk0 bs=4M status=progress conv=fsync'
User pops uSD into Pi 4B + 7" DSI. Boot. Verify:
-
Black background, rainbow ring outer, 6 distinct module pods with icons (not letters) clustered near center, central hollow white circle.
-
Right panel shows ALERTS / DETAIL / CON / CTL tabs; ALERTS highlighted by default.
-
Touch on tab bar switches tabs.
-
USB mouse plugged into the Pi 4B: cursor sprite appears when moving the mouse, hides after 3 s of idle. Mouse click on a tab switches it.
-
Step 4: Boot the same uSD on a Pi 400 + HDMI monitor
User pops the uSD from the Pi 4B into a Pi 400 (USB-C power, HDMI to monitor, USB mouse). Boot. Verify:
-
800×480 dashboard appears center-padded with black letterbox on the larger HDMI fb (e.g., 1920×1080).
-
Mouse clicks on tabs work.
-
Step 5: Flash + boot the round/ image on a Pi Zero W
User uses a separate uSD. Flash + insert into Pi Zero W + HyperPixel 2.1 round. Boot. Verify:
-
Rainbow ring dashboard renders.
-
Module pods show icons, not single-letter placeholders (Task 5 fix — closes the icon-loading bug).
-
Long-press center still opens the radial mode menu (terminal/flash/auth views still navigable).
-
Step 6: Document the bench results
Append a comment on issue #135 with the three boot results + photos.
Task 23: Push branch, open PR
Files: — (no files; git operations)
- Step 1: Verify worktree is clean and on the right branch
cd /home/reepost/CyberMindStudio/secubox-deb-worktrees/135-converged-dashboard
git status
git log --oneline origin/master..HEAD
Expected: working tree clean; commit history shows tasks 2-21 (and 19-20).
- Step 2: Push branch
git push -u origin feature/135-converged-dashboard
- Step 3: Use agent-worktree.sh finish to open the PR
./scripts/agent-worktree.sh finish
Expected: prints the PR URL.
- Step 4: Add bench-result photos / notes to the PR description
gh pr edit <#> --add-label "enhancement,eye-remote"
gh pr comment <#> --body "Hardware bench results: <paste Task 22 photos / notes here>"
- Step 5: Hand off for review
Tell user the PR URL. Stop. Do not merge.
Self-review (writing-plans inline)
Spec coverage check — every spec section mapped to tasks:
- Spec §1 Scope & non-goals → Tasks 1 (worktree), all subsequent tasks satisfy scope.
- Spec §2 Architecture → Tasks 2-10 build the canvas + theme + modules + icons in
secubox_common; Tasks 11 and 17 build the subclasses; Task 18 ports round/. - Spec §3 Components (signatures) → Tasks 3, 4, 5, 6-10, 11, 12, 13, 17, 18 all line up with the listed components.
- Spec §4 Data flow → Task 15 puts the event loop together for square/; Task 18 keeps round/'s loop, only swaps the renderer.
- Spec §5 Error handling → Tier 1 (fatal at startup) baked into Task 15's
if cannot open fb: return 1. Tier 2 (icons missing, font fallback, no input device) baked into Tasks 5, 3, 12. Tier 3 (transient) baked into Task 12 (USB unplug) and the helper client wrapper unchanged. - Spec §6 Testing → Tasks 3, 4, 5, 6-10, 11, 12, 13, 16, 17, 21.
- Spec §7 Migration & rollout → Tasks 1 (worktree), 19 (build script), 20 (systemd), 22 (hardware bench), 23 (PR).
- Spec §8 Out-of-scope followups → Honoured (not in this plan).
Placeholder scan — searched for "TBD" / "TODO" / "implement later" / "similar to" — none found. The only deferred work in Task 18 ("layout_terminal/_flash/_auth delegate to existing helpers; migrated in a followup") is explicitly out-of-scope per spec §1, not a placeholder.
Type consistency — method names match: paint_rainbow_ring, paint_concentric_arcs, paint_pod_cluster, paint_central_button, paint_alert_ribbon, layout, layout_terminal, layout_flash, layout_auth are spelled identically across spec §3 and Tasks 3-13, 17. Attribute names match: cursor_xy, cursor_visible, AUTO_HIDE_S, InputEvent("tap"|"motion", x, y) consistent across Tasks 12 and 15. Module name secubox_common consistent throughout.