Compare commits

...

12 Commits

Author SHA1 Message Date
7fafdd9d7c 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 13:24:40 +02:00
8e3b62efc3 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 13:24:40 +02:00
92c5b1f90f 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 13:24:40 +02:00
c39743726a docs(wip): expand #135/PR #140 with this session's fixups + hardware bench
- List the three fixup commits landed on feature/135-* today (pod_size,
  icon assets, gadget composer) so the WIP captures what's actually in
  the PR head right now (89968477, 35 commits ahead, net +35 / -2086).
- Add hardware-bench notes for both round (Pi Zero W) and square (Pi 4B
  + DSI) covering the converged work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:22:04 +02:00
5c72d869a4 docs(wip): housekeeping — 3 stale 127-* worktrees cleaned, master pushed
- Removed feature/127-add-remote-ui-square-variant-for-pi-4b-7 (PR #130 merged)
- Removed feature/127-phase2-square-variant (PR #131 closed, superseded)
- Force-removed feature/127-phase3-python-kiosk (PR #132 merged) — stray Signal
  Desktop apt-key files in the worktree were unrelated to the project, safe to
  discard
- Master synced with origin at a313816e (pushed 839bab94..a313816e)
- Updated section with the table of still-active worktrees for at-a-glance view

Note: agent-worktree.sh clean resolves by issue number which collides for
multi-worktree issues like #127 — used direct git worktree remove + branch -D
instead. Possible enhancement for the script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:19:44 +02:00
a313816edc docs(wip): hardware bench results for #138/#139 + rescope of #139 (closes nothing)
- #138 (PR #142, radar_concentric): mark hardware-validated on Pi 4B+DSI
  (rotating radar + icons + right panel) on 2026-05-15.
- #139 (PR #143, round image cleanup): rescope from "OTG dead" to
  dead-ifupdown / sudo / misleading-comment cleanup; OTG was always
  working on usb1 via secubox-otg-gadget.sh. Bench validated all 3 fixes
  live (Pi Zero W ARMv6 + HyperPixel) including host ping 3/3 at 0.3 ms,
  SSH OPEN, secubox in sudo group.
- Note the orthogonal pre-existing Pi Zero W dwc2 panic under xHCI reset
  hammering, deferred for a separate investigation.

Conflict between local stash and divergent master resolved by taking the
HEAD side; stashed simpler version dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:16 +02:00
9c6e11d54d docs: WIP snapshot 2026-05-15 — mail #136 PR #141, dashboard #135 PR #140, #138 PR #142, #139 PR #143
- Phase 3 (#127): 3 of 4 hardware gates closed (Pi 4B / Pi 400 / Pi Zero W);
  only Task 18 diffoscope regression remaining. 5 bench bugs fixed in PR #134.
- Mail stack Phase 1 (#136): mailctl/mailser + 62-route pytest + acceptance
  smoke landed; PR #141 opened today.
- Converged dashboard (#135): PR #137 merged (839bab94); PR #140 follow-on
  opened today.
- New: #138 radar_concentric → secubox_common (PR #142), #139 round usb0 OTG
  networking bug (PR #143).
- CMSD SPDX (#81): worktree at aa1f7481, no PR yet.
- Note 3 stale 127-* worktrees safe to clean; master 4↑/1↓ vs origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:16 +02:00
3770586a31 docs: Phase 1 rollback recipe (pre-issue) 2026-05-15 13:16:16 +02:00
bd4ef55a98 docs: revise mail Phase 0 spec + Phase 1 plan to match board reality
Live board inspection on 2026-05-15 found that the single-mail-LXC layout
has already been hand-built under /data/lxc/mail with /data/volumes/mail
bind-mounts and 10.100.0.10/24 unprivileged-veth networking on br-lxc.
The repo source code was out of date (still referenced /srv/lxc, /srv/mail,
192.168.255.x, mail_container/webmail_container).

Spec rev. 2:
- Invariants now reflect /var/lib/lxc/mail, /data/volumes/mail, 10.100.0.10
- New invariant I12: persistent data lives on host bind-mounts only
- New invariant I13: existing 5-user secubox.in mailboxes MUST be preserved
- Postgrey dropped entirely; ClamAV deferred to Phase 2 with Rspamd

Plan rev. 2:
- Reduced from ~30 tasks to ~15 — Phase 1 is now "catch source up to board"
- Migration script becomes defensive scanner (no destructive ops)
- guard_data_path bats test enforces the data-preservation invariant
- secubox-mail bumps 2.1.0 → 2.2.0 (was incorrectly targeting 2.0.0 in rev. 1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:16 +02:00
b59d053c54 docs: Phase 1 implementation plan (mail stack LXC consolidation)
Bite-sized TDD plan covering 8 milestones (~30 tasks) to collapse the
existing mailserver + roundcube two-LXC layout into a single 'mail' LXC.
Resolves spec open questions: Roundcube via nginx+php-fpm; HAProxy
TCP-pass-through to LXC for 25/587/993/4190. Includes data migration
helper, deprecation of 3 packages to transitional, and end-to-end
acceptance smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:16 +02:00
2494b55a18 docs: Phase 0 architecture spec for full-featured mail+webmail stack
8-phase plan to deliver multi-domain Postfix+Dovecot+Rspamd+ClamAV+Roundcube
inside a single 'mail' LXC, integrated with secubox-users (identity) and
secubox-nextcloud (CardDAV/CalDAV). Locks 10 architectural invariants,
deprecates secubox-mail-lxc + secubox-webmail-lxc + secubox-webmail, and
records the deferred per-phase design questions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:16:16 +02:00
CyberMind
839bab94a8
Converge round/ + square/ dashboards into secubox_common + pointer input on Pi 4B/400 (#137)
Some checks failed
License Headers / check (push) Has been cancelled
* feat(common): seed secubox_common package skeleton (ref #135)

* 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>

* feat(common): theme.py — palette constants + DEFAULT_FONT loader (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(common): modules.py — canonical Hamiltonian module table (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* 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>

* feat(common): canvas.py — DashboardCanvas base + paint_background (ref #135)

* feat(common): canvas — paint_rainbow_ring (HSV hue rotation) (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(common): canvas — paint_concentric_arcs (per-module ring fill) (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* 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>

* feat(common): canvas — paint_pod_cluster (icon or letter on coloured disc) (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(common): canvas — paint_central_button + paint_alert_ribbon (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(square): SquareDashboard via secubox_common; drop ring_dashboard.py (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* 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>

* feat(square): pointer_input — mouse + touchpad via python-evdev (ref #135)

* 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>

* feat(square): cursor.py — 12×16 arrow sprite for pointer overlay (ref #135)

* 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>

* 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>

* feat(square): __main__ wires SquareDashboard + PointerInput + cursor overlay (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* test(square): cross-form-factor secubox_common API stability test (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(round): RoundDashboard via secubox_common (ref #135)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(round): fb_dashboard.draw_dashboard delegates to RoundDashboard (ref #135)

* build(images): ship secubox_common to /var/www/common/python/ on both images (ref #135)

* build(systemd): PYTHONPATH=/var/www/common/python for both kiosks (ref #135)

---------

Co-authored-by: CyberMind-FR <gandalf@Gk2.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:49:24 +02:00
37 changed files with 3652 additions and 602 deletions

View File

@ -1,5 +1,117 @@
# WIP — Work In Progress
*Mis à jour : 2026-05-14*
*Mis à jour : 2026-05-15*
---
## 🔄 2026-05-15: Mail stack Phase 1 — LXC consolidation + source catch-up (Issue [#136](https://github.com/CyberMind-FR/secubox-deb/issues/136), PR [#141](https://github.com/CyberMind-FR/secubox-deb/pull/141) OPEN)
### Objective
Catch the in-repo source up to the test board's `/data/lxc/mail` + `/data/volumes/mail` + `10.100.0.10/24` reality (repo still references `/srv/lxc`, `/srv/mail`, `192.168.255.30`, separate `mail_container`/`webmail_container`). Phase 1 collapses the dual-container layout into a single mail LXC, deprecates legacy `secubox-mail-lxc` / `secubox-webmail` / `secubox-webmail-lxc` companion packages with `Breaks/Replaces`, and ships HAProxy mail-TCP snippets pointed at the new IP.
### Status (worktree `136-mail-stack-phase-1-source-catch-up-legac`, branch `feature/136-mail-stack-phase-1-source-catch-up-legac`)
- Phase 0 spec rev. 2 (`docs/superpowers/specs/2026-05-15-mail-stack-architecture-design.md`) + Phase 1 plan + rollback recipe committed to master
- mailctl/mailser feature commits landed (latest `bade94f1`)
- Versions bumped to 2.2.0 with `Breaks/Replaces` markers on the three legacy packages
- HAProxy mail-TCP snippet targets new `10.100.0.10`
- Test coverage shipped: 62-route endpoint-presence pytest + end-to-end acceptance smoke
- **PR [#141](https://github.com/CyberMind-FR/secubox-deb/pull/141) opened** today — awaiting review + live deploy/cutover on test board
---
## 🔄 2026-05-15: remote-ui converged dashboard (Issue [#135](https://github.com/CyberMind-FR/secubox-deb/issues/135), PR [#140](https://github.com/CyberMind-FR/secubox-deb/pull/140) OPEN)
### Status
- PR [#137](https://github.com/CyberMind-FR/secubox-deb/pull/137) (initial converge round/ + square/ dashboards into `secubox_common` + pointer input on Pi 4B/400) **squash-merged `839bab94`**
- **PR [#140](https://github.com/CyberMind-FR/secubox-deb/pull/140) OPEN** on `feature/135-converge-round-square-dashboards-into-re` — head `89968477`, 35 commits ahead of `origin/master`, net +35 / 2086 lines
- Spec + plan landed on master (`b5e44e72`, `78316556`)
### Fixups added in this session (2026-05-15)
- `387fabb4` `fixup(common): pod_size=48 to match deployed icon sizes` — modules pods were rendering as letter placeholders on Pi 4B because `paint_pod_cluster(pod_size=40)` missed the deployed icon sizes (22/48/96/128); bumped to 48, radius 70→78 for clearance from the central button.
- `f4acd5a9` `fixup(square): ship remote-ui/common/assets/icons` — the square build copied `common/python/` but skipped `common/assets/`; without the icons under `/var/www/common/assets/icons/`, `secubox_common.icons.load_module_icon` would fall back to first-letter placeholders.
- `89968477` `fixup(square): install secubox-otg-gadget.sh to /usr/local/sbin` — the square `secubox-otg-gadget.service` ExecStarts that path; without the composer script the gadget never composed (zero USB enumeration events on the MOCHAbin, xHCI setup timeouts).
### Hardware bench (2026-05-15)
- **Pi 4B + 7" DSI (square):** converged dashboard renders ✓, icons ✓, all 4 right-panel tabs work (alerts/console/module_detail/mode_controls), USB-C OTG composes to MOCHAbin after the gadget-composer fixup landed.
- **Pi Zero W + HyperPixel 2.1 (round):** boots clean, `fallback_manager.py` OFFLINE radar renders correctly on the existing image.
---
## 🔄 2026-05-15: Port `radar_concentric` into `secubox_common` (Issue [#138](https://github.com/CyberMind-FR/secubox-deb/issues/138), PR [#142](https://github.com/CyberMind-FR/secubox-deb/pull/142) OPEN)
### Status
- Issue opened today; PR [#142](https://github.com/CyberMind-FR/secubox-deb/pull/142) opened the same day on `feature/138-port-radar-concentric-into-secubox-commo` with title "Port radar_concentric into secubox_common + phase-aware dashboards (closes #138)"
- New `secubox_common.painters.radar_concentric` module (phase-aware); module→arc-angle decoupled from list order via `DEFAULT_NAME_TO_ANGLE`
- `RoundDashboard.layout(metrics, phase=0.0)` + `SquareDashboard.layout(metrics, phase=0.0)` — backward-compatible (phase=0 = still frame)
- Square kiosk `__main__` drives `phase = (time.monotonic() * 12.0 / 60.0) % 1.0` at 12 RPM (matches deployed `fallback_manager._sweep_speed`)
- 118 / 118 tests green (36 secubox_common incl. 8 new + 78 square kiosk + 4 round)
- **Hardware bench (Pi 4B + 7" DSI, 2026-05-15):** rotating radar ✓ · icons ✓ · right panel ✓ — user-confirmed
- `fallback_manager.py` migration deferred (visual-palette decision needed; follow-up)
---
## 🔄 2026-05-15: Round image cleanup — dead ifupdown + secubox sudo + OTG comment (Issue [#139](https://github.com/CyberMind-FR/secubox-deb/issues/139), PR [#143](https://github.com/CyberMind-FR/secubox-deb/pull/143) OPEN)
### Original misdiagnosis → rescoped
Initial report claimed "OTG networking dead": `/etc/network/interfaces.d/usb0` was a static stanza for ifupdown, but `ifupdown` was missing → `usb0` stayed DOWN. Wrong conclusion. Live-system probe via ACM serial showed the actual binding is on `usb1` (10.55.0.2/30), set programmatically by `secubox-otg-gadget.sh`. The dead ifupdown stanza never did anything; OTG was always working.
### Rescoped fix
- Drop dead `/etc/network/interfaces.d/usb0` (file + the inline heredoc in `build-eye-remote-image.sh` that recreated it)
- Add `secubox` user to `sudo` group (so ACM serial recovery is possible — previously the only-path-in had no path-to-fix)
- Rewrite the misleading `usb1 = ECM` comment in `secubox-otg-gadget.sh` — RNDIS+ECM share host_addr so host reaches 10.55.0.2 via either function
### Status
- Issue opened 2026-05-15 10:27; PR [#143](https://github.com/CyberMind-FR/secubox-deb/pull/143) opened 2 minutes later on `fix/139-round-image-usb0-otg-networking-dead-ifu`
- Initial misdiagnosis annotated as a comment on issue #139 — diagnostic confusion came from the dead stanza being visible
- **Hardware bench (Pi Zero W 1st gen, HyperPixel 2.1 Round, 2026-05-15):** all 3 fixes verified live:
- `/etc/network/interfaces.d/` directory absent ✓
- `secubox` in `sudo` group (`27(sudo)`) ✓
- Gadget composer comment rewritten ✓
- Bonus: ping `10.55.0.2` from host 3/3 received at 0.3 ms, SSH port 22 OPEN, both `usb0` + `usb1` UP @ 10.55.0.2/30 on the Pi
- Mid-bench: caught a pre-existing Pi Zero W `dwc2` kernel panic under host xHCI reset hammering — unrelated to #143 (image was good, dwc2 driver instability on ARMv6 under USB stress); deferred to a separate investigation
---
## 🔄 2026-05-15: CMSD SPDX header rollout (Issue [#81](https://github.com/CyberMind-FR/secubox-deb/issues/81))
### Status
Worktree `secubox-deb-license-wt` on branch `feature/license-phase-b-full` at `aa1f7481` ("enroll all in-scope files via `**` allowlist (Phase B + C)"). Phase A + B + C work all on the branch but no PR opened yet.
---
## 🧹 2026-05-15: worktree + local-state housekeeping
### Cleaned 2026-05-15
- `secubox-deb-worktrees/127-add-remote-ui-square-variant-for-pi-4b-7` (Phase 1, PR #130 merged as `7c37415f`) — removed
- `secubox-deb-worktrees/127-phase2-square-variant` (Phase 2, PR #131 closed/superseded) — removed
- `secubox-deb-worktrees/127-phase3-python-kiosk` (Phase 3, PR #132 merged as `dee8bf8b`) — force-removed (had two stray untracked Signal Desktop apt-key files unrelated to the project, safe to discard)
All three feature branches deleted locally. `agent-worktree.sh clean` resolves by issue number which collides for multi-worktree issues like #127; used direct `git worktree remove` + `git branch -D`.
### Local master state
- `master` synced with `origin/master` at `a313816e` (pushed `839bab94..a313816e`, 6 commits). Was 6 ahead / 1 behind earlier; rebased then pushed.
- Untracked: `.claude/settings.json` (pre-existing, intentional)
### Worktrees still active
| Worktree | Branch | Backing PR |
|---|---|---|
| `135-converge-round-square-dashboards-into-re` | `feature/135-…` | #140 OPEN |
| `136-mail-stack-phase-1-source-catch-up-legac` | `feature/136-…` | #141 OPEN |
| `138-port-radar-concentric-into-secubox-commo` | `feature/138-…` | #142 OPEN |
| `139-round-image-usb0-otg-networking-dead-ifu` | `fix/139-…` | #143 OPEN |
| `secubox-deb-license-wt` | `feature/license-phase-b-full` | none (SPDX rollout #81) |
---
@ -20,11 +132,24 @@ Replace Phase 2's Chromium+PySide6 dual-window stack with a single-process Pytho
- Phase 2 PR #131 closed (superseded by Phase 3)
- **Squash-merged 2026-05-14 as `dee8bf8b`** on master (after Phase 1 `7c37415f`)
### Followups
### Hardware gates — 3 of 4 closed (2026-05-15)
- **Task 24 (Pi 400 manual sanity) — IN PROGRESS** this session: building `secubox-eye-square_0.2.0_arm64.img.xz` locally for flash to uSD.
- **Task 23 (Pi 4B manual bench)** — still pending hardware (build + flash + boot + kiosk visible + OTG link to MOCHAbin).
- Issue #127 stays open until both Pi 4B and Pi 400 benches pass.
| Task | Hardware | Status |
|------|----------|--------|
| **Task 23** — Pi 4B square/ manual bench | Pi 4B + official 7" DSI 800×480 | ✅ kiosk renders correctly post-#134 fixes |
| **Task 24** — Pi 400 square/ sanity | Pi 400 + HDMI 1920×1080 | ✅ same image, kiosk center-padded into letterbox (PR #134 second commit) |
| **Task 19** — Pi Zero W round/ manual bench | Pi Zero W + HyperPixel 2.1 | ✅ booted from CI-built `secubox-eye-remote-2.2.1.img.xz`, rainbow ring dashboard clean post-`common/` |
| Task 18 — round/ `diffoscope` regression gate | n/a (automated) | ⏳ still blocked on `hyperpixel2r.dtbo` prerequisite |
### Bug haul from the bench (fixed in PR [#134](https://github.com/CyberMind-FR/secubox-deb/pull/134), merged `a3a918ed`)
1. `/run/secubox` not recreated at boot (tmpfs wipe) → added `tmpfiles.d/secubox-eye-square.conf`
2. `fonts-dejavu-core` missing from chroot apt-install → added
3. `draw.text()` calls relied on Pillow legacy bitmap default (no Unicode) → `theme.DEFAULT_FONT` + `font=` kwarg on all 25 call sites
4. `framebuffer.py` hardcoded 32bpp BGRA but `vc4drmfb` is 16bpp RGB565 → numpy RGB565 packer + `bits_per_pixel` auto-detect
5. (followup) `framebuffer.py` hardcoded 800×480 → `virtual_size` auto-detect + center-pad for HDMI
Issue #127 closure now gated only on Task 18. Bug #139 (round image OTG networking) and enhancement #138 (radar to common) were surfaced from the same bench session — see today's PRs above.
---

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,46 @@
# Mail Phase 1 — Rollback recipe
Backups produced 2026-05-15 07:41 on test board 192.168.1.200 by
`docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md` Task 0.
## What's in `/srv/backups/mail-phase1/`
| File | Size | Contents |
|---|---|---|
| `data-volumes-mail-2026-05-15-0741.tar.gz` | 48K | Entire `/data/volumes/mail/` tree — vmail dirs for `secubox.in/{gk2,bat,bourdon,lemurien,ragondin}`, Postfix lookup tables, ACME certs |
| `lxc-mail-config-2026-05-15-0741.tar.gz` | 4.0K | `/data/lxc/mail/config` (the LXC's unprivileged-veth network config) |
| `mail-toml-2026-05-15-0741.bak` | 0.4K | Original `/etc/secubox/mail.toml` (still has legacy keys) |
| `pkglist-2026-05-15-0741.txt` | 0.7K | `dpkg -l` output for `secubox-mail*` + `secubox-webmail*` pre-deploy |
## Rollback procedure
If Phase 1 deploy breaks the mail stack on the board:
```bash
ssh root@192.168.1.200 'set -euo pipefail
lxc-stop -n mail 2>/dev/null || true
# Restore /data/volumes/mail (vmail + config + ssl)
rm -rf /data/volumes/mail
tar -xzf /srv/backups/mail-phase1/data-volumes-mail-2026-05-15-0741.tar.gz -C /
# Restore LXC config
tar -xzf /srv/backups/mail-phase1/lxc-mail-config-2026-05-15-0741.tar.gz -C /
# Restore toml + downgrade packages
cp /srv/backups/mail-phase1/mail-toml-2026-05-15-0741.bak /etc/secubox/mail.toml
apt install --allow-downgrades -y \
secubox-mail=2.1.0-1~bookworm1 \
secubox-mail-lxc=1.1.0-1~bookworm1 \
secubox-webmail=1.0.0-1~bookworm1 \
secubox-webmail-lxc=1.1.0-1~bookworm1
systemctl restart secubox-mail nginx haproxy'
```
## Priority guarantees
- The data tarball preserves the 5 live `secubox.in` mailboxes (`gk2`, `bat`,
`bourdon`, `lemurien`, `ragondin`) and the ACME certs from Feb 2026.
- Per spec rev. 2 invariant **I13**, this data MUST NOT be lost. If anything
goes wrong, restoring this tarball is the first and most important step.

View File

@ -0,0 +1,316 @@
# Mail Stack Architecture — Phase 0 Design (rev. 2)
**Date:** 2026-05-15 (rev. 2 — reconciled with board reality)
**Status:** Approved direction; revised invariants pending user re-confirmation
**Author:** Gérald Kerma <devel@cybermind.fr>
**Scope:** Architecture-only. Each implementation phase below gets its own spec → plan → PR cycle.
> **Revision note (rev. 2, 2026-05-15):** Initial draft assumed a `/srv/lxc/` + `192.168.255.x` + `lxc.net.0.type = none` greenfield layout. Live board inspection on 2026-05-15 showed the actual single-`mail` LXC has already been hand-built on the test board with a modern unprivileged-veth layout under `/data/lxc/` + `/data/volumes/`. Invariants have been corrected; Phase 1 is reduced from "consolidate two LXCs" to "catch the repo source up to where the board already is, then deprecate the legacy package frame".
---
## 1. Goal
Deliver a "full-featured" multi-domain mail + collaboration stack inside a single LXC container, integrated with the existing SecuBox-Deb identity (`secubox-users`), DNS (`secubox-dns`) and storage (`secubox-nextcloud`) services.
**Definition of full-featured for this project:**
- Standards-compliant SMTP/IMAP with TLS, SPF, DKIM, DMARC, ARC
- Per-user features: ManageSieve filters, quotas, vacation, app passwords
- Multi-domain virtual hosting with per-domain DKIM keys
- Roundcube webmail with PGP (Enigma), 2FA, ManageSieve UI, CardDAV/CalDAV bridged to Nextcloud
- Mailing lists (mlmmj) and shared mailboxes
- imapsync-based migration from OpenWrt SecuBox or any external IMAP
- End-user self-service portal (password, vacation, aliases, app-passwords, sieve)
- Observability + outbound abuse policies
## 2. Non-goals
- ActiveSync / EAS protocol (Z-Push, grommunio) — not in scope this round
- JMAP (Cyrus pivot) — not in scope
- Mail archival / legal hold — not in scope
- Independent CardDAV/CalDAV server inside the mail LXC — delegated to `secubox-nextcloud`
## 3. Locked invariants
Each phase below MUST respect these. Changing one requires a new Phase 0 revision.
| # | Invariant |
|---|---|
| **I1** | Exactly ONE LXC named `mail` at `/data/lxc/mail` (symlinked from `/var/lib/lxc/mail`). No separate `mailserver` or `roundcube` LXCs. |
| **I2** | LXC is **unprivileged** (`lxc.idmap = u 0 100000 65536`), veth on bridge `br-lxc`, IPv4 `10.100.0.10/24` with gateway `10.100.0.1`. AppArmor + `debian.common.conf` includes. |
| **I3** | Antispam stack is **Rspamd** (single daemon: greylisting + spam scoring + DKIM sign+verify + SPF + DMARC + ARC). ClamAV remains as separate AV milter. SpamAssassin, Postgrey, OpenDKIM, opendmarc removed in Phase 2. |
| **I4** | CardDAV + CalDAV are **not** served from the mail LXC. Roundcube plugins point at `https://nextcloud.gk2.secubox.in/remote.php/dav/`. |
| **I5** | Mail accounts are provisioned **by** `secubox-users`. The mail stack is a downstream consumer. Local Dovecot is the materialized projection. |
| **I6** | Outbound delivery is **direct on port 25**. No smarthost relay. |
| **I7** | Multi-domain virtual users. Mailbox path: `/data/volumes/mail/vmail/<domain>/<user>/Maildir/`. Per-domain DKIM key (or Rspamd selector after Phase 2). |
| **I8** | Existing data is migrated from OpenWrt SecuBox via **imapsync** (Phase 7). |
| **I9** | Webmail = Roundcube. SOGo / Cyrus / grommunio rejected. |
| **I10** | All container daemons listen on the LXC IP (`10.100.0.10`). Exposed to LAN/WAN via host's HAProxy (SMTP/IMAPS TCP pass-through) + nginx (admin + webmail HTTPS). |
| **I11** | Configuration source of truth: `/etc/secubox/mail.toml` on the host. Rendered into the LXC by `mailctl`. No editing config inside the LXC. |
| **I12** | **Persistent data lives on the host under `/data/volumes/mail/{vmail,config,ssl}`** and is bind-mounted into the LXC. Destroying the LXC rootfs MUST be safe — no production data lives in the rootfs. |
| **I13** | **Existing mail data on the test board MUST be preserved.** As of 2026-05-15 the board hosts the `secubox.in` domain with five live mailboxes (`gk2`, `bat`, `bourdon`, `lemurien`, `ragondin`) under `/data/volumes/mail/vmail/secubox.in/`. Any upgrade path that touches the data directory MUST refuse to proceed if it cannot guarantee preservation. |
## 4. Current state (test board 192.168.1.200, surveyed 2026-05-15)
| Element | Reality |
|---|---|
| LXCs on board | `gitea`, `mail`, `matrix`, `mitmproxy`, `nextcloud`, `streamlit` |
| `mail` LXC location | `/data/lxc/mail/` (symlinked from `/var/lib/lxc/mail`) |
| `mail` LXC state | STOPPED (last touched 2026-05-08) |
| `mail` LXC networking | unprivileged, veth `br-lxc`, `10.100.0.10/24`, gw `10.100.0.1` |
| `mail` LXC bind mounts | `/data/volumes/mail/vmail``var/vmail`, `/data/volumes/mail/config``etc/mail-config`, `/data/volumes/mail/ssl``etc/ssl/mail` |
| Inside-LXC software | Postfix, Dovecot (core+imapd+lmtpd+pop3d), Apache2+mod_php, nginx, OpenDKIM, SpamAssassin, Roundcube (core+plugins+classic+larry skins, mysql backend), php-net-sieve |
| **NOT yet inside LXC** | Postgrey, ClamAV (planned by spec rev. 1 — never installed; rev. 2 drops Postgrey entirely and defers ClamAV to Phase 2) |
| Persistent data | `/data/volumes/mail/vmail/{secubox.in/{gk2,bat,bourdon,lemurien,ragondin},gk2}`, `/data/volumes/mail/config/{main.cf,master.cf,vmailbox,virtual,vdomains,users,aliases,...}`, `/data/volumes/mail/ssl/{fullchain.pem,privkey.pem}` (Feb 2026 ACME issue) |
| Host packages | `secubox-mail 2.1.0-1`, `secubox-mail-lxc 1.1.0-1`, `secubox-webmail 1.0.0-1`, `secubox-webmail-lxc 1.1.0-1` |
| Host service | `secubox-mail.service` is `active` (FastAPI listens, but mail LXC isn't running) |
| Postfix `main.cf` (in `/data/volumes/mail/config/`) | hostname `mail.secubox.in`, virtual mailbox domains via `/etc/postfix/vdomains`, SASL via Dovecot, TLS via `/etc/ssl/mail/`, Maildir layout |
| Roundcube webserver | Apache2 + libapache2-mod-php8.2 (BOTH nginx and apache2 packages installed inside LXC; only one needed) |
| Repo source layout (this tree) | Out of date: `mailctl` still references `/srv/lxc`, `/srv/mail`, `mail_container = "mailserver"`, `webmail_container = "roundcube"`, `192.168.255.30`. The single `mail` LXC was hand-built outside the repo. |
| Host `mail.toml` | Out of date: still has `mail_container`, `webmail_container`, `mail_ip = "192.168.255.30"`, `webmail_ip = "192.168.255.31"` |
## 5. Target architecture
### 5.1 LXC layout (canonical)
```
/var/lib/lxc/mail -> /data/lxc/mail (symlink, host-side)
/data/lxc/mail/
config # LXC config (unprivileged, veth, br-lxc, 10.100.0.10/24)
rootfs/ # Debian bookworm arm64
etc/postfix/ # rendered by mailctl (read-only at runtime)
etc/dovecot/
etc/rspamd/ # Phase 2+
etc/clamav/ # Phase 2+
etc/apache2/ # Phase 1 keeps Apache; Phase 5 may revisit
etc/roundcube/
etc/mlmmj/ # Phase 6+
opt/start-mail.sh # init script run by lxc.init.cmd
/data/volumes/mail/ # Persistent data (bind-mounted into LXC)
vmail/ # Maildirs
secubox.in/<user>/Maildir/
<future-domain>/<user>/Maildir/
config/ # Postfix/Dovecot lookup tables, owned by host
main.cf, master.cf
users, vmailbox, virtual, valias, vdomains, aliases
*.lmdb (rebuilt by postmap)
ssl/ # ACME-issued certs (host renews, container reads)
fullchain.pem, privkey.pem
dkim/ # per-domain keys (Phase 2 owned by Rspamd)
rspamd/ # Phase 2 — bayes corpus, history
clamav/ # Phase 2 — virus signature DB
sieve/ # Phase 4 — per-user sieve scripts
mlmmj/ # Phase 6 — mailing list spools
roundcube/ # Phase 5 — user data, logs, plugin state
```
### 5.2 Network and ports
LXC IP: `10.100.0.10` (br-lxc). Gateway: `10.100.0.1` (host bridge).
| Listener | Port | Protocol | Exposed how |
|---|---|---|---|
| Postfix smtpd | 25 | SMTP | HAProxy TCP pass-through, WAN |
| Postfix submission | 587 | SMTP+STARTTLS+SASL | HAProxy TCP pass-through, WAN |
| Postfix submissions | 465 | SMTPS+SASL | HAProxy TCP pass-through, WAN |
| Dovecot imap | 143 | IMAP+STARTTLS | LAN only |
| Dovecot imaps | 993 | IMAPS | HAProxy TCP pass-through, WAN |
| Dovecot ManageSieve | 4190 | sieve+STARTTLS | HAProxy TCP pass-through, WAN |
| Rspamd controller | 11334 | HTTP | Behind host nginx admin auth (Phase 2+) |
| Rspamd worker | 11332 | milter | Localhost-in-LXC only (Phase 2+) |
| ClamAV milter | 8894 | milter | Localhost-in-LXC only (Phase 2+) |
| Roundcube HTTP (Apache or nginx) | 80 / 443 | HTTP | Behind host nginx on `webmail.<domain>` |
Host nginx publishes:
- `https://mail-admin.gk2.secubox.in/` → FastAPI on UNIX socket `/run/secubox/mail.sock`
- `https://webmail.gk2.secubox.in/``http://10.100.0.10:80/` (Roundcube)
- `https://mail.gk2.secubox.in/.well-known/autoconfig/...` → FastAPI autoconfig
- `https://rspamd.gk2.secubox.in/``http://10.100.0.10:11334/` (Phase 2+, admin-auth gated)
### 5.3 Daemon inventory (end of Phase 8)
Inside `mail` LXC:
| Daemon | Source | Role | Phase added |
|---|---|---|---|
| Postfix | Debian | MTA | already on board |
| Dovecot | Debian | IMAP + LMTP + ManageSieve + SASL auth | already on board |
| Apache2 + mod_php | Debian | Roundcube webserver | already on board (Phase 5 may migrate to nginx+php-fpm) |
| Roundcube | Debian | Webmail (with classic/larry skins, plugins) | already on board |
| Rspamd | Debian | Greylist + spam + DKIM + SPF + DMARC + ARC + ratelimit | Phase 2 |
| ClamAV (clamd + clamav-milter) | Debian | Virus scan | Phase 2 |
| mlmmj | Debian | Mailing lists | Phase 6 |
| acme.sh | upstream | TLS cert renewal | host-side, already wired |
| imapsync | upstream | One-shot per migration job | Phase 7 |
**Daemons removed by Phase 2:** SpamAssassin, OpenDKIM. (Postgrey was planned by rev. 1 but never installed; dropped from scope.)
### 5.4 Identity / provisioning flow (Phase 3)
```
secubox-users API ──"user.created"──▶ mail provisioning webhook
mailctl provision <user@domain>
├──▶ /data/volumes/mail/vmail/<domain>/<user>/Maildir (mkdir + perms)
├──▶ append /data/volumes/mail/config/users (Dovecot passwd-file, SHA512-CRYPT)
├──▶ append /data/volumes/mail/config/vmailbox (Postfix virtual_mailbox_maps)
└──▶ postmap if needed; notify Rspamd
```
Password sync: `secubox-users` POSTs `/internal/password` over UNIX socket on every change. No password ever leaves the host except as the SHA512-CRYPT hash already stored in Dovecot's `users` file.
### 5.5 DNS records owned by the mail stack
For each managed domain, `mailctl dns-records <domain>` emits records `secubox-dns` must publish:
```
mail.<domain> A <public IP>
<domain> MX 10 mail.<domain>.
<domain> TXT "v=spf1 mx -all"
default._domainkey.<domain> TXT "v=DKIM1; k=rsa; p=<pubkey>"
_dmarc.<domain> TXT "v=DMARC1; p=quarantine; rua=mailto:postmaster@<domain>; ruf=mailto:postmaster@<domain>; adkim=s; aspf=s"
_imaps._tcp.<domain> SRV "0 1 993 mail.<domain>."
_submission._tcp.<domain> SRV "0 1 587 mail.<domain>."
autoconfig.<domain> CNAME mail.<domain>.
autodiscover.<domain> CNAME mail.<domain>.
```
Phase 3 wires this to `secubox-dns` via API.
### 5.6 `mail.toml` schema (target — end of Phase 3)
```toml
[mail]
enabled = true
hostname = "mail.gk2.secubox.in"
container = "mail"
lxc_path = "/var/lib/lxc" # symlink to /data/lxc on this board
data_path = "/data/volumes/mail"
lxc_ip = "10.100.0.10"
lxc_bridge = "br-lxc"
lxc_gateway = "10.100.0.1"
[[mail.domain]]
name = "secubox.in"
primary = true
dkim_selector = "default"
dmarc_policy = "quarantine"
catchall = ""
[mail.tls]
provider = "acme"
acme_email = "postmaster@secubox.in"
[mail.rspamd] # Phase 2+
greylist = true
bayes_autolearn = true
ratelimit_outbound = "100/h/user"
[mail.identity] # Phase 3+
source = "secubox-users"
provisioning_url = "http://127.0.0.1:8093/api/v1/users"
[mail.dav] # Phase 5+
provider = "secubox-nextcloud"
url = "https://nextcloud.gk2.secubox.in/remote.php/dav/"
[mail.webmail] # Phase 5+
enabled = true
url = "https://webmail.gk2.secubox.in/"
plugins = ["managesieve", "carddav", "calendar", "enigma", "twofactor"]
[mail.lists] # Phase 6+
enabled = false
default_domain = "lists.gk2.secubox.in"
```
## 6. Phase plan (revised)
Phase 1 is now substantially smaller: most of the architectural bones are already on the board; the repo source just doesn't reflect them yet.
| # | Phase | Effort | Critical-path? |
|---|---|---|---|
| **0** | Architecture spec (this doc, rev. 2) | done | — |
| **1** | **Reconcile source ↔ board, deprecate legacy packages, lock the data contract** | 23 days | yes |
| **2** | Rspamd migration (drops SA + OpenDKIM, adds ClamAV) | 1 wk | yes |
| **3** | Multi-domain + `secubox-users` provisioning hook | 1.5 wk | yes |
| **4** | ManageSieve + quotas + vacation | 1 wk | yes |
| **5** | Roundcube polish + Nextcloud DAV bridge + (optional) Apache→nginx+php-fpm | 1 wk | no |
| **6** | mlmmj mailing lists + shared mailboxes | 1 wk | no |
| **7** | imapsync migration tooling | 1 wk | no |
| **8** | Self-service portal + observability + outbound abuse policies | 1.5 wk | no |
**Total:** ~8 weeks. Phase 58 can interleave once Phase 3 is in.
### Phase 1 — revised goal: "source-catch-up + legacy package cleanup"
**Deliverables**
- Repo source updated to canonical paths/IP: `/var/lib/lxc/mail`, `/data/volumes/mail`, `10.100.0.10`, unprivileged veth br-lxc. (`mailctl`, `mailserverctl`, `roundcubectl`, `api/main.py`.)
- `mail.toml` schema: single `container`, `lxc_ip`, `lxc_bridge`, `lxc_gateway`, `data_path`. Drop `mail_container`/`webmail_container`/`mail_ip`/`webmail_ip`/`webmail_port`.
- `lib/install.sh` + `lib/lxc.sh` extracted from `mailserverctl` for re-use.
- `mailctl migrate-config` rewrites a legacy `mail.toml` in place. Idempotent.
- `mail-migrate-to-single-lxc.sh` becomes a defensive **scanner** that detects old `mailserver`/`roundcube` LXC directories (none expected on this board) and old toml keys, and applies safe migration. **Refuses to touch `/data/volumes/mail/` if data is present** (per I13).
- Legacy `secubox-mail-lxc`, `secubox-webmail-lxc`, `secubox-webmail` packages → transitional metadata-only `2.2.0` packages that just `Depends: secubox-mail (>= 2.2)`.
- `secubox-mail` bumps to `2.2.0` (one minor higher than current `2.1.0`) with `Breaks:`/`Replaces:` against the transitional packages.
- Host nginx vhost: `mail-admin.<base>` → FastAPI socket; `webmail.<base>``http://10.100.0.10:80/`. Replaces both `packages/secubox-mail/nginx/mail.conf` and `packages/secubox-webmail/nginx/webmail.conf` with one `common/nginx/modules.d/mail.conf`.
- HAProxy SMTP/submission/IMAPS/sieve backends targeting `10.100.0.10`.
- API `main.py` updated to read new keys; all 62 endpoints respond non-5xx (presence test).
- Acceptance: from clean checkout + deploy, `mailctl status` correctly reports the existing `mail` LXC; `mailctl start` brings it up; existing 5 `secubox.in` users can IMAP login; Roundcube responds via host proxy.
**Explicitly out of Phase 1:**
- Installing Postgrey / ClamAV inside the LXC — Phase 2 handles ClamAV; Postgrey is dropped entirely.
- Multi-domain refactor — Phase 3.
- Apache → nginx+php-fpm migration — Phase 5 if desired.
- Roundcube CardDAV/CalDAV plugin wiring — Phase 5.
## 7. Deprecations and breaking changes
| Item | Phase | Migration |
|---|---|---|
| `secubox-mail-lxc` package | 1 | Transitional 2.2.0 stub depending on `secubox-mail (>= 2.2)`. Removed entirely in 3.0. |
| `secubox-webmail-lxc` package | 1 | Same |
| `secubox-webmail` package | 1 | Same — its API surface folded into `secubox-mail` API. |
| `mail_container`, `webmail_container`, `mail_ip`, `webmail_ip`, `webmail_port` in `mail.toml` | 1 | `mailctl migrate-config` rewrites to single `container`/`lxc_ip` + comments the old keys for one release. |
| `/srv/lxc/`, `/srv/mail/` paths in source | 1 | Replaced by `/var/lib/lxc/` and `/data/volumes/mail/` everywhere. |
| `192.168.255.30/31` IP literals in source | 1 | Replaced by `10.100.0.10` (and `lxc_ip` lookup from toml). |
| OpenDKIM (`/dkim/*` API) | 2 | Rspamd DKIM module; old endpoints proxy for one minor version, removed in 3.0. |
| SpamAssassin (`/spam/*`) | 2 | Rspamd spam scoring; same pattern. |
| Postgrey (`/grey/*`) | 2 | Rspamd greylist module; the `/grey/*` endpoints were stubbed but Postgrey was never installed — endpoints return informative deprecation responses. |
| `domain` scalar in `mail.toml` | 3 | Migrated to `[[mail.domain]]` array. |
## 8. GitHub issue plan
| # | Title | Label | Phase |
|---|---|---|---|
| TBD | Mail stack: Phase 1 — source-catch-up + legacy package cleanup | `migration,wip` | 1 |
| TBD | Mail stack: Phase 2 — Rspamd migration | `migration,security` | 2 |
| TBD | Mail stack: Phase 3 — multi-domain + secubox-users integration | `migration,api` | 3 |
| TBD | Mail stack: Phase 4 — ManageSieve + quotas + vacation | `api,frontend` | 4 |
| TBD | Mail stack: Phase 5 — Roundcube polish + Nextcloud DAV bridge | `frontend` | 5 |
| TBD | Mail stack: Phase 6 — mailing lists + shared mailboxes | `api,frontend` | 6 |
| TBD | Mail stack: Phase 7 — imapsync migration tooling | `migration` | 7 |
| TBD | Mail stack: Phase 8 — self-service portal + metrics + abuse | `frontend,infra` | 8 |
Issues filed at start of each phase.
## 9. Open questions (deferred to per-phase specs)
- **Roundcube webserver (Phase 5):** Keep Apache+mod_php (current) or migrate to nginx+php-fpm? Decided in Phase 5 spec. Phase 1 does **not** touch this.
- **PGP key escrow (Phase 5/8):** read-only after import vs. user-managed in self-service portal?
- **HAProxy SMTP cert handling:** TCP pass-through (current direction) vs. terminate at HAProxy with shared cert. Phase 1 stays pass-through; revisit only if cert renewal proves painful.
- **Mailing list tool (Phase 6):** mlmmj vs. Mailman 3?
## 10. ANSSI / CSPN posture
- **Privilege separation:** every daemon under its own user. LXC unprivileged adds a second layer (root inside LXC = uid 100000 outside).
- **Audit logging:** all admin actions (provision, delete, password reset, sieve edit) appended to `/data/volumes/mail/audit.log` and to `secubox-users` audit stream.
- **Double-buffer config:** `mailctl` writes Postfix/Dovecot/Rspamd config under `/data/volumes/mail/config/shadow/`, validates with `postfix check` / `doveconf -n`, atomic-swap to `active/`. Keeps R1..R4.
- **AppArmor profiles:** one per daemon, shipped by `secubox-mail` debian/, enforced via `postinst`.
- **Secrets:** Dovecot SHA512-CRYPT only; DKIM private keys 0600 owned by `_rspamd` (post-Phase-2); ACME private keys 0600 owned by root. Nothing leaves the host.
---
**End of Phase 0 spec rev. 2.** Next: revised Phase 1 plan, then user re-confirmation, then execution.

View File

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

View File

@ -0,0 +1,35 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Cursor sprite — drawn as the last overlay each frame when the pointer
has moved within the AUTO_HIDE_S window."""
from __future__ import annotations
from PIL import Image, ImageDraw
from secubox_common import theme
# Arrow polygon (hand-drawn, top-left origin). _W/_H bound the sprite
# and feed the off-canvas guard so the guard stays in sync with the
# polygon shape.
_W, _H = 12, 16
_OUTLINE = theme.GOLD_HERMETIC + (255,)
_FILL = (0x00, 0x00, 0x00, 255)
_POLY = [
(0, 0), (10, 6), (5, 6), (8, 14), (5, 15), (3, 8), (0, 11),
]
def draw_cursor(img: Image.Image, x: int, y: int) -> None:
"""Draw the cursor sprite with hot-spot at (x, y).
Sprite extends 0..11 px right and 0..15 px down from the hot-spot.
Partial off-canvas placement is fine Pillow's polygon clips itself.
Coordinates with x < 0 or y < 0 fully off-canvas: no-op."""
if x + _W < 0 or y + _H < 0 or x >= img.size[0] or y >= img.size[1]:
return
draw = ImageDraw.Draw(img)
shifted = [(x + px, y + py) for (px, py) in _POLY]
draw.polygon(shifted, fill=_FILL, outline=_OUTLINE)

View File

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

View File

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

View File

@ -1,153 +0,0 @@
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Ring dashboard — left 480x480 Pillow renderer.
Pixel-faithful intent vs Phase 1 round/index.html: 6 concentric arcs
(radii 214/201/188/175/162/149), each module colour-mapped, smooth fill
animation toward target value, central clock + hostname + uptime,
transport badge, status row, temperature bar. Alerts ribbon overlays
bottom 24px when severity warn.
"""
from __future__ import annotations
import math
import socket
import time
from datetime import datetime
from typing import Callable, Optional
from PIL import Image, ImageDraw
from . import theme
from .modules_table import MODULES, Module
CX, CY = 240, 240
RING_WIDTH = 5
EASE_STEPS = 8 # animation frames between metric updates
POD_DISTANCE = 235
ALERT_RIBBON_HEIGHT = 24
TRANSPORT_BADGE_Y = 14
class RingDashboard:
"""480x480 left half. update_metrics() sets target values; advance() eases
current values toward target each tick; draw() renders the frame."""
def __init__(self):
self.size = (480, 480)
self.transport = "SIM"
self.hostname = socket.gethostname()
self._current: dict[str, float] = {m.metric: 0.0 for m in MODULES}
self._target: dict[str, float] = {m.metric: 0.0 for m in MODULES}
self._alert_text = ""
self._alert_severity = "info"
self.on_module_tap: Callable[[str], None] = lambda _: None
def update_metrics(self, metrics: dict) -> None:
"""Set new target values. _current eases toward _target over EASE_STEPS frames.
Stores raw metric values (e.g. cpu_percent=80.0). The modules_table
extract() function converts to 0..1 fill ratio at draw time.
"""
for m in MODULES:
if m.metric in metrics:
self._target[m.metric] = float(metrics[m.metric])
def advance(self) -> None:
"""One easing frame — move _current toward _target by 1/EASE_STEPS."""
for m in MODULES:
cur = self._current[m.metric]
tgt = self._target[m.metric]
self._current[m.metric] = cur + (tgt - cur) / EASE_STEPS
def set_transport(self, active: str) -> None:
self.transport = active
def set_alert_ribbon(self, text: str, severity: str = "info") -> None:
self._alert_text = text
self._alert_severity = severity
def clear_alert_ribbon(self) -> None:
self._alert_text = ""
def handle_tap(self, x: int, y: int) -> None:
"""Detect pod taps. Pods sit at angles -π/2, -π/2+π/3, ... around the ring."""
dx, dy = x - CX, y - CY
dist = math.hypot(dx, dy)
if abs(dist - POD_DISTANCE) > 30:
return
# angle in radians, 0 = right, -π/2 = top
angle = math.atan2(dy, dx)
# Normalise so AUTH is at -π/2 (top), increment by π/3 clockwise
normalised = (angle + math.pi / 2) % (2 * math.pi)
idx = int(normalised / (math.pi / 3))
if 0 <= idx < len(MODULES):
self.on_module_tap(MODULES[idx].name)
def _pod_position(self, idx: int) -> tuple[int, int]:
"""Where to draw the idx-th pod's icon/label."""
angle = -math.pi / 2 + idx * (math.pi / 3)
x = CX + int(POD_DISTANCE * math.cos(angle))
y = CY + int(POD_DISTANCE * math.sin(angle))
return x, y
def draw(self) -> Image.Image:
img = Image.new("RGBA", self.size, theme.COSMOS_BLACK + (255,))
draw = ImageDraw.Draw(img)
# 6 rings — draw track then fill arc for each module
for m in MODULES:
pct = m.extract(self._current)
# ring track (full circle, very dark)
draw.arc(
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
start=-90, end=270,
fill=(0x14, 0x14, 0x14, 255), width=RING_WIDTH + 2,
)
# ring fill (proportional arc from top, clockwise)
if pct > 0.005:
end_angle = -90 + 360 * pct
draw.arc(
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
start=-90, end=end_angle,
fill=m.colour + (255,), width=RING_WIDTH,
)
# Pods — coloured dot at ring perimeter + module name label
for i, m in enumerate(MODULES):
px, py = self._pod_position(i)
# Coloured dot
draw.ellipse((px - 5, py - 5, px + 5, py + 5), fill=m.colour + (255,))
# Module name label below dot
draw.text((px - 16, py + 8), m.name, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
# Central clock + hostname
now = datetime.now().strftime("%H:%M:%S")
date = datetime.now().strftime("%a %d %b")
draw.text((CX - 50, CY - 18), now, fill=theme.TEXT_PRIMARY,
font=theme.DEFAULT_FONT)
draw.text((CX - 30, CY + 4), date, fill=theme.TEXT_MUTED,
font=theme.DEFAULT_FONT)
draw.text((CX - 70, CY + 22), self.hostname[:18], fill=theme.TEXT_MUTED,
font=theme.DEFAULT_FONT)
# Transport badge top-right
dot = "" if self.transport in ("OTG", "WiFi") else ""
dot_colour = theme.MATRIX_GREEN if dot == "" else theme.TEXT_MUTED
draw.text((CX + 110, TRANSPORT_BADGE_Y), f"{dot} {self.transport}",
fill=dot_colour, font=theme.DEFAULT_FONT)
# Alerts ribbon — overlay bottom 24px when alert is active
if self._alert_text:
ribbon_colour = theme.SEVERITY.get(self._alert_severity, theme.TEXT_MUTED)
draw.rectangle((0, 480 - ALERT_RIBBON_HEIGHT, 480, 480),
fill=theme.COSMOS_BLACK + (200,))
draw.text((10, 480 - ALERT_RIBBON_HEIGHT + 4),
f"{self._alert_text}"[:50], fill=ribbon_colour,
font=theme.DEFAULT_FONT)
return img

View File

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

View File

@ -2,45 +2,19 @@
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Hardcoded SecuBox palette (RGB tuples for Pillow). Matches Phase 1 round/'s
literal hex values."""
from __future__ import annotations
"""Backward-compat shim — re-exports secubox_common.theme.
# Module colours (from round/index.html literals — see Phase 1 spec)
AUTH = (0xC0, 0x4E, 0x24)
WALL = (0x9A, 0x60, 0x10)
BOOT = (0x80, 0x30, 0x18)
MIND = (0x3D, 0x35, 0xA0)
ROOT = (0x0A, 0x58, 0x40)
MESH = (0x10, 0x4A, 0x88)
Square/ kiosk modules and tests historically import from
secubox_eye_square_kiosk.theme. This shim keeps those imports working
while the canonical palette + DEFAULT_FONT live in secubox_common.theme.
"""
from secubox_common.theme import * # noqa: F401,F403
from secubox_common.theme import ( # noqa: F401
AUTH, WALL, BOOT, MIND, ROOT, MESH,
COSMOS_BLACK, GOLD_HERMETIC, CINNABAR, MATRIX_GREEN,
CYBER_CYAN, VOID_PURPLE, TEXT_PRIMARY, TEXT_MUTED,
SEVERITY, load_default_font,
)
# C3BOX shared tokens
COSMOS_BLACK = (0x08, 0x08, 0x08)
GOLD_HERMETIC = (0xC9, 0xA8, 0x4C)
CINNABAR = (0xE6, 0x39, 0x46)
MATRIX_GREEN = (0x00, 0xFF, 0x41)
CYBER_CYAN = (0x00, 0xD4, 0xFF)
VOID_PURPLE = (0x6E, 0x40, 0xC9)
TEXT_PRIMARY = (0xCC, 0xCC, 0xCC)
TEXT_MUTED = (0x4A, 0x4A, 0x4A)
# Severity dot colours (used by alerts tab)
SEVERITY = {
"info": CYBER_CYAN,
"warn": GOLD_HERMETIC,
"crit": CINNABAR,
}
# Default font for all draw.text() calls in the kiosk. Pillow's
# load_default() on Bookworm is a latin-1 bitmap font that crashes on
# Unicode glyphs (○ ● ▶ ⚠). Loading DejaVuSans explicitly — apt
# dep python3-pil + fonts-dejavu-core (added in the same fix). Falls
# back to load_default() if the TTF isn't present (e.g. unit tests on
# a host without fonts-dejavu-core).
from PIL import ImageFont as _ImageFont # noqa: E402
_DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
try:
DEFAULT_FONT = _ImageFont.truetype(_DEJAVU, 12)
except OSError:
DEFAULT_FONT = _ImageFont.load_default()
# Older callers expected a module-level constant DEFAULT_FONT.
DEFAULT_FONT = load_default_font(12)

View File

@ -0,0 +1,55 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Catches API drift between secubox_common and its consumers."""
import inspect
import sys
from pathlib import Path
# secubox_common at /var/www/common/python/ on the image, dev checkout here.
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from secubox_common import canvas as common_canvas
from secubox_common import modules as common_modules
from secubox_common import theme as common_theme
def test_dashboard_canvas_has_documented_primitives():
expected = {
"paint_background", "paint_rainbow_ring", "paint_concentric_arcs",
"paint_pod_cluster", "paint_central_button", "paint_alert_ribbon",
"layout",
}
actual = {
name for name, member in inspect.getmembers(common_canvas.DashboardCanvas)
if not name.startswith("_") and callable(member)
}
missing = expected - actual
assert not missing, f"DashboardCanvas missing methods: {missing}"
def test_six_canonical_modules():
names = [m.name for m in common_modules.MODULES]
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_module_dataclass_fields():
m = common_modules.MODULES[0]
for field in ("name", "colour", "icon_name", "metric", "extract"):
assert hasattr(m, field)
def test_theme_required_constants():
for c in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
"TEXT_PRIMARY", "TEXT_MUTED",
"AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
assert hasattr(common_theme, c), f"theme missing {c}"
def test_square_dashboard_subclasses_canvas():
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
assert issubclass(SquareDashboard, common_canvas.DashboardCanvas)

View File

@ -0,0 +1,45 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for cursor.draw_cursor — overlay sprite."""
import sys
from pathlib import Path
# secubox_common ships at /var/www/common/python/ on the image; tests
# pick up the dev checkout via this path injection.
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
from PIL import Image
from secubox_eye_square_kiosk.cursor import draw_cursor
def test_cursor_pixels_at_origin():
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, 50, 50)
# At least one non-black pixel near (50, 50).
nonblack = sum(1 for dy in range(0, 16) for dx in range(0, 12)
if img.getpixel((50 + dx, 50 + dy))[:3] != (0, 0, 0))
assert nonblack > 0
def test_cursor_clamped_to_image_bounds():
"""Sprite at (95, 95) on a 100×100 canvas — Pillow clips the partial
polygon; verify at least one pixel in the clipped 5×5 corner changed."""
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, 95, 95)
nonblack = sum(1 for y in range(95, 100) for x in range(95, 100)
if img.getpixel((x, y))[:3] != (0, 0, 0))
assert nonblack > 0, "expected partial sprite to draw at least 1 px"
def test_cursor_negative_coords_dont_crash():
"""Fully off-canvas: early return, canvas untouched."""
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
draw_cursor(img, -10, -10)
# Canvas must be untouched — sample a few pixels to prove it.
assert img.getpixel((0, 0)) == (0, 0, 0, 255)
assert img.getpixel((50, 50)) == (0, 0, 0, 255)

View File

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

View File

@ -1,47 +0,0 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
"""Tests for modules_table.py — the 6-entry RINGS list + extractors."""
from __future__ import annotations
from secubox_eye_square_kiosk.modules_table import MODULES
def test_six_modules_in_hamiltonian_order():
assert [m.name for m in MODULES] == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_ring_radii_descend_in_steps_of_about_13px():
radii = [m.radius for m in MODULES]
assert radii == [214, 201, 188, 175, 162, 149]
for a, b in zip(radii, radii[1:]):
assert a - b == 13, "uniform 13px ring spacing"
def test_extractor_clamps_overshoot_to_one():
auth = MODULES[0]
assert auth.extract({"cpu_percent": 150.0}) == 1.0
assert auth.extract({"cpu_percent": 50.0}) == 0.5
assert auth.extract({"cpu_percent": -10.0}) == 0.0
def test_extractor_missing_metric_returns_zero():
auth = MODULES[0]
assert auth.extract({}) == 0.0
def test_root_temp_extractor_maps_35c_to_zero_and_85c_to_one():
root = next(m for m in MODULES if m.name == "ROOT")
assert root.extract({"cpu_temp": 35.0}) == 0.0
assert root.extract({"cpu_temp": 85.0}) == 1.0
assert abs(root.extract({"cpu_temp": 60.0}) - 0.5) < 0.001
def test_mesh_rssi_extractor_maps_minus90_to_zero_and_minus20_to_one():
mesh = next(m for m in MODULES if m.name == "MESH")
assert mesh.extract({"wifi_rssi": -90}) == 0.0
assert mesh.extract({"wifi_rssi": -20}) == 1.0
def test_each_module_has_distinct_colour():
colours = {m.colour for m in MODULES}
assert len(colours) == 6

View File

@ -0,0 +1,92 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for PointerInput — mouse + touchpad → InputEvent."""
import time
import pytest
from secubox_eye_square_kiosk.pointer_input import PointerInput, InputEvent
@pytest.fixture
def pointer():
p = PointerInput(fb_size=(800, 480))
return p
def _feed_evdev(pointer, events: list[tuple]):
"""Inject (code_name, value) events as if from a single evdev device."""
pointer._inject_for_tests(events)
def test_initial_cursor_at_centre(pointer):
assert pointer.cursor_xy == (400, 240)
def test_relative_motion_updates_cursor(pointer):
_feed_evdev(pointer, [("EV_REL_X", 10), ("EV_REL_Y", -5), ("EV_SYN", 0)])
events = pointer.poll()
assert pointer.cursor_xy == (410, 235)
assert any(e.kind == "motion" for e in events)
def test_relative_motion_clamps_to_fb_bounds(pointer):
_feed_evdev(pointer, [("EV_REL_X", -1000), ("EV_REL_Y", -1000), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (0, 0)
_feed_evdev(pointer, [("EV_REL_X", 9999), ("EV_REL_Y", 9999), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (799, 479)
def test_btn_left_emits_tap_at_cursor(pointer):
_feed_evdev(pointer, [("EV_REL_X", 50), ("EV_REL_Y", 50)])
pointer.poll() # consume motion
_feed_evdev(pointer, [("EV_KEY_BTN_LEFT", 1), ("EV_SYN", 0)])
events = pointer.poll()
taps = [e for e in events if e.kind == "tap"]
assert len(taps) == 1
assert taps[0].x == 450 and taps[0].y == 290
def test_absolute_motion_sets_cursor_directly(pointer):
_feed_evdev(pointer, [("EV_ABS_X", 600), ("EV_ABS_Y", 300), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_xy == (600, 300)
def test_btn_touch_emits_tap(pointer):
_feed_evdev(pointer, [
("EV_ABS_X", 100), ("EV_ABS_Y", 100),
("EV_KEY_BTN_TOUCH", 1), ("EV_SYN", 0),
])
events = pointer.poll()
taps = [e for e in events if e.kind == "tap"]
assert len(taps) == 1
assert taps[0].x == 100 and taps[0].y == 100
def test_cursor_visible_after_motion(pointer):
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_visible is True
def test_cursor_auto_hides_after_timeout(pointer, monkeypatch):
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
pointer.poll()
assert pointer.cursor_visible is True
# Advance the clock by AUTO_HIDE_S + 1.
real_time = time.time
monkeypatch.setattr(time, "time",
lambda: real_time() + PointerInput.AUTO_HIDE_S + 1.0)
assert pointer.cursor_visible is False
def test_oserror_in_poll_does_not_propagate(pointer):
"""Simulated USB unplug (read raises OSError) is swallowed."""
pointer._mark_device_gone_for_tests()
pointer.poll() # should not raise

View File

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

View File

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

View File

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

View File

@ -0,0 +1,11 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Shared Python primitives for SecuBox Eye Remote kiosks.
Both remote-ui/round/ (Pi Zero W) and packages/secubox-eye-square/kiosk/
(Pi 4B/400) import drawing primitives, theme constants, the module table,
and the icon loader from here.
"""
__version__ = "0.1.0"

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Shared pytest fixtures for secubox_common."""
import pytest
from PIL import Image
@pytest.fixture
def blank_round() -> Image.Image:
"""480×480 RGBA black canvas — round form factor."""
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
@pytest.fixture
def blank_square() -> Image.Image:
"""800×480 RGBA black canvas — square form factor."""
return Image.new("RGBA", (800, 480), (0, 0, 0, 255))

View File

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

View File

@ -0,0 +1,75 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for secubox_common.icons — path resolution + LRU cache."""
from pathlib import Path
from PIL import Image
from secubox_common import icons
def test_load_missing_icon_returns_none(monkeypatch, tmp_path):
"""No icon file anywhere → None, no exception."""
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS",
[tmp_path / "does-not-exist"])
icons._cache_clear()
assert icons.load_module_icon("auth", 48) is None
def test_load_existing_icon_returns_pil_image(monkeypatch, tmp_path):
"""Icon found in the search path is returned as a Pillow image."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
fake = Image.new("RGBA", (48, 48), (255, 0, 0, 255))
fake.save(iconsdir / "auth-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
img = icons.load_module_icon("auth", 48)
assert img is not None
assert img.size == (48, 48)
def test_load_caches_by_name_and_size(monkeypatch, tmp_path):
"""Second call with same (name, size) returns the same object."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
Image.new("RGBA", (48, 48), (0, 255, 0, 255)).save(iconsdir / "wall-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
a = icons.load_module_icon("wall", 48)
b = icons.load_module_icon("wall", 48)
assert a is b
def test_search_paths_in_order(monkeypatch, tmp_path):
"""First path with the icon wins, even if later paths also have one."""
first = tmp_path / "first"; first.mkdir()
second = tmp_path / "second"; second.mkdir()
Image.new("RGBA", (48, 48), (255, 0, 0, 255)).save(first / "boot-48.png")
Image.new("RGBA", (48, 48), (0, 0, 255, 255)).save(second / "boot-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [first, second])
icons._cache_clear()
img = icons.load_module_icon("boot", 48)
# Pixel-sample to confirm we got the RED one (first path)
px = img.getpixel((0, 0))
assert px[:3] == (255, 0, 0)
def test_lowercase_name_normalisation(monkeypatch, tmp_path):
"""Caller can pass 'AUTH' or 'auth' — both find auth-48.png."""
iconsdir = tmp_path / "icons"
iconsdir.mkdir()
Image.new("RGBA", (48, 48), (1, 2, 3, 255)).save(iconsdir / "auth-48.png")
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
icons._cache_clear()
assert icons.load_module_icon("AUTH", 48) is not None
assert icons.load_module_icon("auth", 48) is not None

View File

@ -0,0 +1,59 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for secubox_common.modules — canonical Hamiltonian module table."""
from secubox_common import modules, theme
def test_modules_hamiltonian_order():
names = [m.name for m in modules.MODULES]
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
def test_each_module_has_required_fields():
for m in modules.MODULES:
assert m.name
assert isinstance(m.colour, tuple) and len(m.colour) == 3
assert m.icon_name == m.name.lower()
assert m.metric
assert callable(m.extract)
def test_extract_returns_unit_interval_for_typical_values():
sample = {
"cpu_percent": 50,
"mem_percent": 75,
"disk_percent": 30,
"load_avg_1": 2.0,
"cpu_temp": 60,
"wifi_rssi": -60,
}
for m in modules.MODULES:
v = m.extract(sample)
assert 0.0 <= v <= 1.0, f"{m.name} extract returned {v} out of [0,1]"
def test_extract_clamps_high_values():
high = {
"cpu_percent": 999, "mem_percent": 999, "disk_percent": 999,
"load_avg_1": 999, "cpu_temp": 999, "wifi_rssi": 999,
}
for m in modules.MODULES:
assert m.extract(high) == 1.0
def test_extract_clamps_low_values_and_missing():
low = {} # all metrics missing → defaults
for m in modules.MODULES:
v = m.extract(low)
assert 0.0 <= v <= 1.0
def test_modules_use_theme_colours():
expected = {
"AUTH": theme.AUTH, "WALL": theme.WALL, "BOOT": theme.BOOT,
"MIND": theme.MIND, "ROOT": theme.ROOT, "MESH": theme.MESH,
}
for m in modules.MODULES:
assert m.colour == expected[m.name]

View File

@ -0,0 +1,41 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for secubox_common.theme — palette + DEFAULT_FONT loader."""
from PIL import ImageDraw, Image
from secubox_common import theme
def test_module_colors_are_rgb_byte_tuples():
for name in ("AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
c = getattr(theme, name)
assert isinstance(c, tuple) and len(c) == 3
assert all(isinstance(b, int) and 0 <= b <= 255 for b in c)
def test_token_colors_present():
for name in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
"TEXT_PRIMARY", "TEXT_MUTED"):
c = getattr(theme, name)
assert isinstance(c, tuple) and len(c) == 3
def test_severity_table_has_three_keys():
assert set(theme.SEVERITY.keys()) == {"info", "warn", "crit"}
def test_load_default_font_returns_usable_font():
font = theme.load_default_font(12)
# Must be either a TrueType (DejaVu) or the legacy bitmap default.
assert hasattr(font, "getbbox") or hasattr(font, "getmask")
def test_load_default_font_renders_unicode_without_crash():
"""Regression for the latin-1 bitmap default crash from PR #134."""
img = Image.new("RGB", (60, 20), color=(0, 0, 0))
draw = ImageDraw.Draw(img)
draw.text((2, 2), "○ NOMINAL", fill=(0, 255, 0),
font=theme.load_default_font(12))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,43 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
"""Tests for RoundDashboard — Pi Zero W 480×480 layout via secubox_common."""
import sys
from pathlib import Path
_DEV = Path(__file__).resolve().parents[3] / "remote-ui" / "common" / "python"
if str(_DEV) not in sys.path:
sys.path.insert(0, str(_DEV))
# Also add the round/ directory itself so round_dashboard imports work.
_ROUND = Path(__file__).resolve().parents[1]
if str(_ROUND) not in sys.path:
sys.path.insert(0, str(_ROUND))
from round_dashboard import RoundDashboard
from secubox_common.canvas import DashboardCanvas
def test_round_dashboard_subclasses_canvas():
assert issubclass(RoundDashboard, DashboardCanvas)
def test_round_dashboard_size_is_480():
rd = RoundDashboard()
assert rd.SIZE == (480, 480)
def test_round_dashboard_layout_returns_rgba_480x480():
rd = RoundDashboard()
img = rd.layout({})
assert img.mode == "RGBA"
assert img.size == (480, 480)
def test_round_dashboard_layout_paints_rainbow_ring():
rd = RoundDashboard()
img = rd.layout({})
# Rainbow ring is at radius 220-235; sample at radius 227 angle 0.
px = img.getpixel((240 + 227, 240))
assert px[:3] != (0, 0, 0), "rainbow ring not painted at 3 o'clock"

View File

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

View File

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