secubox-deb/docs/superpowers/plans/2026-05-14-converged-dashboard.md
CyberMind-FR 78316556d4 docs(remote-ui): converged dashboard implementation plan (ref #135)
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>
2026-05-14 12:26:13 +02:00

85 KiB
Raw Blame History

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 with RoundDashboard.layout(metrics) and .layout_terminal/_flash/_auth(state) calls; keep main loop + metrics sources.
  • remote-ui/round/build-eye-remote-image.sh — ship remote-ui/common/python/ to /var/www/common/python/; verify python3-numpy is in apt-install (it already is for round/). Add Environment="PYTHONPATH=/var/www/common/python" to relevant systemd units.
  • remote-ui/round/files/etc/systemd/system/secubox-fb-dashboard.service — add Environment="PYTHONPATH=/var/www/common/python".
  • packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py — use SquareDashboard + 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 from secubox_common.theme for backward compatibility with tests / other callers.
  • packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/modules_table.py — re-export from secubox_common.modules.
  • remote-ui/square/build-eye-square-image.sh — ship remote-ui/common/python/ to /var/www/common/python/; PYTHONPATH already needed.
  • remote-ui/square/files/etc/systemd/system/secubox-square-kiosk.service — add Environment="PYTHONPATH=/var/www/common/python".

DELETED

  • packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py — superseded by square_dashboard.py.
  • packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py — superseded by test_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 inline draw_dashboard() body with RoundDashboard.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 by secubox_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.