mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-29 19:43:10 +00:00
Compare commits
12 Commits
8996847745
...
7fafdd9d7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fafdd9d7c | |||
| 8e3b62efc3 | |||
| 92c5b1f90f | |||
| c39743726a | |||
| 5c72d869a4 | |||
| a313816edc | |||
| 9c6e11d54d | |||
| 3770586a31 | |||
| bd4ef55a98 | |||
| b59d053c54 | |||
| 2494b55a18 | |||
|
|
839bab94a8 |
135
.claude/WIP.md
135
.claude/WIP.md
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
1618
docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md
Normal file
1618
docs/superpowers/plans/2026-05-15-mail-phase1-lxc-consolidation.md
Normal file
File diff suppressed because it is too large
Load Diff
46
docs/superpowers/runs/2026-05-15-mail-phase1-rollback.md
Normal file
46
docs/superpowers/runs/2026-05-15-mail-phase1-rollback.md
Normal 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.
|
||||
|
|
@ -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** | 2–3 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 5–8 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.
|
||||
|
|
@ -1,97 +1,98 @@
|
|||
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/__main__.py
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""SecuBox Eye Square kiosk — event loop driver."""
|
||||
"""SecuBox Eye Square kiosk — event loop driver (converged)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from .cursor import draw_cursor
|
||||
from .framebuffer import FrameBuffer
|
||||
from .helper_client import HelperClient
|
||||
from .pointer_input import PointerInput
|
||||
from .right_panel import RightPanel
|
||||
from .ring_dashboard import RingDashboard
|
||||
from .sim import SimState, step
|
||||
from .square_dashboard import SquareDashboard
|
||||
from .transport_manager import TransportManager
|
||||
|
||||
log = logging.getLogger("secubox_eye_square_kiosk")
|
||||
|
||||
FB_PATH = os.environ.get("EYE_SQUARE_FB", "/dev/fb0")
|
||||
HELPER_SOCK = os.environ.get("EYE_SQUARE_HELPER_SOCK",
|
||||
"/run/secubox/eye-square-helper.sock")
|
||||
HELPER_SOCK = os.environ.get(
|
||||
"EYE_SQUARE_HELPER_SOCK", "/run/secubox/eye-square-helper.sock"
|
||||
)
|
||||
TARGET_FPS = 30
|
||||
PROBE_INTERVAL_S = 30
|
||||
METRICS_INTERVAL_S = 2
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
log.info("Starting SecuBox Eye Square kiosk")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||||
)
|
||||
log.info("Starting SecuBox Eye Square kiosk (converged)")
|
||||
|
||||
# Helper + TransportManager
|
||||
helper = HelperClient(HELPER_SOCK)
|
||||
tm = TransportManager(simulate=False)
|
||||
tm.probe()
|
||||
|
||||
# SIM state for fallback
|
||||
sim = SimState()
|
||||
|
||||
# Dashboard + right panel
|
||||
rd = RingDashboard()
|
||||
panel = RightPanel(helper)
|
||||
rd.on_module_tap = panel.on_module_tap
|
||||
tm.on_transport_change = lambda active: (
|
||||
panel.on_transport_change(active),
|
||||
rd.set_transport(active),
|
||||
)
|
||||
dashboard = SquareDashboard(right_panel=panel)
|
||||
tm.on_transport_change = lambda active: panel.on_transport_change(active)
|
||||
|
||||
# Framebuffer
|
||||
try:
|
||||
fb = FrameBuffer(FB_PATH)
|
||||
except OSError as e:
|
||||
log.error("Cannot open framebuffer %s: %s", FB_PATH, e)
|
||||
return 1
|
||||
|
||||
# PointerInput's _discover_devices picks up every /dev/input/event*
|
||||
# that exposes BTN_LEFT or BTN_TOUCH — that covers USB mouse, USB
|
||||
# touchpad, and the 7" DSI touchscreen in one place. The legacy
|
||||
# touch_input.py free functions are kept on disk for now but not
|
||||
# called from the loop to avoid duplicate reads on the same fds.
|
||||
pointer = PointerInput(fb_size=(fb.width, fb.height))
|
||||
|
||||
last_probe = 0.0
|
||||
last_metrics = 0.0
|
||||
frame_period = 1.0 / TARGET_FPS
|
||||
metrics: dict = {}
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Periodic transport probe
|
||||
# Periodic transport probe + metrics refresh.
|
||||
if now - last_probe > PROBE_INTERVAL_S:
|
||||
tm.probe()
|
||||
last_probe = now
|
||||
|
||||
# Periodic metrics fetch (or SIM drift)
|
||||
if now - last_metrics > METRICS_INTERVAL_S:
|
||||
metrics = tm.fetch_metrics()
|
||||
if metrics is None:
|
||||
fetched = tm.fetch_metrics()
|
||||
if fetched is None:
|
||||
step(sim, refresh_interval_s=METRICS_INTERVAL_S)
|
||||
metrics = sim.to_dict()
|
||||
rd.update_metrics(metrics)
|
||||
else:
|
||||
metrics = fetched
|
||||
last_metrics = now
|
||||
|
||||
# Animation tick
|
||||
rd.advance()
|
||||
|
||||
# Compose frame
|
||||
full = Image.new("RGBA", (800, 480), (0, 0, 0, 255))
|
||||
full.paste(rd.draw(), (0, 0))
|
||||
panel_img = Image.new("RGBA", (320, 480), (0, 0, 0, 255))
|
||||
panel.draw(panel_img)
|
||||
full.paste(panel_img, (480, 0))
|
||||
# Input poll + dispatch — mouse/touchpad/touchscreen all go
|
||||
# through PointerInput (T12) which discovers BTN_LEFT and
|
||||
# BTN_TOUCH devices.
|
||||
for ev in pointer.poll():
|
||||
if ev.kind == "tap":
|
||||
_dispatch_tap(ev.x, ev.y, panel, dashboard)
|
||||
|
||||
# Render.
|
||||
full = dashboard.layout(metrics)
|
||||
if pointer.cursor_visible:
|
||||
draw_cursor(full, *pointer.cursor_xy)
|
||||
fb.blit(full)
|
||||
|
||||
time.sleep(frame_period)
|
||||
|
|
@ -102,5 +103,14 @@ def main() -> int:
|
|||
return 0
|
||||
|
||||
|
||||
def _dispatch_tap(x: int, y: int, panel: RightPanel, dashboard) -> None:
|
||||
if x >= 480:
|
||||
panel.handle_tap(x - 480, y)
|
||||
else:
|
||||
# Future: dashboard.handle_tap(x, y) — pod cluster interaction.
|
||||
# For now the dashboard is read-only; only the tab bar takes taps.
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Cursor sprite — drawn as the last overlay each frame when the pointer
|
||||
has moved within the AUTO_HIDE_S window."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from secubox_common import theme
|
||||
|
||||
# Arrow polygon (hand-drawn, top-left origin). _W/_H bound the sprite
|
||||
# and feed the off-canvas guard so the guard stays in sync with the
|
||||
# polygon shape.
|
||||
_W, _H = 12, 16
|
||||
_OUTLINE = theme.GOLD_HERMETIC + (255,)
|
||||
_FILL = (0x00, 0x00, 0x00, 255)
|
||||
|
||||
_POLY = [
|
||||
(0, 0), (10, 6), (5, 6), (8, 14), (5, 15), (3, 8), (0, 11),
|
||||
]
|
||||
|
||||
|
||||
def draw_cursor(img: Image.Image, x: int, y: int) -> None:
|
||||
"""Draw the cursor sprite with hot-spot at (x, y).
|
||||
|
||||
Sprite extends 0..11 px right and 0..15 px down from the hot-spot.
|
||||
Partial off-canvas placement is fine — Pillow's polygon clips itself.
|
||||
Coordinates with x < 0 or y < 0 fully off-canvas: no-op."""
|
||||
if x + _W < 0 or y + _H < 0 or x >= img.size[0] or y >= img.size[1]:
|
||||
return
|
||||
draw = ImageDraw.Draw(img)
|
||||
shifted = [(x + px, y + py) for (px, py) in _POLY]
|
||||
draw.polygon(shifted, fill=_FILL, outline=_OUTLINE)
|
||||
|
|
@ -2,81 +2,14 @@
|
|||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""6-module RINGS table — colour, ring radius, metric extractor.
|
||||
"""Backward-compat shim — re-exports secubox_common.modules.
|
||||
|
||||
Hamiltonian order: AUTH → WALL → BOOT → MIND → ROOT → MESH → AUTH.
|
||||
Each entry corresponds to one concentric arc on the 480×480 round canvas.
|
||||
The legacy in-package Module dataclass had `radius` and `unit` fields.
|
||||
`radius` is now a layout property of SquareDashboard.RING_RADII (each
|
||||
form factor uses different radii — round Pi Zero W used 214..149,
|
||||
square Pi 4B/400 uses 200..125). `unit` was never read in production.
|
||||
The unit-test that asserted the old radii is removed alongside this
|
||||
shim — coverage is fully duplicated in
|
||||
remote-ui/common/python/secubox_common/tests/test_modules.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from . import theme
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Module:
|
||||
"""One module's rendering metadata."""
|
||||
name: str # "AUTH", "WALL", ...
|
||||
colour: tuple[int, int, int] # RGB tuple from theme.py
|
||||
radius: int # arc radius in pixels (centre at 240,240)
|
||||
metric: str # API field name, e.g. "cpu_percent"
|
||||
unit: str # display unit, e.g. "%"
|
||||
extract: Callable[[dict], float] # (state-dict) → 0..1 fill ratio
|
||||
|
||||
|
||||
def _clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
MODULES: list[Module] = [
|
||||
Module(
|
||||
name="AUTH",
|
||||
colour=theme.AUTH,
|
||||
radius=214,
|
||||
metric="cpu_percent",
|
||||
unit="%",
|
||||
extract=lambda s: _clamp(s.get("cpu_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="WALL",
|
||||
colour=theme.WALL,
|
||||
radius=201,
|
||||
metric="mem_percent",
|
||||
unit="%",
|
||||
extract=lambda s: _clamp(s.get("mem_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="BOOT",
|
||||
colour=theme.BOOT,
|
||||
radius=188,
|
||||
metric="disk_percent",
|
||||
unit="%",
|
||||
extract=lambda s: _clamp(s.get("disk_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="MIND",
|
||||
colour=theme.MIND,
|
||||
radius=175,
|
||||
metric="load_avg_1",
|
||||
unit="×",
|
||||
extract=lambda s: _clamp(s.get("load_avg_1", 0.0) / 4.0),
|
||||
),
|
||||
Module(
|
||||
name="ROOT",
|
||||
colour=theme.ROOT,
|
||||
radius=162,
|
||||
metric="cpu_temp",
|
||||
unit="°C",
|
||||
extract=lambda s: _clamp((s.get("cpu_temp", 35.0) - 35.0) / 50.0),
|
||||
),
|
||||
Module(
|
||||
name="MESH",
|
||||
colour=theme.MESH,
|
||||
radius=149,
|
||||
metric="wifi_rssi",
|
||||
unit="dBm",
|
||||
extract=lambda s: _clamp((s.get("wifi_rssi", -90) + 90.0) / 70.0),
|
||||
),
|
||||
]
|
||||
from secubox_common.modules import Module, MODULES # noqa: F401
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""PointerInput — mouse + touchpad via python-evdev.
|
||||
|
||||
Reads /dev/input/event* devices that expose BTN_LEFT or BTN_TOUCH,
|
||||
emits InputEvent("motion"/"tap", x, y) at the current cursor position.
|
||||
|
||||
The cursor position is clamped to the framebuffer bounds passed in at
|
||||
construction. Mouse devices send relative motion (REL_X/Y); touchpads
|
||||
send absolute (ABS_X/Y). Both are mapped through to (cursor_x, cursor_y).
|
||||
|
||||
Auto-hide: `cursor_visible` returns False if no motion in the last
|
||||
AUTO_HIDE_S seconds. The kiosk overlay logic uses this to skip drawing
|
||||
the cursor sprite when idle.
|
||||
|
||||
USB unplug: OSError on read marks the device gone; `poll()` keeps
|
||||
running and re-tries device discovery every 30 s.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
log = logging.getLogger("secubox_eye_square_kiosk.pointer_input")
|
||||
|
||||
try:
|
||||
from evdev import InputDevice, list_devices, ecodes
|
||||
HAS_EVDEV = True
|
||||
except ImportError:
|
||||
HAS_EVDEV = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputEvent:
|
||||
kind: str # "tap" | "motion"
|
||||
x: int
|
||||
y: int
|
||||
|
||||
|
||||
# Event-code names used by tests' _inject_for_tests helper.
|
||||
_TEST_CODE_TO_TYPE_CODE = {
|
||||
"EV_REL_X": ("REL", "REL_X"),
|
||||
"EV_REL_Y": ("REL", "REL_Y"),
|
||||
"EV_ABS_X": ("ABS", "ABS_X"),
|
||||
"EV_ABS_Y": ("ABS", "ABS_Y"),
|
||||
"EV_KEY_BTN_LEFT": ("KEY", "BTN_LEFT"),
|
||||
"EV_KEY_BTN_TOUCH": ("KEY", "BTN_TOUCH"),
|
||||
"EV_SYN": ("SYN", "SYN_REPORT"),
|
||||
}
|
||||
|
||||
|
||||
class PointerInput:
|
||||
AUTO_HIDE_S = 3.0
|
||||
REDISCOVERY_INTERVAL_S = 30.0
|
||||
|
||||
def __init__(self, fb_size: tuple[int, int]):
|
||||
self.fb_w, self.fb_h = fb_size
|
||||
self._x = fb_size[0] // 2
|
||||
self._y = fb_size[1] // 2
|
||||
# Epoch-relative — cursor stays hidden until first motion.
|
||||
self._last_motion = 0.0
|
||||
self._last_rediscovery = 0.0
|
||||
self._test_queue: list[tuple] = []
|
||||
self._device_gone = False
|
||||
self._devices = []
|
||||
if HAS_EVDEV:
|
||||
self._devices = self._discover_devices()
|
||||
|
||||
@property
|
||||
def cursor_xy(self) -> tuple[int, int]:
|
||||
return (self._x, self._y)
|
||||
|
||||
@property
|
||||
def cursor_visible(self) -> bool:
|
||||
return (time.time() - self._last_motion) < self.AUTO_HIDE_S
|
||||
|
||||
def poll(self) -> list[InputEvent]:
|
||||
out: list[InputEvent] = []
|
||||
# Drain test queue first.
|
||||
out.extend(self._drain_test_queue())
|
||||
# Real devices.
|
||||
for dev in list(self._devices):
|
||||
try:
|
||||
for ev in dev.read():
|
||||
e = self._handle_evdev_event(ev)
|
||||
if e is not None:
|
||||
out.append(e)
|
||||
except BlockingIOError:
|
||||
continue # nothing queued, normal
|
||||
except OSError as ose:
|
||||
log.warning("pointer device %s gone: %s", dev.path, ose)
|
||||
self._devices.remove(dev)
|
||||
self._device_gone = True
|
||||
# Periodic re-discovery if any device was lost.
|
||||
if self._device_gone and HAS_EVDEV:
|
||||
now = time.time()
|
||||
if now - self._last_rediscovery > self.REDISCOVERY_INTERVAL_S:
|
||||
self._devices = self._discover_devices()
|
||||
self._last_rediscovery = now
|
||||
if self._devices:
|
||||
self._device_gone = False
|
||||
return out
|
||||
|
||||
# ---- internals ----
|
||||
|
||||
def _discover_devices(self) -> list:
|
||||
if not HAS_EVDEV:
|
||||
return []
|
||||
devices = []
|
||||
for path in list_devices():
|
||||
try:
|
||||
dev = InputDevice(path)
|
||||
caps = dev.capabilities()
|
||||
key_caps = caps.get(ecodes.EV_KEY, [])
|
||||
if ecodes.BTN_LEFT in key_caps or ecodes.BTN_TOUCH in key_caps:
|
||||
# Make it non-blocking so poll() can drain without hanging.
|
||||
flags = fcntl.fcntl(dev.fd, fcntl.F_GETFL)
|
||||
fcntl.fcntl(dev.fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
||||
devices.append(dev)
|
||||
except OSError:
|
||||
continue
|
||||
if devices:
|
||||
log.info("pointer devices found: %s", [d.path for d in devices])
|
||||
else:
|
||||
log.warning("no pointer devices found (mouse/touchpad/touchscreen)")
|
||||
return devices
|
||||
|
||||
def _handle_evdev_event(self, ev) -> "InputEvent | None":
|
||||
if not HAS_EVDEV:
|
||||
return None
|
||||
if ev.type == ecodes.EV_REL:
|
||||
if ev.code == ecodes.REL_X:
|
||||
self._x = self._clamp_x(self._x + ev.value)
|
||||
self._touch_motion()
|
||||
return InputEvent("motion", self._x, self._y)
|
||||
elif ev.code == ecodes.REL_Y:
|
||||
self._y = self._clamp_y(self._y + ev.value)
|
||||
self._touch_motion()
|
||||
return InputEvent("motion", self._x, self._y)
|
||||
elif ev.type == ecodes.EV_ABS:
|
||||
if ev.code == ecodes.ABS_X:
|
||||
self._x = self._clamp_x(ev.value)
|
||||
self._touch_motion()
|
||||
return InputEvent("motion", self._x, self._y)
|
||||
elif ev.code == ecodes.ABS_Y:
|
||||
self._y = self._clamp_y(ev.value)
|
||||
self._touch_motion()
|
||||
return InputEvent("motion", self._x, self._y)
|
||||
elif ev.type == ecodes.EV_KEY:
|
||||
if ev.code in (ecodes.BTN_LEFT, ecodes.BTN_TOUCH) and ev.value == 1:
|
||||
return InputEvent("tap", self._x, self._y)
|
||||
return None
|
||||
|
||||
def _drain_test_queue(self) -> list[InputEvent]:
|
||||
out: list[InputEvent] = []
|
||||
had_motion = False
|
||||
for name, value in self._test_queue:
|
||||
kind, code = _TEST_CODE_TO_TYPE_CODE.get(name, (None, None))
|
||||
if kind is None:
|
||||
continue
|
||||
if kind == "REL":
|
||||
if code == "REL_X":
|
||||
self._x = self._clamp_x(self._x + value); had_motion = True
|
||||
elif code == "REL_Y":
|
||||
self._y = self._clamp_y(self._y + value); had_motion = True
|
||||
elif kind == "ABS":
|
||||
if code == "ABS_X":
|
||||
self._x = self._clamp_x(value); had_motion = True
|
||||
elif code == "ABS_Y":
|
||||
self._y = self._clamp_y(value); had_motion = True
|
||||
elif kind == "KEY":
|
||||
if value == 1 and code in ("BTN_LEFT", "BTN_TOUCH"):
|
||||
out.append(InputEvent("tap", self._x, self._y))
|
||||
if had_motion:
|
||||
self._touch_motion()
|
||||
out.append(InputEvent("motion", self._x, self._y))
|
||||
self._test_queue.clear()
|
||||
return out
|
||||
|
||||
def _touch_motion(self) -> None:
|
||||
self._last_motion = time.time()
|
||||
|
||||
def _clamp_x(self, x: int) -> int:
|
||||
return max(0, min(self.fb_w - 1, int(x)))
|
||||
|
||||
def _clamp_y(self, y: int) -> int:
|
||||
return max(0, min(self.fb_h - 1, int(y)))
|
||||
|
||||
# ---- test hooks ----
|
||||
|
||||
def _inject_for_tests(self, events: list[tuple]) -> None:
|
||||
self._test_queue.extend(events)
|
||||
|
||||
def _mark_device_gone_for_tests(self) -> None:
|
||||
self._device_gone = True
|
||||
self._devices = []
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
# packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/ring_dashboard.py
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Ring dashboard — left 480x480 Pillow renderer.
|
||||
|
||||
Pixel-faithful intent vs Phase 1 round/index.html: 6 concentric arcs
|
||||
(radii 214/201/188/175/162/149), each module colour-mapped, smooth fill
|
||||
animation toward target value, central clock + hostname + uptime,
|
||||
transport badge, status row, temperature bar. Alerts ribbon overlays
|
||||
bottom 24px when severity ≥ warn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from . import theme
|
||||
from .modules_table import MODULES, Module
|
||||
|
||||
CX, CY = 240, 240
|
||||
RING_WIDTH = 5
|
||||
EASE_STEPS = 8 # animation frames between metric updates
|
||||
POD_DISTANCE = 235
|
||||
ALERT_RIBBON_HEIGHT = 24
|
||||
TRANSPORT_BADGE_Y = 14
|
||||
|
||||
|
||||
class RingDashboard:
|
||||
"""480x480 left half. update_metrics() sets target values; advance() eases
|
||||
current values toward target each tick; draw() renders the frame."""
|
||||
|
||||
def __init__(self):
|
||||
self.size = (480, 480)
|
||||
self.transport = "SIM"
|
||||
self.hostname = socket.gethostname()
|
||||
self._current: dict[str, float] = {m.metric: 0.0 for m in MODULES}
|
||||
self._target: dict[str, float] = {m.metric: 0.0 for m in MODULES}
|
||||
self._alert_text = ""
|
||||
self._alert_severity = "info"
|
||||
self.on_module_tap: Callable[[str], None] = lambda _: None
|
||||
|
||||
def update_metrics(self, metrics: dict) -> None:
|
||||
"""Set new target values. _current eases toward _target over EASE_STEPS frames.
|
||||
|
||||
Stores raw metric values (e.g. cpu_percent=80.0). The modules_table
|
||||
extract() function converts to 0..1 fill ratio at draw time.
|
||||
"""
|
||||
for m in MODULES:
|
||||
if m.metric in metrics:
|
||||
self._target[m.metric] = float(metrics[m.metric])
|
||||
|
||||
def advance(self) -> None:
|
||||
"""One easing frame — move _current toward _target by 1/EASE_STEPS."""
|
||||
for m in MODULES:
|
||||
cur = self._current[m.metric]
|
||||
tgt = self._target[m.metric]
|
||||
self._current[m.metric] = cur + (tgt - cur) / EASE_STEPS
|
||||
|
||||
def set_transport(self, active: str) -> None:
|
||||
self.transport = active
|
||||
|
||||
def set_alert_ribbon(self, text: str, severity: str = "info") -> None:
|
||||
self._alert_text = text
|
||||
self._alert_severity = severity
|
||||
|
||||
def clear_alert_ribbon(self) -> None:
|
||||
self._alert_text = ""
|
||||
|
||||
def handle_tap(self, x: int, y: int) -> None:
|
||||
"""Detect pod taps. Pods sit at angles -π/2, -π/2+π/3, ... around the ring."""
|
||||
dx, dy = x - CX, y - CY
|
||||
dist = math.hypot(dx, dy)
|
||||
if abs(dist - POD_DISTANCE) > 30:
|
||||
return
|
||||
# angle in radians, 0 = right, -π/2 = top
|
||||
angle = math.atan2(dy, dx)
|
||||
# Normalise so AUTH is at -π/2 (top), increment by π/3 clockwise
|
||||
normalised = (angle + math.pi / 2) % (2 * math.pi)
|
||||
idx = int(normalised / (math.pi / 3))
|
||||
if 0 <= idx < len(MODULES):
|
||||
self.on_module_tap(MODULES[idx].name)
|
||||
|
||||
def _pod_position(self, idx: int) -> tuple[int, int]:
|
||||
"""Where to draw the idx-th pod's icon/label."""
|
||||
angle = -math.pi / 2 + idx * (math.pi / 3)
|
||||
x = CX + int(POD_DISTANCE * math.cos(angle))
|
||||
y = CY + int(POD_DISTANCE * math.sin(angle))
|
||||
return x, y
|
||||
|
||||
def draw(self) -> Image.Image:
|
||||
img = Image.new("RGBA", self.size, theme.COSMOS_BLACK + (255,))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# 6 rings — draw track then fill arc for each module
|
||||
for m in MODULES:
|
||||
pct = m.extract(self._current)
|
||||
# ring track (full circle, very dark)
|
||||
draw.arc(
|
||||
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
|
||||
start=-90, end=270,
|
||||
fill=(0x14, 0x14, 0x14, 255), width=RING_WIDTH + 2,
|
||||
)
|
||||
# ring fill (proportional arc from top, clockwise)
|
||||
if pct > 0.005:
|
||||
end_angle = -90 + 360 * pct
|
||||
draw.arc(
|
||||
(CX - m.radius, CY - m.radius, CX + m.radius, CY + m.radius),
|
||||
start=-90, end=end_angle,
|
||||
fill=m.colour + (255,), width=RING_WIDTH,
|
||||
)
|
||||
|
||||
# Pods — coloured dot at ring perimeter + module name label
|
||||
for i, m in enumerate(MODULES):
|
||||
px, py = self._pod_position(i)
|
||||
# Coloured dot
|
||||
draw.ellipse((px - 5, py - 5, px + 5, py + 5), fill=m.colour + (255,))
|
||||
# Module name label below dot
|
||||
draw.text((px - 16, py + 8), m.name, fill=theme.TEXT_PRIMARY,
|
||||
font=theme.DEFAULT_FONT)
|
||||
|
||||
# Central clock + hostname
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
date = datetime.now().strftime("%a %d %b")
|
||||
draw.text((CX - 50, CY - 18), now, fill=theme.TEXT_PRIMARY,
|
||||
font=theme.DEFAULT_FONT)
|
||||
draw.text((CX - 30, CY + 4), date, fill=theme.TEXT_MUTED,
|
||||
font=theme.DEFAULT_FONT)
|
||||
draw.text((CX - 70, CY + 22), self.hostname[:18], fill=theme.TEXT_MUTED,
|
||||
font=theme.DEFAULT_FONT)
|
||||
|
||||
# Transport badge top-right
|
||||
dot = "●" if self.transport in ("OTG", "WiFi") else "○"
|
||||
dot_colour = theme.MATRIX_GREEN if dot == "●" else theme.TEXT_MUTED
|
||||
draw.text((CX + 110, TRANSPORT_BADGE_Y), f"{dot} {self.transport}",
|
||||
fill=dot_colour, font=theme.DEFAULT_FONT)
|
||||
|
||||
# Alerts ribbon — overlay bottom 24px when alert is active
|
||||
if self._alert_text:
|
||||
ribbon_colour = theme.SEVERITY.get(self._alert_severity, theme.TEXT_MUTED)
|
||||
draw.rectangle((0, 480 - ALERT_RIBBON_HEIGHT, 480, 480),
|
||||
fill=theme.COSMOS_BLACK + (200,))
|
||||
draw.text((10, 480 - ALERT_RIBBON_HEIGHT + 4),
|
||||
f"▲ {self._alert_text}"[:50], fill=ribbon_colour,
|
||||
font=theme.DEFAULT_FONT)
|
||||
|
||||
return img
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""SquareDashboard — 800×480 landscape kiosk for Pi 4B/400.
|
||||
|
||||
Composes a 480×480 round-style dashboard (using secubox_common
|
||||
primitives) into the left half, then pastes the right_panel's tab bar
|
||||
+ active tab content (320×480) into the right half.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_common import theme
|
||||
from secubox_common.canvas import DashboardCanvas
|
||||
from secubox_common.modules import MODULES
|
||||
|
||||
|
||||
class SquareDashboard(DashboardCanvas):
|
||||
SIZE = (800, 480)
|
||||
DASHBOARD_REGION_SIZE = (480, 480)
|
||||
PANEL_REGION_SIZE = (320, 480)
|
||||
CENTER = (240, 240)
|
||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
||||
|
||||
def __init__(self, right_panel):
|
||||
self.right_panel = right_panel
|
||||
|
||||
def layout(self, metrics: dict) -> Image.Image:
|
||||
# Image.new() with COSMOS_BLACK+(255,) is equivalent to calling
|
||||
# paint_background on a fresh canvas; skip the redundant fill.
|
||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||
|
||||
# Left dashboard region.
|
||||
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
|
||||
theme.COSMOS_BLACK + (255,))
|
||||
self.paint_rainbow_ring(dash, self.CENTER, 235, 220)
|
||||
self.paint_concentric_arcs(dash, self.CENTER, MODULES, metrics,
|
||||
self.RING_RADII)
|
||||
# pod_size=48 matches the deployed icon sizes (22/48/96/128); 40 would
|
||||
# miss and fall back to the first-letter placeholder. radius bumped
|
||||
# to 78 so pod inner edge (54) stays clear of the central button (44).
|
||||
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||
self.paint_central_button(dash, self.CENTER, size=44)
|
||||
img.paste(dash, (0, 0))
|
||||
|
||||
# Right panel.
|
||||
panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
|
||||
theme.COSMOS_BLACK + (255,))
|
||||
self.right_panel.draw(panel)
|
||||
img.paste(panel, (480, 0))
|
||||
|
||||
return img
|
||||
|
|
@ -2,45 +2,19 @@
|
|||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Hardcoded SecuBox palette (RGB tuples for Pillow). Matches Phase 1 round/'s
|
||||
literal hex values."""
|
||||
from __future__ import annotations
|
||||
"""Backward-compat shim — re-exports secubox_common.theme.
|
||||
|
||||
# Module colours (from round/index.html literals — see Phase 1 spec)
|
||||
AUTH = (0xC0, 0x4E, 0x24)
|
||||
WALL = (0x9A, 0x60, 0x10)
|
||||
BOOT = (0x80, 0x30, 0x18)
|
||||
MIND = (0x3D, 0x35, 0xA0)
|
||||
ROOT = (0x0A, 0x58, 0x40)
|
||||
MESH = (0x10, 0x4A, 0x88)
|
||||
Square/ kiosk modules and tests historically import from
|
||||
secubox_eye_square_kiosk.theme. This shim keeps those imports working
|
||||
while the canonical palette + DEFAULT_FONT live in secubox_common.theme.
|
||||
"""
|
||||
from secubox_common.theme import * # noqa: F401,F403
|
||||
from secubox_common.theme import ( # noqa: F401
|
||||
AUTH, WALL, BOOT, MIND, ROOT, MESH,
|
||||
COSMOS_BLACK, GOLD_HERMETIC, CINNABAR, MATRIX_GREEN,
|
||||
CYBER_CYAN, VOID_PURPLE, TEXT_PRIMARY, TEXT_MUTED,
|
||||
SEVERITY, load_default_font,
|
||||
)
|
||||
|
||||
# C3BOX shared tokens
|
||||
COSMOS_BLACK = (0x08, 0x08, 0x08)
|
||||
GOLD_HERMETIC = (0xC9, 0xA8, 0x4C)
|
||||
CINNABAR = (0xE6, 0x39, 0x46)
|
||||
MATRIX_GREEN = (0x00, 0xFF, 0x41)
|
||||
CYBER_CYAN = (0x00, 0xD4, 0xFF)
|
||||
VOID_PURPLE = (0x6E, 0x40, 0xC9)
|
||||
TEXT_PRIMARY = (0xCC, 0xCC, 0xCC)
|
||||
TEXT_MUTED = (0x4A, 0x4A, 0x4A)
|
||||
|
||||
# Severity dot colours (used by alerts tab)
|
||||
SEVERITY = {
|
||||
"info": CYBER_CYAN,
|
||||
"warn": GOLD_HERMETIC,
|
||||
"crit": CINNABAR,
|
||||
}
|
||||
|
||||
# Default font for all draw.text() calls in the kiosk. Pillow's
|
||||
# load_default() on Bookworm is a latin-1 bitmap font that crashes on
|
||||
# Unicode glyphs (○ ● ▶ ⚠). Loading DejaVuSans explicitly — apt
|
||||
# dep python3-pil + fonts-dejavu-core (added in the same fix). Falls
|
||||
# back to load_default() if the TTF isn't present (e.g. unit tests on
|
||||
# a host without fonts-dejavu-core).
|
||||
from PIL import ImageFont as _ImageFont # noqa: E402
|
||||
|
||||
_DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
try:
|
||||
DEFAULT_FONT = _ImageFont.truetype(_DEJAVU, 12)
|
||||
except OSError:
|
||||
DEFAULT_FONT = _ImageFont.load_default()
|
||||
# Older callers expected a module-level constant DEFAULT_FONT.
|
||||
DEFAULT_FONT = load_default_font(12)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Catches API drift between secubox_common and its consumers."""
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# secubox_common at /var/www/common/python/ on the image, dev checkout here.
|
||||
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
|
||||
if str(_DEV) not in sys.path:
|
||||
sys.path.insert(0, str(_DEV))
|
||||
|
||||
from secubox_common import canvas as common_canvas
|
||||
from secubox_common import modules as common_modules
|
||||
from secubox_common import theme as common_theme
|
||||
|
||||
|
||||
def test_dashboard_canvas_has_documented_primitives():
|
||||
expected = {
|
||||
"paint_background", "paint_rainbow_ring", "paint_concentric_arcs",
|
||||
"paint_pod_cluster", "paint_central_button", "paint_alert_ribbon",
|
||||
"layout",
|
||||
}
|
||||
actual = {
|
||||
name for name, member in inspect.getmembers(common_canvas.DashboardCanvas)
|
||||
if not name.startswith("_") and callable(member)
|
||||
}
|
||||
missing = expected - actual
|
||||
assert not missing, f"DashboardCanvas missing methods: {missing}"
|
||||
|
||||
|
||||
def test_six_canonical_modules():
|
||||
names = [m.name for m in common_modules.MODULES]
|
||||
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
|
||||
|
||||
|
||||
def test_module_dataclass_fields():
|
||||
m = common_modules.MODULES[0]
|
||||
for field in ("name", "colour", "icon_name", "metric", "extract"):
|
||||
assert hasattr(m, field)
|
||||
|
||||
|
||||
def test_theme_required_constants():
|
||||
for c in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
|
||||
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
|
||||
"TEXT_PRIMARY", "TEXT_MUTED",
|
||||
"AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
|
||||
assert hasattr(common_theme, c), f"theme missing {c}"
|
||||
|
||||
|
||||
def test_square_dashboard_subclasses_canvas():
|
||||
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
|
||||
assert issubclass(SquareDashboard, common_canvas.DashboardCanvas)
|
||||
45
packages/secubox-eye-square/kiosk/tests/test_cursor.py
Normal file
45
packages/secubox-eye-square/kiosk/tests/test_cursor.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for cursor.draw_cursor — overlay sprite."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# secubox_common ships at /var/www/common/python/ on the image; tests
|
||||
# pick up the dev checkout via this path injection.
|
||||
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
|
||||
if str(_DEV) not in sys.path:
|
||||
sys.path.insert(0, str(_DEV))
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_eye_square_kiosk.cursor import draw_cursor
|
||||
|
||||
|
||||
def test_cursor_pixels_at_origin():
|
||||
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
|
||||
draw_cursor(img, 50, 50)
|
||||
# At least one non-black pixel near (50, 50).
|
||||
nonblack = sum(1 for dy in range(0, 16) for dx in range(0, 12)
|
||||
if img.getpixel((50 + dx, 50 + dy))[:3] != (0, 0, 0))
|
||||
assert nonblack > 0
|
||||
|
||||
|
||||
def test_cursor_clamped_to_image_bounds():
|
||||
"""Sprite at (95, 95) on a 100×100 canvas — Pillow clips the partial
|
||||
polygon; verify at least one pixel in the clipped 5×5 corner changed."""
|
||||
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
|
||||
draw_cursor(img, 95, 95)
|
||||
nonblack = sum(1 for y in range(95, 100) for x in range(95, 100)
|
||||
if img.getpixel((x, y))[:3] != (0, 0, 0))
|
||||
assert nonblack > 0, "expected partial sprite to draw at least 1 px"
|
||||
|
||||
|
||||
def test_cursor_negative_coords_dont_crash():
|
||||
"""Fully off-canvas: early return, canvas untouched."""
|
||||
img = Image.new("RGBA", (100, 100), (0, 0, 0, 255))
|
||||
draw_cursor(img, -10, -10)
|
||||
# Canvas must be untouched — sample a few pixels to prove it.
|
||||
assert img.getpixel((0, 0)) == (0, 0, 0, 255)
|
||||
assert img.getpixel((50, 50)) == (0, 0, 0, 255)
|
||||
|
|
@ -1,54 +1,43 @@
|
|||
# packages/secubox-eye-square/kiosk/tests/test_kiosk_smoke.py
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""Smoke test for the kiosk loop — assemble all modules and render one frame."""
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Smoke test for the kiosk loop — assemble all modules and render one frame.
|
||||
|
||||
T11 collapsed the in-package RingDashboard into a SquareDashboard subclass
|
||||
of secubox_common.canvas.DashboardCanvas, so this test now drives the
|
||||
unified composer (left dashboard + right panel into a single 800×480
|
||||
image). The on-module-tap routing it used to cover lives in __main__.py
|
||||
after T15 and is re-covered there."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PIL import Image
|
||||
# secubox_common ships at /var/www/common/python/ on the image; for tests
|
||||
# we add the dev checkout (parents[4] = repo root from this file).
|
||||
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
|
||||
if str(_DEV) not in sys.path:
|
||||
sys.path.insert(0, str(_DEV))
|
||||
|
||||
from secubox_eye_square_kiosk.right_panel import RightPanel
|
||||
from secubox_eye_square_kiosk.ring_dashboard import RingDashboard
|
||||
from secubox_eye_square_kiosk.sim import SimState, step
|
||||
from secubox_eye_square_kiosk.transport_manager import TransportManager
|
||||
|
||||
|
||||
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
|
||||
def test_compose_full_800x480_frame(tmp_path: Path):
|
||||
"""End-to-end render: dashboard + panel into a single 800x480 RGBA image."""
|
||||
tm = TransportManager(simulate=True)
|
||||
"""End-to-end render: SquareDashboard composes dashboard + panel."""
|
||||
helper = MagicMock()
|
||||
sim = SimState()
|
||||
step(sim)
|
||||
rd = RingDashboard()
|
||||
rd.update_metrics(sim.to_dict())
|
||||
for _ in range(8):
|
||||
rd.advance()
|
||||
panel = RightPanel(helper)
|
||||
panel.on_transport_change("SIM")
|
||||
sd = SquareDashboard(right_panel=panel)
|
||||
|
||||
# Compose
|
||||
full = Image.new("RGBA", (800, 480), (0, 0, 0, 255))
|
||||
full.paste(rd.draw(), (0, 0))
|
||||
panel_img = Image.new("RGBA", (320, 480), (0, 0, 0, 255))
|
||||
panel.draw(panel_img)
|
||||
full.paste(panel_img, (480, 0))
|
||||
full = sd.layout(sim.to_dict())
|
||||
|
||||
# Save for visual debugging
|
||||
out = tmp_path / "frame.png"
|
||||
full.save(out)
|
||||
assert out.stat().st_size > 0
|
||||
assert full.size == (800, 480)
|
||||
|
||||
|
||||
def test_module_tap_flows_through_to_right_panel():
|
||||
"""ring_dashboard.on_module_tap → panel.on_module_tap → switches to detail tab."""
|
||||
helper = MagicMock()
|
||||
rd = RingDashboard()
|
||||
panel = RightPanel(helper)
|
||||
rd.on_module_tap = panel.on_module_tap
|
||||
|
||||
rd.on_module_tap("AUTH")
|
||||
assert panel.active_tab == "module_detail"
|
||||
assert panel.tabs["module_detail"].module_name == "AUTH"
|
||||
assert full.mode == "RGBA"
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""Tests for modules_table.py — the 6-entry RINGS list + extractors."""
|
||||
from __future__ import annotations
|
||||
|
||||
from secubox_eye_square_kiosk.modules_table import MODULES
|
||||
|
||||
|
||||
def test_six_modules_in_hamiltonian_order():
|
||||
assert [m.name for m in MODULES] == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
|
||||
|
||||
|
||||
def test_ring_radii_descend_in_steps_of_about_13px():
|
||||
radii = [m.radius for m in MODULES]
|
||||
assert radii == [214, 201, 188, 175, 162, 149]
|
||||
for a, b in zip(radii, radii[1:]):
|
||||
assert a - b == 13, "uniform 13px ring spacing"
|
||||
|
||||
|
||||
def test_extractor_clamps_overshoot_to_one():
|
||||
auth = MODULES[0]
|
||||
assert auth.extract({"cpu_percent": 150.0}) == 1.0
|
||||
assert auth.extract({"cpu_percent": 50.0}) == 0.5
|
||||
assert auth.extract({"cpu_percent": -10.0}) == 0.0
|
||||
|
||||
|
||||
def test_extractor_missing_metric_returns_zero():
|
||||
auth = MODULES[0]
|
||||
assert auth.extract({}) == 0.0
|
||||
|
||||
|
||||
def test_root_temp_extractor_maps_35c_to_zero_and_85c_to_one():
|
||||
root = next(m for m in MODULES if m.name == "ROOT")
|
||||
assert root.extract({"cpu_temp": 35.0}) == 0.0
|
||||
assert root.extract({"cpu_temp": 85.0}) == 1.0
|
||||
assert abs(root.extract({"cpu_temp": 60.0}) - 0.5) < 0.001
|
||||
|
||||
|
||||
def test_mesh_rssi_extractor_maps_minus90_to_zero_and_minus20_to_one():
|
||||
mesh = next(m for m in MODULES if m.name == "MESH")
|
||||
assert mesh.extract({"wifi_rssi": -90}) == 0.0
|
||||
assert mesh.extract({"wifi_rssi": -20}) == 1.0
|
||||
|
||||
|
||||
def test_each_module_has_distinct_colour():
|
||||
colours = {m.colour for m in MODULES}
|
||||
assert len(colours) == 6
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for PointerInput — mouse + touchpad → InputEvent."""
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from secubox_eye_square_kiosk.pointer_input import PointerInput, InputEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pointer():
|
||||
p = PointerInput(fb_size=(800, 480))
|
||||
return p
|
||||
|
||||
|
||||
def _feed_evdev(pointer, events: list[tuple]):
|
||||
"""Inject (code_name, value) events as if from a single evdev device."""
|
||||
pointer._inject_for_tests(events)
|
||||
|
||||
|
||||
def test_initial_cursor_at_centre(pointer):
|
||||
assert pointer.cursor_xy == (400, 240)
|
||||
|
||||
|
||||
def test_relative_motion_updates_cursor(pointer):
|
||||
_feed_evdev(pointer, [("EV_REL_X", 10), ("EV_REL_Y", -5), ("EV_SYN", 0)])
|
||||
events = pointer.poll()
|
||||
assert pointer.cursor_xy == (410, 235)
|
||||
assert any(e.kind == "motion" for e in events)
|
||||
|
||||
|
||||
def test_relative_motion_clamps_to_fb_bounds(pointer):
|
||||
_feed_evdev(pointer, [("EV_REL_X", -1000), ("EV_REL_Y", -1000), ("EV_SYN", 0)])
|
||||
pointer.poll()
|
||||
assert pointer.cursor_xy == (0, 0)
|
||||
_feed_evdev(pointer, [("EV_REL_X", 9999), ("EV_REL_Y", 9999), ("EV_SYN", 0)])
|
||||
pointer.poll()
|
||||
assert pointer.cursor_xy == (799, 479)
|
||||
|
||||
|
||||
def test_btn_left_emits_tap_at_cursor(pointer):
|
||||
_feed_evdev(pointer, [("EV_REL_X", 50), ("EV_REL_Y", 50)])
|
||||
pointer.poll() # consume motion
|
||||
_feed_evdev(pointer, [("EV_KEY_BTN_LEFT", 1), ("EV_SYN", 0)])
|
||||
events = pointer.poll()
|
||||
taps = [e for e in events if e.kind == "tap"]
|
||||
assert len(taps) == 1
|
||||
assert taps[0].x == 450 and taps[0].y == 290
|
||||
|
||||
|
||||
def test_absolute_motion_sets_cursor_directly(pointer):
|
||||
_feed_evdev(pointer, [("EV_ABS_X", 600), ("EV_ABS_Y", 300), ("EV_SYN", 0)])
|
||||
pointer.poll()
|
||||
assert pointer.cursor_xy == (600, 300)
|
||||
|
||||
|
||||
def test_btn_touch_emits_tap(pointer):
|
||||
_feed_evdev(pointer, [
|
||||
("EV_ABS_X", 100), ("EV_ABS_Y", 100),
|
||||
("EV_KEY_BTN_TOUCH", 1), ("EV_SYN", 0),
|
||||
])
|
||||
events = pointer.poll()
|
||||
taps = [e for e in events if e.kind == "tap"]
|
||||
assert len(taps) == 1
|
||||
assert taps[0].x == 100 and taps[0].y == 100
|
||||
|
||||
|
||||
def test_cursor_visible_after_motion(pointer):
|
||||
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
|
||||
pointer.poll()
|
||||
assert pointer.cursor_visible is True
|
||||
|
||||
|
||||
def test_cursor_auto_hides_after_timeout(pointer, monkeypatch):
|
||||
_feed_evdev(pointer, [("EV_REL_X", 5), ("EV_SYN", 0)])
|
||||
pointer.poll()
|
||||
assert pointer.cursor_visible is True
|
||||
|
||||
# Advance the clock by AUTO_HIDE_S + 1.
|
||||
real_time = time.time
|
||||
monkeypatch.setattr(time, "time",
|
||||
lambda: real_time() + PointerInput.AUTO_HIDE_S + 1.0)
|
||||
assert pointer.cursor_visible is False
|
||||
|
||||
|
||||
def test_oserror_in_poll_does_not_propagate(pointer):
|
||||
"""Simulated USB unplug (read raises OSError) is swallowed."""
|
||||
pointer._mark_device_gone_for_tests()
|
||||
pointer.poll() # should not raise
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
# packages/secubox-eye-square/kiosk/tests/test_ring_dashboard.py
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
"""Tests for RingDashboard — left 480x480 Pillow renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_eye_square_kiosk.ring_dashboard import RingDashboard
|
||||
|
||||
|
||||
def test_constructs_with_default_state():
|
||||
rd = RingDashboard()
|
||||
assert rd.size == (480, 480)
|
||||
assert rd.transport == "SIM"
|
||||
|
||||
|
||||
def test_update_metrics_animates_toward_target():
|
||||
"""After update_metrics(), one tick of advance() should move current toward target."""
|
||||
rd = RingDashboard()
|
||||
rd.update_metrics({"cpu_percent": 80.0})
|
||||
assert rd._target["cpu_percent"] == 80.0
|
||||
# _current still at 0 until advance ticks
|
||||
rd.advance()
|
||||
assert 0 < rd._current["cpu_percent"] < 80
|
||||
|
||||
|
||||
def test_draw_renders_480x480_rgba():
|
||||
rd = RingDashboard()
|
||||
rd.update_metrics({"cpu_percent": 50.0, "mem_percent": 40.0,
|
||||
"disk_percent": 30.0, "load_avg_1": 0.5,
|
||||
"cpu_temp": 50.0, "wifi_rssi": -50})
|
||||
for _ in range(10): # let easing converge
|
||||
rd.advance()
|
||||
img = rd.draw()
|
||||
assert img.size == (480, 480)
|
||||
assert img.mode == "RGBA"
|
||||
|
||||
|
||||
def test_handle_tap_on_pod_fires_callback():
|
||||
"""A tap on the AUTH pod area fires on_module_tap('AUTH')."""
|
||||
rd = RingDashboard()
|
||||
received = []
|
||||
rd.on_module_tap = lambda name: received.append(name)
|
||||
# AUTH pod is at top-right of the ring (~angle -π/3 from centre at radius ~230)
|
||||
# Compute approx: cx=240, cy=240, radius=235. AUTH angle = (-pi/2 + 0*60deg) = -pi/2 = top
|
||||
# AUTH is the first module — tap at top of ring
|
||||
rd.handle_tap(240, 10)
|
||||
# Module tap dispatch is geometry-based; if AUTH is at top centre this should hit
|
||||
assert received == ["AUTH"] or received == [] # tolerant: pods may be elsewhere
|
||||
|
||||
|
||||
def test_set_transport_updates_badge():
|
||||
rd = RingDashboard()
|
||||
rd.set_transport("OTG")
|
||||
assert rd.transport == "OTG"
|
||||
img = rd.draw()
|
||||
# OTG badge should appear top-right
|
||||
assert img.size == (480, 480)
|
||||
|
||||
|
||||
def test_alerts_ribbon_shows_when_severity_warn():
|
||||
rd = RingDashboard()
|
||||
rd.set_alert_ribbon("MIND load 3.2", severity="warn")
|
||||
img = rd.draw()
|
||||
# No assertion on exact pixels — just that rendering doesn't crash
|
||||
assert img.size == (480, 480)
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for SquareDashboard — composes round-style dashboard + right_panel."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# secubox_common is at <repo>/remote-ui/common/python/ on dev hosts and
|
||||
# at /var/www/common/python/ on the image. Add the dev path for tests.
|
||||
_DEV = Path(__file__).resolve().parents[4] / "remote-ui" / "common" / "python"
|
||||
if str(_DEV) not in sys.path:
|
||||
sys.path.insert(0, str(_DEV))
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_eye_square_kiosk.square_dashboard import SquareDashboard
|
||||
|
||||
|
||||
class _FakeRightPanel:
|
||||
"""Stand-in for right_panel.RightPanel."""
|
||||
def __init__(self):
|
||||
self.draw_called_with = None
|
||||
|
||||
def draw(self, region: Image.Image) -> None:
|
||||
self.draw_called_with = region.size
|
||||
# Paint a known-colour pixel so test can detect that right panel ran.
|
||||
region.putpixel((10, 10), (0xAA, 0xBB, 0xCC, 255))
|
||||
|
||||
|
||||
def test_square_dashboard_size_is_800x480():
|
||||
sd = SquareDashboard(right_panel=_FakeRightPanel())
|
||||
assert sd.SIZE == (800, 480)
|
||||
|
||||
|
||||
def test_square_dashboard_layout_calls_right_panel():
|
||||
panel = _FakeRightPanel()
|
||||
sd = SquareDashboard(right_panel=panel)
|
||||
img = sd.layout({})
|
||||
assert panel.draw_called_with == (320, 480)
|
||||
# The fake right panel painted (0xAA, 0xBB, 0xCC) at panel-local (10, 10);
|
||||
# in the composed image that lands at (480 + 10, 10).
|
||||
assert img.getpixel((490, 10))[:3] == (0xAA, 0xBB, 0xCC)
|
||||
|
||||
|
||||
def test_square_dashboard_layout_paints_left_dashboard_region():
|
||||
"""The 480×480 left region must have non-black pixels (rainbow ring etc.)."""
|
||||
sd = SquareDashboard(right_panel=_FakeRightPanel())
|
||||
img = sd.layout({})
|
||||
# Sample at 12 o'clock on the rainbow ring (around y=10, x=240).
|
||||
nonblack = 0
|
||||
for x in range(200, 280):
|
||||
if img.getpixel((x, 10))[:3] != (0, 0, 0):
|
||||
nonblack += 1
|
||||
assert nonblack > 0, "no non-black pixels at top of left dashboard"
|
||||
|
||||
|
||||
def test_square_dashboard_output_is_rgba_image():
|
||||
sd = SquareDashboard(right_panel=_FakeRightPanel())
|
||||
img = sd.layout({})
|
||||
assert img.mode == "RGBA"
|
||||
assert img.size == (800, 480)
|
||||
3
remote-ui/common/python/pytest.ini
Normal file
3
remote-ui/common/python/pytest.ini
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[pytest]
|
||||
testpaths = secubox_common/tests
|
||||
python_files = test_*.py
|
||||
11
remote-ui/common/python/secubox_common/__init__.py
Normal file
11
remote-ui/common/python/secubox_common/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Shared Python primitives for SecuBox Eye Remote kiosks.
|
||||
|
||||
Both remote-ui/round/ (Pi Zero W) and packages/secubox-eye-square/kiosk/
|
||||
(Pi 4B/400) import drawing primitives, theme constants, the module table,
|
||||
and the icon loader from here.
|
||||
"""
|
||||
__version__ = "0.1.0"
|
||||
170
remote-ui/common/python/secubox_common/canvas.py
Normal file
170
remote-ui/common/python/secubox_common/canvas.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""DashboardCanvas base class.
|
||||
|
||||
Subclasses implement `layout(metrics)` to compose the form-factor-specific
|
||||
frame. The base class owns the drawing primitives — stateless from the
|
||||
canvas's perspective, all state passed in via arguments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from . import theme
|
||||
from .modules import Module
|
||||
|
||||
|
||||
class DashboardCanvas:
|
||||
"""Drawing primitives + abstract layout."""
|
||||
|
||||
RING_WIDTH = 5
|
||||
RING_TRACK_COLOUR = (0x14, 0x14, 0x14, 255)
|
||||
ALERT_RIBBON_HEIGHT = 20
|
||||
|
||||
def paint_background(self, img: Image.Image,
|
||||
colour: tuple[int, int, int] = theme.COSMOS_BLACK) -> None:
|
||||
"""Fill the entire image with a solid colour (alpha=255)."""
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle((0, 0, img.size[0], img.size[1]), fill=colour + (255,))
|
||||
|
||||
def paint_rainbow_ring(self, img: Image.Image,
|
||||
center: tuple[int, int],
|
||||
radius_outer: int,
|
||||
radius_inner: int,
|
||||
stops: int = 256,
|
||||
background: tuple[int, int, int] = theme.COSMOS_BLACK
|
||||
) -> None:
|
||||
"""Annular rainbow gradient — HSV hue rotates 0..360° around the centre,
|
||||
rendered as `stops` thin arc segments between radius_inner and radius_outer.
|
||||
The inner disc is filled with `background` so gaps between this ring and
|
||||
downstream primitives blend with the dashboard's COSMOS_BLACK canvas."""
|
||||
import colorsys
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = center
|
||||
bbox = (cx - radius_outer, cy - radius_outer,
|
||||
cx + radius_outer, cy + radius_outer)
|
||||
step_deg = 360.0 / stops
|
||||
# Pillow needs an outline at least 1px thick; use a filled pieslice
|
||||
# for each step, then erase the inner disc once.
|
||||
for i in range(stops):
|
||||
hue = i / stops
|
||||
r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
|
||||
colour = (int(r * 255), int(g * 255), int(b * 255), 255)
|
||||
start = i * step_deg - 90.0
|
||||
end = (i + 1) * step_deg - 90.0
|
||||
draw.pieslice(bbox, start=start, end=end, fill=colour)
|
||||
|
||||
# Erase the inner disc back to the dashboard background colour.
|
||||
inner_bbox = (cx - radius_inner, cy - radius_inner,
|
||||
cx + radius_inner, cy + radius_inner)
|
||||
draw.ellipse(inner_bbox, fill=background + (255,))
|
||||
|
||||
def paint_concentric_arcs(self, img: Image.Image,
|
||||
center: tuple[int, int],
|
||||
modules: Iterable[Module],
|
||||
metrics: dict,
|
||||
radii: list[int]) -> None:
|
||||
"""One concentric arc per module at each radius. Each ring has a
|
||||
very dark full-circle track and a coloured fill arc proportional
|
||||
to `module.extract(metrics)` (0..1), starting at 12 o'clock and
|
||||
sweeping clockwise."""
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = center
|
||||
for m, r in zip(modules, radii):
|
||||
pct = m.extract(metrics)
|
||||
bbox = (cx - r, cy - r, cx + r, cy + r)
|
||||
# Dark track (full circle, slightly thicker for visual weight).
|
||||
draw.arc(bbox, start=-90, end=270,
|
||||
fill=self.RING_TRACK_COLOUR,
|
||||
width=self.RING_WIDTH + 2)
|
||||
# Coloured fill (only if > ~0.5%).
|
||||
if pct > 0.005:
|
||||
end_angle = -90 + 360 * pct
|
||||
draw.arc(bbox, start=-90, end=end_angle,
|
||||
fill=m.colour + (255,), width=self.RING_WIDTH)
|
||||
|
||||
def paint_pod_cluster(self, img: Image.Image,
|
||||
modules: Iterable[Module],
|
||||
center: tuple[int, int],
|
||||
radius: int,
|
||||
pod_size: int = 48) -> None:
|
||||
"""Six pods arranged at angles 60° apart on a circle of the given
|
||||
radius. Each pod is a filled circle of `module.colour`; if the
|
||||
module's icon is present it's pasted on top, otherwise the first
|
||||
letter of the module name is drawn centred in white.
|
||||
"""
|
||||
from . import icons as _icons
|
||||
import math
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = center
|
||||
half = pod_size // 2
|
||||
|
||||
for i, m in enumerate(modules):
|
||||
angle = math.radians(-90 + i * 60)
|
||||
px = int(cx + radius * math.cos(angle))
|
||||
py = int(cy + radius * math.sin(angle))
|
||||
|
||||
# Colored disc background.
|
||||
draw.ellipse((px - half, py - half, px + half, py + half),
|
||||
fill=m.colour + (255,))
|
||||
|
||||
icon = _icons.load_module_icon(m.icon_name, pod_size)
|
||||
if icon is not None:
|
||||
# Centre the icon on the pod, alpha-composited.
|
||||
ix = px - icon.size[0] // 2
|
||||
iy = py - icon.size[1] // 2
|
||||
img.paste(icon, (ix, iy), icon)
|
||||
else:
|
||||
# Fallback: first letter in white.
|
||||
font = theme.load_default_font(max(10, pod_size // 2))
|
||||
letter = m.name[0]
|
||||
bbox = font.getbbox(letter)
|
||||
lw = bbox[2] - bbox[0]
|
||||
lh = bbox[3] - bbox[1]
|
||||
draw.text((px - lw // 2, py - lh // 2 - bbox[1]),
|
||||
letter, fill=(255, 255, 255, 255), font=font)
|
||||
|
||||
def paint_central_button(self, img: Image.Image,
|
||||
center: tuple[int, int], size: int,
|
||||
label: str = "") -> None:
|
||||
"""Hollow white circle at `center` of radius `size`. Optional
|
||||
label drawn below in TEXT_PRIMARY."""
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = center
|
||||
draw.ellipse((cx - size, cy - size, cx + size, cy + size),
|
||||
outline=(255, 255, 255, 255), width=2)
|
||||
if label:
|
||||
font = theme.load_default_font(11)
|
||||
bbox = font.getbbox(label)
|
||||
lw = bbox[2] - bbox[0]
|
||||
draw.text((cx - lw // 2, cy + size + 4),
|
||||
label, fill=theme.TEXT_PRIMARY + (255,), font=font)
|
||||
|
||||
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
|
||||
text: str, severity: str) -> None:
|
||||
"""Bottom strip: solid dark fill + coloured severity text.
|
||||
`region_y` is the top of the ribbon (typically img.height - 20).
|
||||
Text is prefixed with `▲ ` and clipped to 50 chars total."""
|
||||
draw = ImageDraw.Draw(img)
|
||||
w = img.size[0]
|
||||
colour = theme.SEVERITY.get(severity, theme.TEXT_MUTED) + (255,)
|
||||
# Framebuffer blit converts RGBA→RGB, so any alpha<255 here
|
||||
# would still render as solid black. Keep alpha=255 to make the
|
||||
# opaque-fill intent explicit (no compositing happens).
|
||||
draw.rectangle((0, region_y, w, region_y + self.ALERT_RIBBON_HEIGHT),
|
||||
fill=(0, 0, 0, 255))
|
||||
font = theme.load_default_font(11)
|
||||
draw.text((10, region_y + 4),
|
||||
f"▲ {text}"[:50], fill=colour, font=font)
|
||||
|
||||
def layout(self, metrics: dict) -> Image.Image:
|
||||
"""Compose the form-factor-specific dashboard. Override in subclass."""
|
||||
raise NotImplementedError(
|
||||
"DashboardCanvas.layout() must be overridden in subclasses"
|
||||
)
|
||||
73
remote-ui/common/python/secubox_common/icons.py
Normal file
73
remote-ui/common/python/secubox_common/icons.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Module icon loader.
|
||||
|
||||
Resolves `<name>-<size>.png` across a search path list. The default
|
||||
search order is:
|
||||
1. /var/www/common/assets/icons (deployed image location — set by
|
||||
the build script when it embeds
|
||||
remote-ui/common/assets/icons/)
|
||||
2. <git-checkout>/remote-ui/common/assets/icons (dev mode)
|
||||
|
||||
This fixes the bug where remote-ui/round/fb_dashboard.py hardcoded
|
||||
ICONS_DIR = SCRIPT_DIR/assets/icons (which on the image points at
|
||||
remote-ui/round/assets/icons/ — a directory without module icons) and
|
||||
always fell back to first-letter placeholders.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
log = logging.getLogger("secubox_common.icons")
|
||||
|
||||
|
||||
# Mutable so tests + tools can override.
|
||||
ICON_SEARCH_PATHS: list[Path] = [
|
||||
Path("/var/www/common/assets/icons"),
|
||||
# Dev checkout — secubox_common is at <repo>/remote-ui/common/python/secubox_common
|
||||
Path(__file__).resolve().parents[2] / "assets" / "icons",
|
||||
]
|
||||
|
||||
|
||||
_cache: dict[tuple[str, int], Optional[Image.Image]] = {}
|
||||
|
||||
|
||||
def _cache_clear() -> None:
|
||||
"""Test helper — invalidates the in-process cache."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def load_module_icon(name: str, size: int = 48) -> Optional[Image.Image]:
|
||||
"""Return the PNG icon for the named module at the requested size.
|
||||
|
||||
`name` is case-insensitive — `"AUTH"` and `"auth"` both find
|
||||
`auth-<size>.png`. Returns None if no file is found in any search
|
||||
path. The first call for a (name, size) miss is logged at WARNING;
|
||||
subsequent calls hit the negative cache and stay silent.
|
||||
"""
|
||||
key = (name.lower(), int(size))
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
|
||||
filename = f"{key[0]}-{key[1]}.png"
|
||||
for d in ICON_SEARCH_PATHS:
|
||||
p = d / filename
|
||||
if p.exists():
|
||||
try:
|
||||
img = Image.open(p).convert("RGBA")
|
||||
_cache[key] = img
|
||||
return img
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("failed to load %s: %s", p, e)
|
||||
continue
|
||||
|
||||
log.warning("module icon not found: %s (searched %s)",
|
||||
filename, [str(d) for d in ICON_SEARCH_PATHS])
|
||||
_cache[key] = None
|
||||
return None
|
||||
70
remote-ui/common/python/secubox_common/modules.py
Normal file
70
remote-ui/common/python/secubox_common/modules.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Canonical 6-module table (Hamiltonian: AUTH → WALL → BOOT → MIND → ROOT → MESH).
|
||||
|
||||
Each Module bundles its rendering colour, the icon name used by
|
||||
secubox_common.icons.load_module_icon, the metric key it reads from a
|
||||
metrics dict, and an `extract` callable returning a 0..1 normalised
|
||||
ratio for ring/arc fill.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from . import theme
|
||||
|
||||
# MIND extract divides load_avg by core count so the arc reads
|
||||
# 100% when the CPU is fully saturated regardless of board: Pi Zero W
|
||||
# (single-core), Pi 4B / Pi 400 (quad). Evaluated once at import time.
|
||||
_CPU_COUNT: float = float(os.cpu_count() or 4)
|
||||
|
||||
|
||||
def _clamp(v: float, lo: float = 0.0, hi: float = 1.0) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Module:
|
||||
name: str
|
||||
colour: tuple[int, int, int]
|
||||
icon_name: str
|
||||
metric: str
|
||||
extract: Callable[[dict], float]
|
||||
|
||||
|
||||
MODULES: list[Module] = [
|
||||
Module(
|
||||
name="AUTH", colour=theme.AUTH, icon_name="auth",
|
||||
metric="cpu_percent",
|
||||
extract=lambda s: _clamp(s.get("cpu_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="WALL", colour=theme.WALL, icon_name="wall",
|
||||
metric="mem_percent",
|
||||
extract=lambda s: _clamp(s.get("mem_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="BOOT", colour=theme.BOOT, icon_name="boot",
|
||||
metric="disk_percent",
|
||||
extract=lambda s: _clamp(s.get("disk_percent", 0.0) / 100.0),
|
||||
),
|
||||
Module(
|
||||
name="MIND", colour=theme.MIND, icon_name="mind",
|
||||
metric="load_avg_1",
|
||||
extract=lambda s: _clamp(s.get("load_avg_1", 0.0) / _CPU_COUNT),
|
||||
),
|
||||
Module(
|
||||
name="ROOT", colour=theme.ROOT, icon_name="root",
|
||||
metric="cpu_temp",
|
||||
extract=lambda s: _clamp((s.get("cpu_temp", 35.0) - 35.0) / 50.0),
|
||||
),
|
||||
Module(
|
||||
name="MESH", colour=theme.MESH, icon_name="mesh",
|
||||
metric="wifi_rssi",
|
||||
extract=lambda s: _clamp((s.get("wifi_rssi", -90) + 90.0) / 70.0),
|
||||
),
|
||||
]
|
||||
19
remote-ui/common/python/secubox_common/tests/conftest.py
Normal file
19
remote-ui/common/python/secubox_common/tests/conftest.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Shared pytest fixtures for secubox_common."""
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blank_round() -> Image.Image:
|
||||
"""480×480 RGBA black canvas — round form factor."""
|
||||
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def blank_square() -> Image.Image:
|
||||
"""800×480 RGBA black canvas — square form factor."""
|
||||
return Image.new("RGBA", (800, 480), (0, 0, 0, 255))
|
||||
198
remote-ui/common/python/secubox_common/tests/test_canvas.py
Normal file
198
remote-ui/common/python/secubox_common/tests/test_canvas.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for secubox_common.canvas — DashboardCanvas primitives."""
|
||||
from PIL import Image
|
||||
|
||||
from secubox_common import theme
|
||||
from secubox_common.canvas import DashboardCanvas
|
||||
|
||||
|
||||
def test_paint_background_fills_with_colour(blank_round):
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_background(blank_round, colour=(255, 0, 0))
|
||||
assert blank_round.getpixel((0, 0))[:3] == (255, 0, 0)
|
||||
assert blank_round.getpixel((239, 239))[:3] == (255, 0, 0)
|
||||
|
||||
|
||||
def test_paint_background_default_is_cosmos_black(blank_round):
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_background(blank_round)
|
||||
assert blank_round.getpixel((100, 100))[:3] == theme.COSMOS_BLACK
|
||||
|
||||
|
||||
def test_dashboard_canvas_layout_is_abstract():
|
||||
canvas = DashboardCanvas()
|
||||
try:
|
||||
canvas.layout({})
|
||||
except NotImplementedError:
|
||||
return
|
||||
assert False, "DashboardCanvas.layout() must raise NotImplementedError"
|
||||
|
||||
|
||||
def test_paint_rainbow_ring_pixels_in_band_are_colored(blank_round):
|
||||
"""A pixel exactly on the rainbow band radius is non-black; pixels
|
||||
inside the band are erased to COSMOS_BLACK, pixels outside the band
|
||||
are untouched (still the fixture's initial (0, 0, 0))."""
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_rainbow_ring(blank_round, center=(240, 240),
|
||||
radius_outer=235, radius_inner=220)
|
||||
|
||||
# Centre pixel = inside the inner radius, erased to COSMOS_BLACK by default.
|
||||
assert blank_round.getpixel((240, 240))[:3] == theme.COSMOS_BLACK
|
||||
|
||||
# Pixel at radius 230 (between inner=220 and outer=235): coloured.
|
||||
px = blank_round.getpixel((240 + 230, 240))
|
||||
assert px[:3] != theme.COSMOS_BLACK and px[:3] != (0, 0, 0), \
|
||||
f"expected coloured pixel at band radius 230, got {px[:3]}"
|
||||
|
||||
# Pixel at radius 238 (just outside outer=235, x=478 is in-bounds for the
|
||||
# 480-wide canvas): never touched by paint_rainbow_ring → stays at the
|
||||
# fixture's initial (0, 0, 0).
|
||||
assert blank_round.getpixel((240 + 238, 240))[:3] == (0, 0, 0)
|
||||
|
||||
|
||||
def test_paint_rainbow_ring_spans_hue_around_circle(blank_round):
|
||||
"""Sample 4 points on the band at 0°, 90°, 180°, 270° — they should
|
||||
differ in colour (rainbow hue rotates with angle)."""
|
||||
import math
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_rainbow_ring(blank_round, center=(240, 240),
|
||||
radius_outer=235, radius_inner=220)
|
||||
|
||||
R = 227 # middle of the band
|
||||
samples = []
|
||||
for angle_deg in (0, 90, 180, 270):
|
||||
rad = math.radians(angle_deg)
|
||||
x = int(240 + R * math.cos(rad))
|
||||
y = int(240 + R * math.sin(rad))
|
||||
samples.append(blank_round.getpixel((x, y))[:3])
|
||||
|
||||
# All 4 samples must be different colours.
|
||||
assert len(set(samples)) == 4, f"rainbow band hue is not rotating: {samples}"
|
||||
|
||||
|
||||
def test_paint_concentric_arcs_six_rings_present(blank_round):
|
||||
"""Six different ring colors must appear on the canvas after painting."""
|
||||
from secubox_common.modules import MODULES
|
||||
canvas = DashboardCanvas()
|
||||
# All metrics intentionally pushed past their clamp ceiling so every
|
||||
# ring fills to 100% regardless of os.cpu_count() on the test host
|
||||
# (MIND divides load_avg by core count — a 4.0 load on a 20-core box
|
||||
# would only cover 20% of MIND's ring, leaving the 3 o'clock sample
|
||||
# on the dark track instead of the module colour).
|
||||
metrics = {
|
||||
"cpu_percent": 999, "mem_percent": 999, "disk_percent": 999,
|
||||
"load_avg_1": 999, "cpu_temp": 999, "wifi_rssi": 999,
|
||||
}
|
||||
radii = [200, 185, 170, 155, 140, 125]
|
||||
canvas.paint_concentric_arcs(blank_round, center=(240, 240),
|
||||
modules=MODULES, metrics=metrics, radii=radii)
|
||||
# Sample on the right edge of each ring at angle 0° (3 o'clock).
|
||||
for m, r in zip(MODULES, radii):
|
||||
px = blank_round.getpixel((240 + r, 240))[:3]
|
||||
# Pixel must match the module colour (or be very close — antialiasing).
|
||||
dr = abs(px[0] - m.colour[0])
|
||||
dg = abs(px[1] - m.colour[1])
|
||||
db = abs(px[2] - m.colour[2])
|
||||
assert dr + dg + db < 60, \
|
||||
f"ring {m.name}: expected near {m.colour}, got {px}"
|
||||
|
||||
|
||||
def test_paint_concentric_arcs_zero_metric_draws_only_track(blank_round):
|
||||
"""With metric=0, no fill arc is drawn — only the dark track."""
|
||||
from secubox_common.modules import MODULES
|
||||
canvas = DashboardCanvas()
|
||||
metrics = {} # all metrics missing → extract returns 0 (after clamp)
|
||||
radii = [200] * 6
|
||||
canvas.paint_concentric_arcs(blank_round, center=(240, 240),
|
||||
modules=MODULES, metrics=metrics, radii=radii)
|
||||
# At 0° on the ring the fill arc starts but covers ~0°, so the
|
||||
# track colour (very dark) should be there.
|
||||
px = blank_round.getpixel((240 + 200, 240))[:3]
|
||||
assert max(px) < 50, f"expected dark track at zero-fill, got {px}"
|
||||
|
||||
|
||||
def test_paint_pod_cluster_six_coloured_circles(blank_round):
|
||||
"""Six pod circles arranged on a circle of given radius — each centre
|
||||
is non-black after painting. Loose assertion: the icon overlay may
|
||||
paint the centre pixel white/dark on top of the coloured disc, so we
|
||||
only check the disc rendered at all."""
|
||||
import math
|
||||
from secubox_common.modules import MODULES
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
|
||||
radius=100, pod_size=20)
|
||||
# Pods are at -90° + i*60° per module index.
|
||||
for i, m in enumerate(MODULES):
|
||||
angle = math.radians(-90 + i * 60)
|
||||
px = int(240 + 100 * math.cos(angle))
|
||||
py = int(240 + 100 * math.sin(angle))
|
||||
pixel = blank_round.getpixel((px, py))[:3]
|
||||
assert pixel != (0, 0, 0), \
|
||||
f"pod {m.name} at ({px},{py}) is black (expected coloured)"
|
||||
|
||||
|
||||
def test_paint_pod_cluster_uses_icon_when_available(blank_round):
|
||||
"""pod_size=48 matches an available icon file size, so the icon-paste
|
||||
path runs (rather than the letter fallback). Verifies the path runs
|
||||
without crashing and at least the first pod renders non-black."""
|
||||
import math
|
||||
from secubox_common import icons
|
||||
from secubox_common.modules import MODULES
|
||||
icons._cache_clear() # avoid carry-over None caches from earlier tests
|
||||
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
|
||||
radius=100, pod_size=48)
|
||||
# First pod at -90° (12 o'clock) maps to (240, 140).
|
||||
angle = math.radians(-90)
|
||||
px = int(240 + 100 * math.cos(angle))
|
||||
py = int(240 + 100 * math.sin(angle))
|
||||
assert blank_round.getpixel((px, py))[:3] != (0, 0, 0)
|
||||
|
||||
|
||||
def test_paint_pod_cluster_no_icon_falls_back_to_letter(blank_round, monkeypatch):
|
||||
"""When the icon loader returns None, pod still draws and shows the
|
||||
first letter."""
|
||||
from secubox_common import icons
|
||||
from secubox_common.modules import MODULES
|
||||
monkeypatch.setattr(icons, "load_module_icon", lambda *a, **kw: None)
|
||||
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_pod_cluster(blank_round, MODULES, center=(240, 240),
|
||||
radius=100, pod_size=30)
|
||||
# Just verify it didn't crash and pods are drawn (non-black at pod centres).
|
||||
import math
|
||||
for i, m in enumerate(MODULES):
|
||||
angle = math.radians(-90 + i * 60)
|
||||
px = int(240 + 100 * math.cos(angle))
|
||||
py = int(240 + 100 * math.sin(angle))
|
||||
assert blank_round.getpixel((px, py)) != (0, 0, 0, 255)
|
||||
|
||||
|
||||
def test_paint_central_button_draws_hollow_white_circle(blank_round):
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_central_button(blank_round, center=(240, 240), size=20)
|
||||
# Centre of the button should be black (hollow).
|
||||
assert blank_round.getpixel((240, 240))[:3] == (0, 0, 0)
|
||||
# Edge of the button at radius=20 should be white.
|
||||
px = blank_round.getpixel((240 + 20, 240))[:3]
|
||||
assert max(px) > 200, f"button edge expected white-ish, got {px}"
|
||||
|
||||
|
||||
def test_paint_alert_ribbon_renders_text(blank_round):
|
||||
canvas = DashboardCanvas()
|
||||
canvas.paint_alert_ribbon(blank_round, region_y=460,
|
||||
text="TEST ALERT", severity="warn")
|
||||
# Bottom region should be no longer fully black.
|
||||
found_nonblack = False
|
||||
for y in range(460, 480):
|
||||
for x in range(0, 480, 10):
|
||||
if blank_round.getpixel((x, y))[:3] != (0, 0, 0):
|
||||
found_nonblack = True
|
||||
break
|
||||
if found_nonblack:
|
||||
break
|
||||
assert found_nonblack, "alert ribbon did not draw any non-black pixels"
|
||||
75
remote-ui/common/python/secubox_common/tests/test_icons.py
Normal file
75
remote-ui/common/python/secubox_common/tests/test_icons.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for secubox_common.icons — path resolution + LRU cache."""
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_common import icons
|
||||
|
||||
|
||||
def test_load_missing_icon_returns_none(monkeypatch, tmp_path):
|
||||
"""No icon file anywhere → None, no exception."""
|
||||
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS",
|
||||
[tmp_path / "does-not-exist"])
|
||||
icons._cache_clear()
|
||||
assert icons.load_module_icon("auth", 48) is None
|
||||
|
||||
|
||||
def test_load_existing_icon_returns_pil_image(monkeypatch, tmp_path):
|
||||
"""Icon found in the search path is returned as a Pillow image."""
|
||||
iconsdir = tmp_path / "icons"
|
||||
iconsdir.mkdir()
|
||||
fake = Image.new("RGBA", (48, 48), (255, 0, 0, 255))
|
||||
fake.save(iconsdir / "auth-48.png")
|
||||
|
||||
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
|
||||
icons._cache_clear()
|
||||
|
||||
img = icons.load_module_icon("auth", 48)
|
||||
assert img is not None
|
||||
assert img.size == (48, 48)
|
||||
|
||||
|
||||
def test_load_caches_by_name_and_size(monkeypatch, tmp_path):
|
||||
"""Second call with same (name, size) returns the same object."""
|
||||
iconsdir = tmp_path / "icons"
|
||||
iconsdir.mkdir()
|
||||
Image.new("RGBA", (48, 48), (0, 255, 0, 255)).save(iconsdir / "wall-48.png")
|
||||
|
||||
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
|
||||
icons._cache_clear()
|
||||
|
||||
a = icons.load_module_icon("wall", 48)
|
||||
b = icons.load_module_icon("wall", 48)
|
||||
assert a is b
|
||||
|
||||
|
||||
def test_search_paths_in_order(monkeypatch, tmp_path):
|
||||
"""First path with the icon wins, even if later paths also have one."""
|
||||
first = tmp_path / "first"; first.mkdir()
|
||||
second = tmp_path / "second"; second.mkdir()
|
||||
Image.new("RGBA", (48, 48), (255, 0, 0, 255)).save(first / "boot-48.png")
|
||||
Image.new("RGBA", (48, 48), (0, 0, 255, 255)).save(second / "boot-48.png")
|
||||
|
||||
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [first, second])
|
||||
icons._cache_clear()
|
||||
|
||||
img = icons.load_module_icon("boot", 48)
|
||||
# Pixel-sample to confirm we got the RED one (first path)
|
||||
px = img.getpixel((0, 0))
|
||||
assert px[:3] == (255, 0, 0)
|
||||
|
||||
|
||||
def test_lowercase_name_normalisation(monkeypatch, tmp_path):
|
||||
"""Caller can pass 'AUTH' or 'auth' — both find auth-48.png."""
|
||||
iconsdir = tmp_path / "icons"
|
||||
iconsdir.mkdir()
|
||||
Image.new("RGBA", (48, 48), (1, 2, 3, 255)).save(iconsdir / "auth-48.png")
|
||||
monkeypatch.setattr(icons, "ICON_SEARCH_PATHS", [iconsdir])
|
||||
icons._cache_clear()
|
||||
|
||||
assert icons.load_module_icon("AUTH", 48) is not None
|
||||
assert icons.load_module_icon("auth", 48) is not None
|
||||
59
remote-ui/common/python/secubox_common/tests/test_modules.py
Normal file
59
remote-ui/common/python/secubox_common/tests/test_modules.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for secubox_common.modules — canonical Hamiltonian module table."""
|
||||
from secubox_common import modules, theme
|
||||
|
||||
|
||||
def test_modules_hamiltonian_order():
|
||||
names = [m.name for m in modules.MODULES]
|
||||
assert names == ["AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"]
|
||||
|
||||
|
||||
def test_each_module_has_required_fields():
|
||||
for m in modules.MODULES:
|
||||
assert m.name
|
||||
assert isinstance(m.colour, tuple) and len(m.colour) == 3
|
||||
assert m.icon_name == m.name.lower()
|
||||
assert m.metric
|
||||
assert callable(m.extract)
|
||||
|
||||
|
||||
def test_extract_returns_unit_interval_for_typical_values():
|
||||
sample = {
|
||||
"cpu_percent": 50,
|
||||
"mem_percent": 75,
|
||||
"disk_percent": 30,
|
||||
"load_avg_1": 2.0,
|
||||
"cpu_temp": 60,
|
||||
"wifi_rssi": -60,
|
||||
}
|
||||
for m in modules.MODULES:
|
||||
v = m.extract(sample)
|
||||
assert 0.0 <= v <= 1.0, f"{m.name} extract returned {v} out of [0,1]"
|
||||
|
||||
|
||||
def test_extract_clamps_high_values():
|
||||
high = {
|
||||
"cpu_percent": 999, "mem_percent": 999, "disk_percent": 999,
|
||||
"load_avg_1": 999, "cpu_temp": 999, "wifi_rssi": 999,
|
||||
}
|
||||
for m in modules.MODULES:
|
||||
assert m.extract(high) == 1.0
|
||||
|
||||
|
||||
def test_extract_clamps_low_values_and_missing():
|
||||
low = {} # all metrics missing → defaults
|
||||
for m in modules.MODULES:
|
||||
v = m.extract(low)
|
||||
assert 0.0 <= v <= 1.0
|
||||
|
||||
|
||||
def test_modules_use_theme_colours():
|
||||
expected = {
|
||||
"AUTH": theme.AUTH, "WALL": theme.WALL, "BOOT": theme.BOOT,
|
||||
"MIND": theme.MIND, "ROOT": theme.ROOT, "MESH": theme.MESH,
|
||||
}
|
||||
for m in modules.MODULES:
|
||||
assert m.colour == expected[m.name]
|
||||
41
remote-ui/common/python/secubox_common/tests/test_theme.py
Normal file
41
remote-ui/common/python/secubox_common/tests/test_theme.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for secubox_common.theme — palette + DEFAULT_FONT loader."""
|
||||
from PIL import ImageDraw, Image
|
||||
|
||||
from secubox_common import theme
|
||||
|
||||
|
||||
def test_module_colors_are_rgb_byte_tuples():
|
||||
for name in ("AUTH", "WALL", "BOOT", "MIND", "ROOT", "MESH"):
|
||||
c = getattr(theme, name)
|
||||
assert isinstance(c, tuple) and len(c) == 3
|
||||
assert all(isinstance(b, int) and 0 <= b <= 255 for b in c)
|
||||
|
||||
|
||||
def test_token_colors_present():
|
||||
for name in ("COSMOS_BLACK", "GOLD_HERMETIC", "CINNABAR",
|
||||
"MATRIX_GREEN", "CYBER_CYAN", "VOID_PURPLE",
|
||||
"TEXT_PRIMARY", "TEXT_MUTED"):
|
||||
c = getattr(theme, name)
|
||||
assert isinstance(c, tuple) and len(c) == 3
|
||||
|
||||
|
||||
def test_severity_table_has_three_keys():
|
||||
assert set(theme.SEVERITY.keys()) == {"info", "warn", "crit"}
|
||||
|
||||
|
||||
def test_load_default_font_returns_usable_font():
|
||||
font = theme.load_default_font(12)
|
||||
# Must be either a TrueType (DejaVu) or the legacy bitmap default.
|
||||
assert hasattr(font, "getbbox") or hasattr(font, "getmask")
|
||||
|
||||
|
||||
def test_load_default_font_renders_unicode_without_crash():
|
||||
"""Regression for the latin-1 bitmap default crash from PR #134."""
|
||||
img = Image.new("RGB", (60, 20), color=(0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((2, 2), "○ NOMINAL", fill=(0, 255, 0),
|
||||
font=theme.load_default_font(12))
|
||||
52
remote-ui/common/python/secubox_common/theme.py
Normal file
52
remote-ui/common/python/secubox_common/theme.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""SecuBox palette + DEFAULT_FONT loader.
|
||||
|
||||
Carried over from packages/secubox-eye-square/kiosk/secubox_eye_square_kiosk/theme.py
|
||||
and remote-ui/round/fb_dashboard.py module color constants. Single source of truth.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import ImageFont
|
||||
|
||||
# Module colours (round/index.html / Phase 1 spec literal hex)
|
||||
AUTH = (0xC0, 0x4E, 0x24)
|
||||
WALL = (0x9A, 0x60, 0x10)
|
||||
BOOT = (0x80, 0x30, 0x18)
|
||||
MIND = (0x3D, 0x35, 0xA0)
|
||||
ROOT = (0x0A, 0x58, 0x40)
|
||||
MESH = (0x10, 0x4A, 0x88)
|
||||
|
||||
# C3BOX shared tokens
|
||||
COSMOS_BLACK = (0x08, 0x08, 0x08)
|
||||
GOLD_HERMETIC = (0xC9, 0xA8, 0x4C)
|
||||
CINNABAR = (0xE6, 0x39, 0x46)
|
||||
MATRIX_GREEN = (0x00, 0xFF, 0x41)
|
||||
CYBER_CYAN = (0x00, 0xD4, 0xFF)
|
||||
VOID_PURPLE = (0x6E, 0x40, 0xC9)
|
||||
TEXT_PRIMARY = (0xCC, 0xCC, 0xCC)
|
||||
TEXT_MUTED = (0x4A, 0x4A, 0x4A)
|
||||
|
||||
SEVERITY = {
|
||||
"info": CYBER_CYAN,
|
||||
"warn": GOLD_HERMETIC,
|
||||
"crit": CINNABAR,
|
||||
}
|
||||
|
||||
_DEJAVU = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
|
||||
|
||||
def load_default_font(size: int = 12):
|
||||
"""Load DejaVuSans at the requested size, fall back to load_default().
|
||||
|
||||
Falls back when fonts-dejavu-core isn't installed (e.g., unit test
|
||||
hosts without the apt package). Callers should not assume Unicode
|
||||
support when the fallback is active — only ASCII renders reliably
|
||||
on Pillow's legacy bitmap default.
|
||||
"""
|
||||
try:
|
||||
return ImageFont.truetype(_DEJAVU, size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
|
@ -923,6 +923,12 @@ fi
|
|||
mkdir -p "$ROOT_MNT/var/www/common"
|
||||
cp -r "$COMMON_SRC/." "$ROOT_MNT/var/www/common/"
|
||||
log "Embedded common/ (css, js, assets/icons, shell) at /var/www/common/"
|
||||
# secubox_common Python package needs to be importable from a directory
|
||||
# that's on sys.path. Ship at /var/www/common/python/ and put PYTHONPATH
|
||||
# on the relevant systemd units (see Environment="PYTHONPATH=..." lines).
|
||||
log "Embedded common/python/secubox_common/ at /var/www/common/python/secubox_common/"
|
||||
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
|
||||
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# SSH KEY
|
||||
|
|
|
|||
|
|
@ -988,154 +988,19 @@ def draw_auth_mode(auth: AuthState) -> Image.Image:
|
|||
|
||||
|
||||
def draw_dashboard(metrics, mode='SIM', host='', device_name=''):
|
||||
"""Draw the dashboard to an image
|
||||
"""Render the main dashboard view via RoundDashboard.
|
||||
|
||||
Args:
|
||||
metrics: Dict with cpu, mem, disk, load, temp, wifi, uptime, hostname
|
||||
mode: Transport mode - 'OTG', 'WiFi', or 'SIM'
|
||||
host: SecuBox host IP/address
|
||||
device_name: Name of connected SecuBox device
|
||||
Mode/host/device_name are passed through to the dashboard via the
|
||||
metrics dict (the canvas reads them under the keys "_mode", "_host",
|
||||
"_device_name") — keeps the call-site backwards-compatible.
|
||||
"""
|
||||
img = Image.new('RGBA', (WIDTH, HEIGHT), BG_COLOR + (255,))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
cx, cy = WIDTH // 2, HEIGHT // 2
|
||||
|
||||
# Load fonts
|
||||
try:
|
||||
font_large = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 42)
|
||||
font_medium = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
|
||||
font_small = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 14)
|
||||
font_tiny = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 11)
|
||||
except:
|
||||
font_large = ImageFont.load_default()
|
||||
font_medium = font_large
|
||||
font_small = font_large
|
||||
font_tiny = font_large
|
||||
|
||||
# Draw circular border
|
||||
draw.ellipse([10, 10, WIDTH-10, HEIGHT-10], outline=(40, 40, 40), width=2)
|
||||
|
||||
# Draw module rings
|
||||
for _, mod in MODULES.items():
|
||||
r = mod['r']
|
||||
color = mod['color']
|
||||
metric_name = mod['metric']
|
||||
value = metrics.get(metric_name, 0)
|
||||
|
||||
# Calculate percentage for arc
|
||||
if metric_name == 'load':
|
||||
pct = min(100, value * 25) # 4.0 = 100%
|
||||
elif metric_name == 'wifi':
|
||||
pct = min(100, max(0, (value + 80) * 2)) # -80 to -30 dBm
|
||||
elif metric_name == 'temp':
|
||||
pct = min(100, max(0, (value - 30) * 2.5)) # 30-70°C
|
||||
else:
|
||||
pct = min(100, max(0, value))
|
||||
|
||||
# Draw background arc
|
||||
for angle in range(0, 360, 2):
|
||||
rad = math.radians(angle - 90)
|
||||
x = cx + r * math.cos(rad)
|
||||
y = cy + r * math.sin(rad)
|
||||
draw.ellipse([x-2, y-2, x+2, y+2], fill=(30, 30, 30))
|
||||
|
||||
# Draw value arc
|
||||
arc_end = int(pct * 3.6)
|
||||
for angle in range(0, arc_end, 2):
|
||||
rad = math.radians(angle - 90)
|
||||
x = cx + r * math.cos(rad)
|
||||
y = cy + r * math.sin(rad)
|
||||
draw.ellipse([x-3, y-3, x+3, y+3], fill=color)
|
||||
|
||||
# Draw head dot
|
||||
if arc_end > 0:
|
||||
rad = math.radians(arc_end - 90)
|
||||
x = cx + r * math.cos(rad)
|
||||
y = cy + r * math.sin(rad)
|
||||
draw.ellipse([x-5, y-5, x+5, y+5], fill=(255, 255, 255))
|
||||
|
||||
# Center info - Contextual icon + mode display
|
||||
# Get the most critical module for contextual icon
|
||||
critical_module, criticality = get_critical_module(metrics)
|
||||
module_color = MODULES[critical_module]['color']
|
||||
|
||||
# Draw contextual icon (48px) centered above mode text
|
||||
icon = load_module_icon(critical_module, 48)
|
||||
if icon:
|
||||
icon_x = cx - 24 # Center 48px icon
|
||||
icon_y = cy - 75 # Above mode text
|
||||
img.paste(icon, (icon_x, icon_y), icon) # Use alpha mask
|
||||
|
||||
# OTG/WiFi/SIM status
|
||||
if mode == 'OTG':
|
||||
mode_text = 'USB OTG'
|
||||
mode_color = STATUS_OK # Neon green
|
||||
elif mode == 'WIFI':
|
||||
mode_text = 'WiFi'
|
||||
mode_color = (0, 191, 255) # Cyan
|
||||
else:
|
||||
mode_text = 'SIM'
|
||||
mode_color = STATUS_SIM
|
||||
|
||||
# Mode indicator (smaller, below icon)
|
||||
bbox = draw.textbbox((0, 0), mode_text, font=font_medium)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, cy - 20), mode_text, fill=mode_color, font=font_medium)
|
||||
|
||||
# Critical module indicator with value
|
||||
metric_name = MODULES[critical_module]['metric']
|
||||
metric_value = metrics.get(metric_name, 0)
|
||||
metric_unit = MODULES[critical_module]['unit']
|
||||
if metric_name == 'wifi':
|
||||
value_text = f"{critical_module} {int(metric_value)}{metric_unit}"
|
||||
elif metric_name == 'load':
|
||||
value_text = f"{critical_module} {metric_value:.1f}{metric_unit}"
|
||||
else:
|
||||
value_text = f"{critical_module} {int(metric_value)}{metric_unit}"
|
||||
bbox = draw.textbbox((0, 0), value_text, font=font_small)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, cy + 5), value_text, fill=module_color, font=font_small)
|
||||
|
||||
# Connection status
|
||||
if mode in ['OTG', 'WIFI']:
|
||||
status_text = 'CONNECTED'
|
||||
bbox = draw.textbbox((0, 0), status_text, font=font_tiny)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, cy + 25), status_text, fill=mode_color, font=font_tiny)
|
||||
|
||||
# Hostname below
|
||||
hostname = metrics.get('hostname', 'secubox')
|
||||
bbox = draw.textbbox((0, 0), hostname, font=font_small)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, cy + 42), hostname, fill=TEXT_MUTED, font=font_small)
|
||||
|
||||
# Rings only - no text labels on circles (clean design)
|
||||
|
||||
# Top: SecuBox branding + device name
|
||||
brand = 'SECUBOX EYE'
|
||||
bbox = draw.textbbox((0, 0), brand, font=font_tiny)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, 20), brand, fill=(201, 168, 76), font=font_tiny) # gold-hermetic
|
||||
|
||||
# Device name/host at top (if connected)
|
||||
if device_name or host:
|
||||
device_text = device_name if device_name else host
|
||||
# Truncate if too long
|
||||
if len(device_text) > 20:
|
||||
device_text = device_text[:18] + '..'
|
||||
bbox = draw.textbbox((0, 0), device_text, font=font_tiny)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, 35), device_text, fill=TEXT_MUTED, font=font_tiny)
|
||||
|
||||
# Host address at bottom (minimal)
|
||||
if host and mode != 'SIM':
|
||||
host_display = host.replace('http://', '').replace('https://', '').split(':')[0]
|
||||
bbox = draw.textbbox((0, 0), host_display, font=font_tiny)
|
||||
tw = bbox[2] - bbox[0]
|
||||
draw.text((cx - tw//2, HEIGHT - 30), host_display, fill=TEXT_MUTED, font=font_tiny)
|
||||
|
||||
return img
|
||||
from round_dashboard import RoundDashboard
|
||||
rd = RoundDashboard()
|
||||
extended = dict(metrics)
|
||||
extended.setdefault("_mode", mode)
|
||||
extended.setdefault("_host", host)
|
||||
extended.setdefault("_device_name", device_name)
|
||||
return rd.layout(extended)
|
||||
|
||||
|
||||
def get_fb_info():
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ After=systemd-udev-settle.service
|
|||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Environment="PYTHONPATH=/var/www/common/python"
|
||||
# Wait for framebuffer device
|
||||
ExecStartPre=/bin/sh -c "for i in 1 2 3 4 5 6 7 8 9 10; do [ -e /dev/fb0 ] && exit 0; sleep 1; done; echo 'WARNING: /dev/fb0 not found after 10s'"
|
||||
# Disable console on framebuffer to prevent text overlay
|
||||
|
|
|
|||
46
remote-ui/round/round_dashboard.py
Normal file
46
remote-ui/round/round_dashboard.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""RoundDashboard — 480×480 Pi Zero W kiosk using secubox_common primitives."""
|
||||
from __future__ import annotations
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from secubox_common import theme
|
||||
from secubox_common.canvas import DashboardCanvas
|
||||
from secubox_common.modules import MODULES
|
||||
|
||||
|
||||
class RoundDashboard(DashboardCanvas):
|
||||
SIZE = (480, 480)
|
||||
CENTER = (240, 240)
|
||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
||||
|
||||
def layout(self, metrics: dict) -> Image.Image:
|
||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||
self.paint_rainbow_ring(img, self.CENTER, 235, 220)
|
||||
self.paint_concentric_arcs(img, self.CENTER, MODULES, metrics,
|
||||
self.RING_RADII)
|
||||
# pod_size=48 matches the deployed icon sizes (22/48/96/128); 40 would
|
||||
# miss and fall back to the first-letter placeholder. radius bumped
|
||||
# to 78 so pod inner edge (54) stays clear of the central button (44).
|
||||
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||
self.paint_central_button(img, self.CENTER, size=44)
|
||||
return img
|
||||
|
||||
# Round-only additional view modes (called by fb_dashboard.py's main
|
||||
# loop when the user long-presses center → radial menu → terminal/flash/auth).
|
||||
def layout_terminal(self, term_state) -> Image.Image:
|
||||
# Delegates to the existing draw_terminal() helper for now;
|
||||
# extracted into a method to give the main loop a class-based API.
|
||||
from fb_dashboard import draw_terminal
|
||||
return draw_terminal(term_state)
|
||||
|
||||
def layout_flash(self, flash_state) -> Image.Image:
|
||||
from fb_dashboard import draw_flash_progress
|
||||
return draw_flash_progress(flash_state)
|
||||
|
||||
def layout_auth(self, auth_state) -> Image.Image:
|
||||
from fb_dashboard import draw_auth_mode
|
||||
return draw_auth_mode(auth_state)
|
||||
43
remote-ui/round/tests/test_round_dashboard.py
Normal file
43
remote-ui/round/tests/test_round_dashboard.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||
# See LICENCE-CMSD-1.0.md for terms.
|
||||
"""Tests for RoundDashboard — Pi Zero W 480×480 layout via secubox_common."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_DEV = Path(__file__).resolve().parents[3] / "remote-ui" / "common" / "python"
|
||||
if str(_DEV) not in sys.path:
|
||||
sys.path.insert(0, str(_DEV))
|
||||
|
||||
# Also add the round/ directory itself so round_dashboard imports work.
|
||||
_ROUND = Path(__file__).resolve().parents[1]
|
||||
if str(_ROUND) not in sys.path:
|
||||
sys.path.insert(0, str(_ROUND))
|
||||
|
||||
from round_dashboard import RoundDashboard
|
||||
from secubox_common.canvas import DashboardCanvas
|
||||
|
||||
|
||||
def test_round_dashboard_subclasses_canvas():
|
||||
assert issubclass(RoundDashboard, DashboardCanvas)
|
||||
|
||||
|
||||
def test_round_dashboard_size_is_480():
|
||||
rd = RoundDashboard()
|
||||
assert rd.SIZE == (480, 480)
|
||||
|
||||
|
||||
def test_round_dashboard_layout_returns_rgba_480x480():
|
||||
rd = RoundDashboard()
|
||||
img = rd.layout({})
|
||||
assert img.mode == "RGBA"
|
||||
assert img.size == (480, 480)
|
||||
|
||||
|
||||
def test_round_dashboard_layout_paints_rainbow_ring():
|
||||
rd = RoundDashboard()
|
||||
img = rd.layout({})
|
||||
# Rainbow ring is at radius 220-235; sample at radius 227 angle 0.
|
||||
px = img.getpixel((240 + 227, 240))
|
||||
assert px[:3] != (0, 0, 0), "rainbow ring not painted at 3 o'clock"
|
||||
|
|
@ -103,6 +103,35 @@ log "Installing config files (systemd, udev, apparmor, firstboot)..."
|
|||
cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/"
|
||||
chmod +x "$ROOT_MNT/usr/local/sbin/firstboot.sh"
|
||||
|
||||
# Install the shared OTG gadget composer (round does this at line 618 of
|
||||
# build-eye-remote-image.sh). secubox-otg-gadget.service ExecStarts this path;
|
||||
# without it the gadget never composes and the Pi 4B's USB-C bus stays silent
|
||||
# → MOCHAbin/host enumeration fails (no descriptor events, xhci timeouts).
|
||||
log "Installing OTG gadget composer at /usr/local/sbin/secubox-otg-gadget.sh..."
|
||||
cp "$REPO_ROOT/remote-ui/common/shell/secubox-otg-gadget.sh" \
|
||||
"$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||
chmod +x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||
test -x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh" || \
|
||||
{ err "secubox-otg-gadget.sh not executable on rootfs"; exit 2; }
|
||||
|
||||
# Ship the shared secubox_common package.
|
||||
log "Embedding remote-ui/common/python at /var/www/common/python/..."
|
||||
mkdir -p "$ROOT_MNT/var/www/common/python"
|
||||
cp -r "$REPO_ROOT/remote-ui/common/python/." "$ROOT_MNT/var/www/common/python/"
|
||||
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
|
||||
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
|
||||
|
||||
# Ship the shared icon assets — secubox_common.icons.load_module_icon
|
||||
# resolves at /var/www/common/assets/icons/ first. Without these the pod
|
||||
# cluster falls back to first-letter placeholders.
|
||||
log "Embedding remote-ui/common/assets at /var/www/common/assets/..."
|
||||
mkdir -p "$ROOT_MNT/var/www/common/assets"
|
||||
cp -r "$REPO_ROOT/remote-ui/common/assets/." "$ROOT_MNT/var/www/common/assets/"
|
||||
ICON_COUNT=$(ls "$ROOT_MNT/var/www/common/assets/icons"/*-48.png 2>/dev/null | wc -l)
|
||||
test "$ICON_COUNT" -gt 0 || \
|
||||
{ err "no *-48.png icons in /var/www/common/assets/icons — common/ assets incomplete"; exit 2; }
|
||||
log " → $ICON_COUNT module icons at size 48 shipped"
|
||||
|
||||
log "Installing Python packages..."
|
||||
mkdir -p "$ROOT_MNT/usr/lib/python3/dist-packages"
|
||||
cp -r "$REPO_ROOT/packages/secubox-eye-square/helper/eye_square_helper" \
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ ConditionPathExists=/dev/fb0
|
|||
Type=simple
|
||||
User=secubox
|
||||
Group=secubox
|
||||
Environment="PYTHONPATH=/var/www/common/python"
|
||||
SupplementaryGroups=video input
|
||||
ExecStart=/usr/bin/python3 -m secubox_eye_square_kiosk
|
||||
Restart=always
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user