Compare commits

...

37 Commits

Author SHA1 Message Date
ec1a4641e0 Merge remote-tracking branch 'origin/feature/135-converge-round-square-dashboards-into-re' into feature/138-port-radar-concentric-into-secubox-commo
# Conflicts:
#	packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py
#	packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/square_dashboard.py
#	remote-ui/common/python/secubox_common/canvas.py
#	remote-ui/round/round_dashboard.py
#	remote-ui/square/build-eye-square-image.sh
2026-05-15 11:46:27 +02:00
6d12af8624 feat(common): radar_concentric painter + phase-aware dashboards (ref #138)
Port the radar concentric layout from remote-ui/round/agent/display/fallback/
into a stateless painter under secubox_common.painters.radar_concentric.
Both round and square dashboards now consume it; the square's main loop
drives a monotonic-clock phase so its left half rotates the same way the
round's deployed fallback_manager does.

What landed
-----------
- `secubox_common/painters/radar_concentric.py`
  - `paint(img, center, modules, metrics, radii=None, phase=0.0, ...)`
  - phase × 2π = sweep angle radians, clockwise from 12 o'clock.
  - Module → wheel-angle map decoupled from list order via
    DEFAULT_NAME_TO_ANGLE so AUTH/WALL/ROOT/MESH/MIND/BOOT sit at the
    rainbow position matching their colour regardless of MODULES order.
  - DEFAULT_RADII = [214, 188, 162, 136, 110, 84] matches the deployed
    fallback_manager.py geometry.
  - draw_hub=False lets the converged dashboard composite its own
    central button + pod cluster on top.

- `DashboardCanvas.paint_radar_concentric` — thin wrapper, single source
  of truth verified by a test that asserts canvas-vs-painter byte-equal.

- `RoundDashboard.layout(metrics, phase=0.0)` and
  `SquareDashboard.layout(metrics, phase=0.0)` — phase=0 preserves the
  static still-frame contract for tests / callers that don't drive
  animation. RING_RADII bumped to the deployed [214..84] geometry on
  both classes so the converged left-half look matches the round.

- `secubox_eye_square_kiosk.__main__` drives
  `phase = (time.monotonic() * RADAR_RPM / 60) % 1` at 12 RPM, matching
  fallback_manager._sweep_speed.

Tests: 8 new in secubox_common/tests/test_radar_concentric.py — phase
differentiation, period-1 wrap, default-radii fallback, length-mismatch
guard, hub toggle, sweep_accent range, canvas-helper byte-equal.

Total green: 118 / 118 (36 secubox_common + 78 square kiosk + 4 round).

Out of scope for this commit (deferred)
---------------------------------------
- `fallback_manager.py` migration to the painter (#138 acceptance lists it).
  fallback_manager uses its own brighter MODULES list and stateful
  ConnectionState machinery; converting it requires a colour-palette
  decision the user has not yet signed off on. Will land in a follow-up
  fixup after hardware confirms the square radar visual is what we want.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:25:22 +02:00
8996847745 fixup(square): install secubox-otg-gadget.sh to /usr/local/sbin (ref #135)
The square build script enables secubox-otg-gadget.service but never copies
the composer script to /usr/local/sbin/. On boot the service ExecStarts a
missing file → fails silently → the Pi 4B's USB-C bus stays unconfigured →
MOCHAbin/host see no enumeration (zero dmesg events, xhci setup timeouts).

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

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

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

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

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

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

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

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

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

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

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

Full square/ kiosk suite still 73 passed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -28,6 +28,8 @@ HELPER_SOCK = os.environ.get(
TARGET_FPS = 30 TARGET_FPS = 30
PROBE_INTERVAL_S = 30 PROBE_INTERVAL_S = 30
METRICS_INTERVAL_S = 2 METRICS_INTERVAL_S = 2
# Radar sweep rotation speed (matches the deployed round fallback radar).
RADAR_RPM = 12.0
def main() -> int: def main() -> int:
@ -89,8 +91,10 @@ def main() -> int:
if ev.kind == "tap": if ev.kind == "tap":
_dispatch_tap(ev.x, ev.y, panel, dashboard) _dispatch_tap(ev.x, ev.y, panel, dashboard)
# Render. # Render. phase advances the radar sweep angle — monotonic
full = dashboard.layout(metrics) # so frame-to-frame motion is smooth across system clock jumps.
phase = (time.monotonic() * RADAR_RPM / 60.0) % 1.0
full = dashboard.layout(metrics, phase=phase)
if pointer.cursor_visible: if pointer.cursor_visible:
draw_cursor(full, *pointer.cursor_xy) draw_cursor(full, *pointer.cursor_xy)
fb.blit(full) fb.blit(full)

View File

@ -22,27 +22,34 @@ class SquareDashboard(DashboardCanvas):
DASHBOARD_REGION_SIZE = (480, 480) DASHBOARD_REGION_SIZE = (480, 480)
PANEL_REGION_SIZE = (320, 480) PANEL_REGION_SIZE = (320, 480)
CENTER = (240, 240) CENTER = (240, 240)
RING_RADII = [200, 185, 170, 155, 140, 125] # Same radii as RoundDashboard so the left half is visually identical
# to the deployed Pi Zero W radar.
RING_RADII = [214, 188, 162, 136, 110, 84]
def __init__(self, right_panel): def __init__(self, right_panel):
self.right_panel = right_panel self.right_panel = right_panel
def layout(self, metrics: dict) -> Image.Image: def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
# Image.new() with COSMOS_BLACK+(255,) is equivalent to calling """Render one frame at animation `phase` (0..1).
# paint_background on a fresh canvas; skip the redundant fill.
Phase rotates the radar sweep on the left half; the right panel
is static. Pass `phase=0.0` for a still frame.
"""
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,)) img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
# Left dashboard region. # Left dashboard region — phase-aware radar.
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE, dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
theme.COSMOS_BLACK + (255,)) theme.COSMOS_BLACK + (255,))
self.paint_rainbow_ring(dash, self.CENTER, 235, 220) self.paint_radar_concentric(
self.paint_concentric_arcs(dash, self.CENTER, MODULES, metrics, dash, self.CENTER, MODULES, metrics,
self.RING_RADII) radii=self.RING_RADII, phase=phase, draw_hub=True,
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=70, pod_size=40) )
# pod_size=48 matches deployed icon sizes (22/48/96/128).
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=78, pod_size=48)
self.paint_central_button(dash, self.CENTER, size=44) self.paint_central_button(dash, self.CENTER, size=44)
img.paste(dash, (0, 0)) img.paste(dash, (0, 0))
# Right panel. # Right panel (static).
panel = Image.new("RGBA", self.PANEL_REGION_SIZE, panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
theme.COSMOS_BLACK + (255,)) theme.COSMOS_BLACK + (255,))
self.right_panel.draw(panel) self.right_panel.draw(panel)

View File

@ -146,6 +146,25 @@ class DashboardCanvas:
draw.text((cx - lw // 2, cy + size + 4), draw.text((cx - lw // 2, cy + size + 4),
label, fill=theme.TEXT_PRIMARY + (255,), font=font) label, fill=theme.TEXT_PRIMARY + (255,), font=font)
def paint_radar_concentric(self, img: Image.Image,
center: tuple[int, int],
modules: Iterable[Module],
metrics: dict,
radii: list[int] | None = None,
phase: float = 0.0,
draw_hub: bool = True,
name_to_angle: dict[str, int] | None = None
) -> None:
"""Phase-aware concentric radar (ring backgrounds + tube arcs +
rotating sweep + sweep head + optional hub). Delegates to
`secubox_common.painters.radar_concentric.paint`. Drives the
OFFLINE-state visual on both round/ and the square's left half.
"""
from .painters import radar_concentric as _radar
_radar.paint(img, center, modules, metrics,
radii=radii, phase=phase, draw_hub=draw_hub,
name_to_angle=name_to_angle)
def paint_alert_ribbon(self, img: Image.Image, region_y: int, def paint_alert_ribbon(self, img: Image.Image, region_y: int,
text: str, severity: str) -> None: text: str, severity: str) -> None:
"""Bottom strip: solid dark fill + coloured severity text. """Bottom strip: solid dark fill + coloured severity text.

View File

@ -0,0 +1,5 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Phase-aware painter modules for animated dashboard variants."""

View File

@ -0,0 +1,244 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Concentric radar painter — port of fallback_manager._draw_offline_radar().
Stateless: given a `phase` in [0, 1) it draws the full radar frame
(ring backgrounds, balanced tube arcs, rotating segment-coloured sweep,
sweep head, centre hub) onto `img` centred on `center`.
`phase * 2π` is the sweep angle in radians, measured clockwise from
12 o'clock. Caller is responsible for advancing phase across frames
(typically `(time.monotonic() * rpm / 60) % 1`).
"""
from __future__ import annotations
import colorsys
import math
from collections.abc import Iterable
from PIL import Image, ImageDraw
from ..modules import Module
# Default radii match the deployed fallback_manager.py layout (6 rings).
DEFAULT_RADII: list[int] = [214, 188, 162, 136, 110, 84]
# Stroke width for ring backgrounds + tube arcs.
RING_WIDTH: int = 20
# Inner hub radius — masks the centre so it can host icons / metrics text.
HUB_RADIUS: int = 85
# Mapping module NAME → PIL arc centre angle so each tube arc sits at the
# position on the wheel that matches its colour. PIL convention: 0=3 o'clock,
# angles increase counter-clockwise. Decoupling angle from list order means
# the painter is invariant to MODULES iteration order — callers can pass
# secubox_common.MODULES (AUTH-first) or fallback_manager's local list
# (BOOT-first) and get the same wheel layout.
DEFAULT_NAME_TO_ANGLE: dict[str, int] = {
"AUTH": 0, # red-orange (3 o'clock)
"WALL": 60, # orange-yellow
"ROOT": 120, # green
"MESH": 180, # blue
"MIND": 240, # purple
"BOOT": 300, # deep red — closes the wheel between purple and red
}
def paint(
img: Image.Image,
center: tuple[int, int],
modules: Iterable[Module],
metrics: dict,
radii: list[int] | None = None,
phase: float = 0.0,
ring_width: int = RING_WIDTH,
hub_radius: int = HUB_RADIUS,
draw_hub: bool = True,
name_to_angle: dict[str, int] | None = None,
) -> None:
"""Paint one radar frame at `phase`.
Args:
img: target RGBA image; must be large enough that
`radii[0] + 8` fits within ``min(img.size) / 2``.
center: (cx, cy) the radar centre in image coordinates.
modules: ordered iterable of Module drawn outermost-first.
metrics: dict passed to `Module.extract` for each ring's fill ratio.
radii: per-ring radius, outermost-first. Default matches the
deployed Pi Zero W fallback radar.
phase: 0..1 maps to 0..2π sweep angle (clockwise from 12 o'clock).
ring_width: stroke width for ring backgrounds and tube arcs.
hub_radius: radius of the dark centre disc.
draw_hub: when False, callers can composite their own centre
(e.g. the converged dashboard's central button + pod cluster).
"""
if radii is None:
radii = DEFAULT_RADII
if name_to_angle is None:
name_to_angle = DEFAULT_NAME_TO_ANGLE
modules = list(modules)
if len(modules) != len(radii):
raise ValueError(
f"modules ({len(modules)}) and radii ({len(radii)}) length mismatch"
)
draw = ImageDraw.Draw(img)
cx, cy = center
sweep_rad = (phase % 1.0) * 2.0 * math.pi
_paint_ring_backgrounds(draw, cx, cy, radii, ring_width)
_paint_tube_arcs(draw, cx, cy, modules, metrics, radii, ring_width,
name_to_angle)
_paint_sweep(draw, cx, cy, modules, metrics, radii, sweep_rad)
_paint_sweep_head(draw, cx, cy, modules[0].colour, radii[0], sweep_rad)
if draw_hub:
draw.ellipse(
(cx - hub_radius, cy - hub_radius, cx + hub_radius, cy + hub_radius),
fill=(12, 12, 22, 255),
)
def _paint_ring_backgrounds(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
radii: list[int],
ring_width: int,
) -> None:
for r in radii:
draw.ellipse(
(cx - r, cy - r, cx + r, cy + r),
outline=(20, 20, 28, 255),
width=ring_width,
)
def _paint_tube_arcs(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
modules: list[Module],
metrics: dict,
radii: list[int],
ring_width: int,
name_to_angle: dict[str, int],
) -> None:
# PIL arc degrees: 0=3 o'clock, counter-clockwise. Each module's arc
# sits at the wheel position matching its colour, looked up by name
# — independent of caller's iteration order.
for m, r in zip(modules, radii):
pct = m.extract(metrics)
if pct <= 0.0:
continue
# Fall back to evenly-spaced positions if name unknown — keeps the
# painter robust against custom module sets without bringing in a
# hard dependency on the canonical six.
center_deg = name_to_angle.get(m.name, 0)
half = (pct * 360.0) / 2.0
start = center_deg + half
end = center_deg - half
# Outer dark edge (color × 1/3).
dark = (m.colour[0] // 3, m.colour[1] // 3, m.colour[2] // 3, 255)
draw.arc(
(cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2),
end, start, fill=dark, width=ring_width - 2,
)
# Main color band.
draw.arc(
(cx - r, cy - r, cx + r, cy + r),
end, start, fill=m.colour + (255,), width=ring_width - 6,
)
# Inner light tube highlight.
light = (
min(255, m.colour[0] + 80),
min(255, m.colour[1] + 80),
min(255, m.colour[2] + 80),
255,
)
draw.arc(
(cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2),
end, start, fill=light, width=4,
)
def _paint_sweep(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
modules: list[Module],
metrics: dict,
radii: list[int],
sweep_rad: float,
) -> None:
# Per-ring sweep segments coloured by that ring's metric.
max_r = radii[0] + 8
min_r = radii[-1] - 8
for idx, m in enumerate(modules):
r = radii[idx]
# Segment bounds — each ring owns the slab between its midpoint
# to the previous ring and its midpoint to the next ring.
r_outer = max_r if idx == 0 else (radii[idx - 1] + r) // 2
r_inner = min_r if idx == len(modules) - 1 else (r + radii[idx + 1]) // 2
pct = m.extract(metrics)
intensity = 0.5 + pct * 0.5
# Fading trail behind the sweep line.
for i in range(15):
offset = -0.15 * (i / 15.0)
a = sweep_rad + offset
fade = 1.0 - (i / 15.0)
x1 = cx + r_inner * math.sin(a)
y1 = cy - r_inner * math.cos(a)
x2 = cx + r_outer * math.sin(a)
y2 = cy - r_outer * math.cos(a)
seg = (
int(m.colour[0] * fade * intensity),
int(m.colour[1] * fade * intensity),
int(m.colour[2] * fade * intensity),
255,
)
draw.line([(x1, y1), (x2, y2)], fill=seg, width=2)
# Main bright leading edge for this ring.
x1 = cx + r_inner * math.sin(sweep_rad)
y1 = cy - r_inner * math.cos(sweep_rad)
x2 = cx + r_outer * math.sin(sweep_rad)
y2 = cy - r_outer * math.cos(sweep_rad)
bright = (
min(255, m.colour[0] + 60),
min(255, m.colour[1] + 60),
min(255, m.colour[2] + 60),
255,
)
draw.line([(x1, y1), (x2, y2)], fill=bright, width=3)
def _paint_sweep_head(
draw: ImageDraw.ImageDraw,
cx: int,
cy: int,
head_color: tuple[int, int, int],
outer_r: int,
sweep_rad: float,
) -> None:
hx = cx + outer_r * math.sin(sweep_rad)
hy = cy - outer_r * math.cos(sweep_rad)
draw.ellipse(
(hx - 4, hy - 4, hx + 4, hy + 4),
fill=head_color + (255,),
)
# Convenience for callers that want a quick HSV-rotating accent colour
# matching the current sweep position (used by the original radar variant
# but kept here for downstream painters that want it).
def sweep_accent(phase: float) -> tuple[int, int, int]:
hue = phase % 1.0
r, g, b = colorsys.hsv_to_rgb(hue, 0.9, 0.8)
return (int(r * 255), int(g * 255), int(b * 255))

View File

@ -0,0 +1,114 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for secubox_common.painters.radar_concentric."""
from __future__ import annotations
import pytest
from PIL import Image
from secubox_common.modules import MODULES
from secubox_common.painters import radar_concentric
METRICS = {
"cpu_percent": 60.0,
"mem_percent": 45.0,
"disk_percent": 30.0,
"load_avg_1": 0.8,
"cpu_temp": 55.0,
"wifi_rssi": -55,
}
def _frame() -> Image.Image:
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
def test_paint_modifies_canvas():
"""Painting at any phase must change the canvas from solid black."""
img = _frame()
radar_concentric.paint(img, (240, 240), MODULES, METRICS, phase=0.0)
# At least one pixel outside the inner hub must have changed.
sample = img.getpixel((240, 30)) # near outer ring
assert sample != (0, 0, 0, 255), \
"outer ring area should have radar pixels painted"
def test_paint_differs_across_phases():
"""phase=0.0 and phase=0.5 must produce different sweep positions."""
a = _frame()
b = _frame()
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=0.5)
assert a.tobytes() != b.tobytes(), \
"frames at phase 0.0 vs 0.5 should differ (sweep rotated 180°)"
def test_paint_wraps_at_phase_one():
"""phase=0.0 and phase=1.0 produce the same frame (period = 1)."""
a = _frame()
b = _frame()
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=1.0)
assert a.tobytes() == b.tobytes(), \
"phase=1.0 must wrap back to phase=0.0"
def test_paint_uses_default_radii_when_none():
"""radii=None must fall back to DEFAULT_RADII (6 entries)."""
img = _frame()
# 6 modules + 6 default radii must match.
radar_concentric.paint(img, (240, 240), MODULES, METRICS, radii=None)
sample = img.getpixel((240, 30))
assert sample != (0, 0, 0, 255)
def test_paint_rejects_mismatched_radii():
"""A radii list of wrong length must raise."""
img = _frame()
with pytest.raises(ValueError, match="length mismatch"):
radar_concentric.paint(
img, (240, 240), MODULES, METRICS, radii=[200, 180, 160]
)
def test_paint_hub_toggle():
"""draw_hub=False must leave the centre area unfilled by the painter
(so callers can composite their own central button + icons)."""
a = _frame()
b = _frame()
radar_concentric.paint(a, (240, 240), MODULES, METRICS,
phase=0.0, draw_hub=True)
radar_concentric.paint(b, (240, 240), MODULES, METRICS,
phase=0.0, draw_hub=False)
# Sample a point well inside the hub (radius ~85). With draw_hub=True
# the colour is the hub fill (12, 12, 22, 255). With False it remains
# the canvas background.
assert a.getpixel((240, 240)) == (12, 12, 22, 255)
assert b.getpixel((240, 240)) == (0, 0, 0, 255)
def test_sweep_accent_in_rgb_range():
"""sweep_accent returns valid RGB tuples for any phase."""
for p in [0.0, 0.25, 0.5, 0.75, 0.999]:
r, g, b = radar_concentric.sweep_accent(p)
for chan in (r, g, b):
assert 0 <= chan <= 255
def test_canvas_method_delegates():
"""DashboardCanvas.paint_radar_concentric must produce the same frame
as calling the painter directly proves the canvas helper is a
thin wrapper, not divergent code."""
from secubox_common.canvas import DashboardCanvas
direct = _frame()
via_canvas = _frame()
radar_concentric.paint(direct, (240, 240), MODULES, METRICS, phase=0.25)
DashboardCanvas().paint_radar_concentric(
via_canvas, (240, 240), MODULES, METRICS, phase=0.25,
)
assert direct.tobytes() == via_canvas.tobytes(), \
"canvas helper should delegate verbatim to painters.radar_concentric.paint"

View File

@ -15,22 +15,32 @@ from secubox_common.modules import MODULES
class RoundDashboard(DashboardCanvas): class RoundDashboard(DashboardCanvas):
SIZE = (480, 480) SIZE = (480, 480)
CENTER = (240, 240) CENTER = (240, 240)
RING_RADII = [200, 185, 170, 155, 140, 125] # Match the deployed fallback_manager.py radar geometry so the round
# rendering stays visually identical after migration.
RING_RADII = [214, 188, 162, 136, 110, 84]
def layout(self, metrics: dict) -> Image.Image: def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
"""Render one frame at the given animation `phase` (0..1).
Phase advances the rotating sweep; callers pass `phase=0.0` for
a static still frame, or `(time.monotonic() * rpm / 60) % 1` for
an animated loop.
"""
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,)) img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
self.paint_rainbow_ring(img, self.CENTER, 235, 220) self.paint_radar_concentric(
self.paint_concentric_arcs(img, self.CENTER, MODULES, metrics, img, self.CENTER, MODULES, metrics,
self.RING_RADII) radii=self.RING_RADII, phase=phase, draw_hub=True,
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=70, pod_size=40) )
# Pods sit on top of the hub; pod_size=48 matches deployed icon
# sizes (22/48/96/128). radius=78 keeps the pod inner edge (54)
# clear of the central button (44).
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=78, pod_size=48)
self.paint_central_button(img, self.CENTER, size=44) self.paint_central_button(img, self.CENTER, size=44)
return img return img
# Round-only additional view modes (called by fb_dashboard.py's main # Round-only additional view modes (called by fb_dashboard.py's main
# loop when the user long-presses center → radial menu → terminal/flash/auth). # loop when the user long-presses center → radial menu → terminal/flash/auth).
def layout_terminal(self, term_state) -> Image.Image: 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 from fb_dashboard import draw_terminal
return draw_terminal(term_state) return draw_terminal(term_state)

View File

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