mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-06-30 14:10:33 +00:00
Compare commits
37 Commits
25e1e89ed1
...
ec1a4641e0
| Author | SHA1 | Date | |
|---|---|---|---|
| ec1a4641e0 | |||
| 6d12af8624 | |||
| 8996847745 | |||
| f4acd5a9ca | |||
| 387fabb4f3 | |||
| 5546169971 | |||
| c9539f8ba6 | |||
| b57891941b | |||
| 39769c318b | |||
| eeb455b673 | |||
| 4aa36f104e | |||
| 626e68904f | |||
| 4e03649954 | |||
| d2ec163912 | |||
| c214c946c2 | |||
| 50a687654c | |||
| 75991ec5b0 | |||
| 238304f351 | |||
| 4c176f8be2 | |||
| 153023b390 | |||
| 26e7f45285 | |||
| b15497d668 | |||
| 8922b2f59e | |||
| f3fba94bc4 | |||
| f1e2375e57 | |||
| 1cc3f8ee8e | |||
| ea7c278126 | |||
| 43a994fde0 | |||
| 74c5076b60 | |||
| c31c61bf3b | |||
| 8809104760 | |||
| 50e4c05a67 | |||
| 33fb2a20b0 | |||
| 0f8498a37a | |||
| 847af3e563 | |||
| 7b0f5306f5 | |||
| f552411f96 |
|
|
@ -28,6 +28,8 @@ HELPER_SOCK = os.environ.get(
|
||||||
TARGET_FPS = 30
|
TARGET_FPS = 30
|
||||||
PROBE_INTERVAL_S = 30
|
PROBE_INTERVAL_S = 30
|
||||||
METRICS_INTERVAL_S = 2
|
METRICS_INTERVAL_S = 2
|
||||||
|
# Radar sweep rotation speed (matches the deployed round fallback radar).
|
||||||
|
RADAR_RPM = 12.0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|
@ -89,8 +91,10 @@ def main() -> int:
|
||||||
if ev.kind == "tap":
|
if ev.kind == "tap":
|
||||||
_dispatch_tap(ev.x, ev.y, panel, dashboard)
|
_dispatch_tap(ev.x, ev.y, panel, dashboard)
|
||||||
|
|
||||||
# Render.
|
# Render. phase advances the radar sweep angle — monotonic
|
||||||
full = dashboard.layout(metrics)
|
# so frame-to-frame motion is smooth across system clock jumps.
|
||||||
|
phase = (time.monotonic() * RADAR_RPM / 60.0) % 1.0
|
||||||
|
full = dashboard.layout(metrics, phase=phase)
|
||||||
if pointer.cursor_visible:
|
if pointer.cursor_visible:
|
||||||
draw_cursor(full, *pointer.cursor_xy)
|
draw_cursor(full, *pointer.cursor_xy)
|
||||||
fb.blit(full)
|
fb.blit(full)
|
||||||
|
|
|
||||||
|
|
@ -22,27 +22,34 @@ class SquareDashboard(DashboardCanvas):
|
||||||
DASHBOARD_REGION_SIZE = (480, 480)
|
DASHBOARD_REGION_SIZE = (480, 480)
|
||||||
PANEL_REGION_SIZE = (320, 480)
|
PANEL_REGION_SIZE = (320, 480)
|
||||||
CENTER = (240, 240)
|
CENTER = (240, 240)
|
||||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
# Same radii as RoundDashboard so the left half is visually identical
|
||||||
|
# to the deployed Pi Zero W radar.
|
||||||
|
RING_RADII = [214, 188, 162, 136, 110, 84]
|
||||||
|
|
||||||
def __init__(self, right_panel):
|
def __init__(self, right_panel):
|
||||||
self.right_panel = right_panel
|
self.right_panel = right_panel
|
||||||
|
|
||||||
def layout(self, metrics: dict) -> Image.Image:
|
def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
|
||||||
# Image.new() with COSMOS_BLACK+(255,) is equivalent to calling
|
"""Render one frame at animation `phase` (0..1).
|
||||||
# paint_background on a fresh canvas; skip the redundant fill.
|
|
||||||
|
Phase rotates the radar sweep on the left half; the right panel
|
||||||
|
is static. Pass `phase=0.0` for a still frame.
|
||||||
|
"""
|
||||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||||
|
|
||||||
# Left dashboard region.
|
# Left dashboard region — phase-aware radar.
|
||||||
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
|
dash = Image.new("RGBA", self.DASHBOARD_REGION_SIZE,
|
||||||
theme.COSMOS_BLACK + (255,))
|
theme.COSMOS_BLACK + (255,))
|
||||||
self.paint_rainbow_ring(dash, self.CENTER, 235, 220)
|
self.paint_radar_concentric(
|
||||||
self.paint_concentric_arcs(dash, self.CENTER, MODULES, metrics,
|
dash, self.CENTER, MODULES, metrics,
|
||||||
self.RING_RADII)
|
radii=self.RING_RADII, phase=phase, draw_hub=True,
|
||||||
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=70, pod_size=40)
|
)
|
||||||
|
# pod_size=48 matches deployed icon sizes (22/48/96/128).
|
||||||
|
self.paint_pod_cluster(dash, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||||
self.paint_central_button(dash, self.CENTER, size=44)
|
self.paint_central_button(dash, self.CENTER, size=44)
|
||||||
img.paste(dash, (0, 0))
|
img.paste(dash, (0, 0))
|
||||||
|
|
||||||
# Right panel.
|
# Right panel (static).
|
||||||
panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
|
panel = Image.new("RGBA", self.PANEL_REGION_SIZE,
|
||||||
theme.COSMOS_BLACK + (255,))
|
theme.COSMOS_BLACK + (255,))
|
||||||
self.right_panel.draw(panel)
|
self.right_panel.draw(panel)
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,25 @@ class DashboardCanvas:
|
||||||
draw.text((cx - lw // 2, cy + size + 4),
|
draw.text((cx - lw // 2, cy + size + 4),
|
||||||
label, fill=theme.TEXT_PRIMARY + (255,), font=font)
|
label, fill=theme.TEXT_PRIMARY + (255,), font=font)
|
||||||
|
|
||||||
|
def paint_radar_concentric(self, img: Image.Image,
|
||||||
|
center: tuple[int, int],
|
||||||
|
modules: Iterable[Module],
|
||||||
|
metrics: dict,
|
||||||
|
radii: list[int] | None = None,
|
||||||
|
phase: float = 0.0,
|
||||||
|
draw_hub: bool = True,
|
||||||
|
name_to_angle: dict[str, int] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Phase-aware concentric radar (ring backgrounds + tube arcs +
|
||||||
|
rotating sweep + sweep head + optional hub). Delegates to
|
||||||
|
`secubox_common.painters.radar_concentric.paint`. Drives the
|
||||||
|
OFFLINE-state visual on both round/ and the square's left half.
|
||||||
|
"""
|
||||||
|
from .painters import radar_concentric as _radar
|
||||||
|
_radar.paint(img, center, modules, metrics,
|
||||||
|
radii=radii, phase=phase, draw_hub=draw_hub,
|
||||||
|
name_to_angle=name_to_angle)
|
||||||
|
|
||||||
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
|
def paint_alert_ribbon(self, img: Image.Image, region_y: int,
|
||||||
text: str, severity: str) -> None:
|
text: str, severity: str) -> None:
|
||||||
"""Bottom strip: solid dark fill + coloured severity text.
|
"""Bottom strip: solid dark fill + coloured severity text.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""Phase-aware painter modules for animated dashboard variants."""
|
||||||
|
|
@ -0,0 +1,244 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""Concentric radar painter — port of fallback_manager._draw_offline_radar().
|
||||||
|
|
||||||
|
Stateless: given a `phase` in [0, 1) it draws the full radar frame
|
||||||
|
(ring backgrounds, balanced tube arcs, rotating segment-coloured sweep,
|
||||||
|
sweep head, centre hub) onto `img` centred on `center`.
|
||||||
|
|
||||||
|
`phase * 2π` is the sweep angle in radians, measured clockwise from
|
||||||
|
12 o'clock. Caller is responsible for advancing phase across frames
|
||||||
|
(typically `(time.monotonic() * rpm / 60) % 1`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import colorsys
|
||||||
|
import math
|
||||||
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from ..modules import Module
|
||||||
|
|
||||||
|
# Default radii match the deployed fallback_manager.py layout (6 rings).
|
||||||
|
DEFAULT_RADII: list[int] = [214, 188, 162, 136, 110, 84]
|
||||||
|
|
||||||
|
# Stroke width for ring backgrounds + tube arcs.
|
||||||
|
RING_WIDTH: int = 20
|
||||||
|
|
||||||
|
# Inner hub radius — masks the centre so it can host icons / metrics text.
|
||||||
|
HUB_RADIUS: int = 85
|
||||||
|
|
||||||
|
# Mapping module NAME → PIL arc centre angle so each tube arc sits at the
|
||||||
|
# position on the wheel that matches its colour. PIL convention: 0=3 o'clock,
|
||||||
|
# angles increase counter-clockwise. Decoupling angle from list order means
|
||||||
|
# the painter is invariant to MODULES iteration order — callers can pass
|
||||||
|
# secubox_common.MODULES (AUTH-first) or fallback_manager's local list
|
||||||
|
# (BOOT-first) and get the same wheel layout.
|
||||||
|
DEFAULT_NAME_TO_ANGLE: dict[str, int] = {
|
||||||
|
"AUTH": 0, # red-orange (3 o'clock)
|
||||||
|
"WALL": 60, # orange-yellow
|
||||||
|
"ROOT": 120, # green
|
||||||
|
"MESH": 180, # blue
|
||||||
|
"MIND": 240, # purple
|
||||||
|
"BOOT": 300, # deep red — closes the wheel between purple and red
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def paint(
|
||||||
|
img: Image.Image,
|
||||||
|
center: tuple[int, int],
|
||||||
|
modules: Iterable[Module],
|
||||||
|
metrics: dict,
|
||||||
|
radii: list[int] | None = None,
|
||||||
|
phase: float = 0.0,
|
||||||
|
ring_width: int = RING_WIDTH,
|
||||||
|
hub_radius: int = HUB_RADIUS,
|
||||||
|
draw_hub: bool = True,
|
||||||
|
name_to_angle: dict[str, int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Paint one radar frame at `phase`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: target RGBA image; must be large enough that
|
||||||
|
`radii[0] + 8` fits within ``min(img.size) / 2``.
|
||||||
|
center: (cx, cy) — the radar centre in image coordinates.
|
||||||
|
modules: ordered iterable of Module — drawn outermost-first.
|
||||||
|
metrics: dict passed to `Module.extract` for each ring's fill ratio.
|
||||||
|
radii: per-ring radius, outermost-first. Default matches the
|
||||||
|
deployed Pi Zero W fallback radar.
|
||||||
|
phase: 0..1 maps to 0..2π sweep angle (clockwise from 12 o'clock).
|
||||||
|
ring_width: stroke width for ring backgrounds and tube arcs.
|
||||||
|
hub_radius: radius of the dark centre disc.
|
||||||
|
draw_hub: when False, callers can composite their own centre
|
||||||
|
(e.g. the converged dashboard's central button + pod cluster).
|
||||||
|
"""
|
||||||
|
if radii is None:
|
||||||
|
radii = DEFAULT_RADII
|
||||||
|
if name_to_angle is None:
|
||||||
|
name_to_angle = DEFAULT_NAME_TO_ANGLE
|
||||||
|
modules = list(modules)
|
||||||
|
if len(modules) != len(radii):
|
||||||
|
raise ValueError(
|
||||||
|
f"modules ({len(modules)}) and radii ({len(radii)}) length mismatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
cx, cy = center
|
||||||
|
sweep_rad = (phase % 1.0) * 2.0 * math.pi
|
||||||
|
|
||||||
|
_paint_ring_backgrounds(draw, cx, cy, radii, ring_width)
|
||||||
|
_paint_tube_arcs(draw, cx, cy, modules, metrics, radii, ring_width,
|
||||||
|
name_to_angle)
|
||||||
|
_paint_sweep(draw, cx, cy, modules, metrics, radii, sweep_rad)
|
||||||
|
_paint_sweep_head(draw, cx, cy, modules[0].colour, radii[0], sweep_rad)
|
||||||
|
if draw_hub:
|
||||||
|
draw.ellipse(
|
||||||
|
(cx - hub_radius, cy - hub_radius, cx + hub_radius, cy + hub_radius),
|
||||||
|
fill=(12, 12, 22, 255),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_ring_backgrounds(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
radii: list[int],
|
||||||
|
ring_width: int,
|
||||||
|
) -> None:
|
||||||
|
for r in radii:
|
||||||
|
draw.ellipse(
|
||||||
|
(cx - r, cy - r, cx + r, cy + r),
|
||||||
|
outline=(20, 20, 28, 255),
|
||||||
|
width=ring_width,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_tube_arcs(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
modules: list[Module],
|
||||||
|
metrics: dict,
|
||||||
|
radii: list[int],
|
||||||
|
ring_width: int,
|
||||||
|
name_to_angle: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
# PIL arc degrees: 0=3 o'clock, counter-clockwise. Each module's arc
|
||||||
|
# sits at the wheel position matching its colour, looked up by name
|
||||||
|
# — independent of caller's iteration order.
|
||||||
|
for m, r in zip(modules, radii):
|
||||||
|
pct = m.extract(metrics)
|
||||||
|
if pct <= 0.0:
|
||||||
|
continue
|
||||||
|
# Fall back to evenly-spaced positions if name unknown — keeps the
|
||||||
|
# painter robust against custom module sets without bringing in a
|
||||||
|
# hard dependency on the canonical six.
|
||||||
|
center_deg = name_to_angle.get(m.name, 0)
|
||||||
|
half = (pct * 360.0) / 2.0
|
||||||
|
start = center_deg + half
|
||||||
|
end = center_deg - half
|
||||||
|
|
||||||
|
# Outer dark edge (color × 1/3).
|
||||||
|
dark = (m.colour[0] // 3, m.colour[1] // 3, m.colour[2] // 3, 255)
|
||||||
|
draw.arc(
|
||||||
|
(cx - r - 2, cy - r - 2, cx + r + 2, cy + r + 2),
|
||||||
|
end, start, fill=dark, width=ring_width - 2,
|
||||||
|
)
|
||||||
|
# Main color band.
|
||||||
|
draw.arc(
|
||||||
|
(cx - r, cy - r, cx + r, cy + r),
|
||||||
|
end, start, fill=m.colour + (255,), width=ring_width - 6,
|
||||||
|
)
|
||||||
|
# Inner light tube highlight.
|
||||||
|
light = (
|
||||||
|
min(255, m.colour[0] + 80),
|
||||||
|
min(255, m.colour[1] + 80),
|
||||||
|
min(255, m.colour[2] + 80),
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
draw.arc(
|
||||||
|
(cx - r + 2, cy - r + 2, cx + r - 2, cy + r - 2),
|
||||||
|
end, start, fill=light, width=4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_sweep(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
modules: list[Module],
|
||||||
|
metrics: dict,
|
||||||
|
radii: list[int],
|
||||||
|
sweep_rad: float,
|
||||||
|
) -> None:
|
||||||
|
# Per-ring sweep segments coloured by that ring's metric.
|
||||||
|
max_r = radii[0] + 8
|
||||||
|
min_r = radii[-1] - 8
|
||||||
|
|
||||||
|
for idx, m in enumerate(modules):
|
||||||
|
r = radii[idx]
|
||||||
|
# Segment bounds — each ring owns the slab between its midpoint
|
||||||
|
# to the previous ring and its midpoint to the next ring.
|
||||||
|
r_outer = max_r if idx == 0 else (radii[idx - 1] + r) // 2
|
||||||
|
r_inner = min_r if idx == len(modules) - 1 else (r + radii[idx + 1]) // 2
|
||||||
|
|
||||||
|
pct = m.extract(metrics)
|
||||||
|
intensity = 0.5 + pct * 0.5
|
||||||
|
|
||||||
|
# Fading trail behind the sweep line.
|
||||||
|
for i in range(15):
|
||||||
|
offset = -0.15 * (i / 15.0)
|
||||||
|
a = sweep_rad + offset
|
||||||
|
fade = 1.0 - (i / 15.0)
|
||||||
|
x1 = cx + r_inner * math.sin(a)
|
||||||
|
y1 = cy - r_inner * math.cos(a)
|
||||||
|
x2 = cx + r_outer * math.sin(a)
|
||||||
|
y2 = cy - r_outer * math.cos(a)
|
||||||
|
seg = (
|
||||||
|
int(m.colour[0] * fade * intensity),
|
||||||
|
int(m.colour[1] * fade * intensity),
|
||||||
|
int(m.colour[2] * fade * intensity),
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
draw.line([(x1, y1), (x2, y2)], fill=seg, width=2)
|
||||||
|
|
||||||
|
# Main bright leading edge for this ring.
|
||||||
|
x1 = cx + r_inner * math.sin(sweep_rad)
|
||||||
|
y1 = cy - r_inner * math.cos(sweep_rad)
|
||||||
|
x2 = cx + r_outer * math.sin(sweep_rad)
|
||||||
|
y2 = cy - r_outer * math.cos(sweep_rad)
|
||||||
|
bright = (
|
||||||
|
min(255, m.colour[0] + 60),
|
||||||
|
min(255, m.colour[1] + 60),
|
||||||
|
min(255, m.colour[2] + 60),
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
draw.line([(x1, y1), (x2, y2)], fill=bright, width=3)
|
||||||
|
|
||||||
|
|
||||||
|
def _paint_sweep_head(
|
||||||
|
draw: ImageDraw.ImageDraw,
|
||||||
|
cx: int,
|
||||||
|
cy: int,
|
||||||
|
head_color: tuple[int, int, int],
|
||||||
|
outer_r: int,
|
||||||
|
sweep_rad: float,
|
||||||
|
) -> None:
|
||||||
|
hx = cx + outer_r * math.sin(sweep_rad)
|
||||||
|
hy = cy - outer_r * math.cos(sweep_rad)
|
||||||
|
draw.ellipse(
|
||||||
|
(hx - 4, hy - 4, hx + 4, hy + 4),
|
||||||
|
fill=head_color + (255,),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience for callers that want a quick HSV-rotating accent colour
|
||||||
|
# matching the current sweep position (used by the original radar variant
|
||||||
|
# but kept here for downstream painters that want it).
|
||||||
|
def sweep_accent(phase: float) -> tuple[int, int, int]:
|
||||||
|
hue = phase % 1.0
|
||||||
|
r, g, b = colorsys.hsv_to_rgb(hue, 0.9, 0.8)
|
||||||
|
return (int(r * 255), int(g * 255), int(b * 255))
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||||
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
||||||
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
||||||
|
# See LICENCE-CMSD-1.0.md for terms.
|
||||||
|
"""Tests for secubox_common.painters.radar_concentric."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from secubox_common.modules import MODULES
|
||||||
|
from secubox_common.painters import radar_concentric
|
||||||
|
|
||||||
|
|
||||||
|
METRICS = {
|
||||||
|
"cpu_percent": 60.0,
|
||||||
|
"mem_percent": 45.0,
|
||||||
|
"disk_percent": 30.0,
|
||||||
|
"load_avg_1": 0.8,
|
||||||
|
"cpu_temp": 55.0,
|
||||||
|
"wifi_rssi": -55,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frame() -> Image.Image:
|
||||||
|
return Image.new("RGBA", (480, 480), (0, 0, 0, 255))
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_modifies_canvas():
|
||||||
|
"""Painting at any phase must change the canvas from solid black."""
|
||||||
|
img = _frame()
|
||||||
|
radar_concentric.paint(img, (240, 240), MODULES, METRICS, phase=0.0)
|
||||||
|
# At least one pixel outside the inner hub must have changed.
|
||||||
|
sample = img.getpixel((240, 30)) # near outer ring
|
||||||
|
assert sample != (0, 0, 0, 255), \
|
||||||
|
"outer ring area should have radar pixels painted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_differs_across_phases():
|
||||||
|
"""phase=0.0 and phase=0.5 must produce different sweep positions."""
|
||||||
|
a = _frame()
|
||||||
|
b = _frame()
|
||||||
|
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
|
||||||
|
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=0.5)
|
||||||
|
assert a.tobytes() != b.tobytes(), \
|
||||||
|
"frames at phase 0.0 vs 0.5 should differ (sweep rotated 180°)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_wraps_at_phase_one():
|
||||||
|
"""phase=0.0 and phase=1.0 produce the same frame (period = 1)."""
|
||||||
|
a = _frame()
|
||||||
|
b = _frame()
|
||||||
|
radar_concentric.paint(a, (240, 240), MODULES, METRICS, phase=0.0)
|
||||||
|
radar_concentric.paint(b, (240, 240), MODULES, METRICS, phase=1.0)
|
||||||
|
assert a.tobytes() == b.tobytes(), \
|
||||||
|
"phase=1.0 must wrap back to phase=0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_uses_default_radii_when_none():
|
||||||
|
"""radii=None must fall back to DEFAULT_RADII (6 entries)."""
|
||||||
|
img = _frame()
|
||||||
|
# 6 modules + 6 default radii must match.
|
||||||
|
radar_concentric.paint(img, (240, 240), MODULES, METRICS, radii=None)
|
||||||
|
sample = img.getpixel((240, 30))
|
||||||
|
assert sample != (0, 0, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_rejects_mismatched_radii():
|
||||||
|
"""A radii list of wrong length must raise."""
|
||||||
|
img = _frame()
|
||||||
|
with pytest.raises(ValueError, match="length mismatch"):
|
||||||
|
radar_concentric.paint(
|
||||||
|
img, (240, 240), MODULES, METRICS, radii=[200, 180, 160]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_paint_hub_toggle():
|
||||||
|
"""draw_hub=False must leave the centre area unfilled by the painter
|
||||||
|
(so callers can composite their own central button + icons)."""
|
||||||
|
a = _frame()
|
||||||
|
b = _frame()
|
||||||
|
radar_concentric.paint(a, (240, 240), MODULES, METRICS,
|
||||||
|
phase=0.0, draw_hub=True)
|
||||||
|
radar_concentric.paint(b, (240, 240), MODULES, METRICS,
|
||||||
|
phase=0.0, draw_hub=False)
|
||||||
|
# Sample a point well inside the hub (radius ~85). With draw_hub=True
|
||||||
|
# the colour is the hub fill (12, 12, 22, 255). With False it remains
|
||||||
|
# the canvas background.
|
||||||
|
assert a.getpixel((240, 240)) == (12, 12, 22, 255)
|
||||||
|
assert b.getpixel((240, 240)) == (0, 0, 0, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_accent_in_rgb_range():
|
||||||
|
"""sweep_accent returns valid RGB tuples for any phase."""
|
||||||
|
for p in [0.0, 0.25, 0.5, 0.75, 0.999]:
|
||||||
|
r, g, b = radar_concentric.sweep_accent(p)
|
||||||
|
for chan in (r, g, b):
|
||||||
|
assert 0 <= chan <= 255
|
||||||
|
|
||||||
|
|
||||||
|
def test_canvas_method_delegates():
|
||||||
|
"""DashboardCanvas.paint_radar_concentric must produce the same frame
|
||||||
|
as calling the painter directly — proves the canvas helper is a
|
||||||
|
thin wrapper, not divergent code."""
|
||||||
|
from secubox_common.canvas import DashboardCanvas
|
||||||
|
|
||||||
|
direct = _frame()
|
||||||
|
via_canvas = _frame()
|
||||||
|
radar_concentric.paint(direct, (240, 240), MODULES, METRICS, phase=0.25)
|
||||||
|
DashboardCanvas().paint_radar_concentric(
|
||||||
|
via_canvas, (240, 240), MODULES, METRICS, phase=0.25,
|
||||||
|
)
|
||||||
|
assert direct.tobytes() == via_canvas.tobytes(), \
|
||||||
|
"canvas helper should delegate verbatim to painters.radar_concentric.paint"
|
||||||
|
|
@ -15,22 +15,32 @@ from secubox_common.modules import MODULES
|
||||||
class RoundDashboard(DashboardCanvas):
|
class RoundDashboard(DashboardCanvas):
|
||||||
SIZE = (480, 480)
|
SIZE = (480, 480)
|
||||||
CENTER = (240, 240)
|
CENTER = (240, 240)
|
||||||
RING_RADII = [200, 185, 170, 155, 140, 125]
|
# Match the deployed fallback_manager.py radar geometry so the round
|
||||||
|
# rendering stays visually identical after migration.
|
||||||
|
RING_RADII = [214, 188, 162, 136, 110, 84]
|
||||||
|
|
||||||
def layout(self, metrics: dict) -> Image.Image:
|
def layout(self, metrics: dict, phase: float = 0.0) -> Image.Image:
|
||||||
|
"""Render one frame at the given animation `phase` (0..1).
|
||||||
|
|
||||||
|
Phase advances the rotating sweep; callers pass `phase=0.0` for
|
||||||
|
a static still frame, or `(time.monotonic() * rpm / 60) % 1` for
|
||||||
|
an animated loop.
|
||||||
|
"""
|
||||||
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
img = Image.new("RGBA", self.SIZE, theme.COSMOS_BLACK + (255,))
|
||||||
self.paint_rainbow_ring(img, self.CENTER, 235, 220)
|
self.paint_radar_concentric(
|
||||||
self.paint_concentric_arcs(img, self.CENTER, MODULES, metrics,
|
img, self.CENTER, MODULES, metrics,
|
||||||
self.RING_RADII)
|
radii=self.RING_RADII, phase=phase, draw_hub=True,
|
||||||
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=70, pod_size=40)
|
)
|
||||||
|
# Pods sit on top of the hub; pod_size=48 matches deployed icon
|
||||||
|
# sizes (22/48/96/128). radius=78 keeps the pod inner edge (54)
|
||||||
|
# clear of the central button (44).
|
||||||
|
self.paint_pod_cluster(img, MODULES, self.CENTER, radius=78, pod_size=48)
|
||||||
self.paint_central_button(img, self.CENTER, size=44)
|
self.paint_central_button(img, self.CENTER, size=44)
|
||||||
return img
|
return img
|
||||||
|
|
||||||
# Round-only additional view modes (called by fb_dashboard.py's main
|
# Round-only additional view modes (called by fb_dashboard.py's main
|
||||||
# loop when the user long-presses center → radial menu → terminal/flash/auth).
|
# loop when the user long-presses center → radial menu → terminal/flash/auth).
|
||||||
def layout_terminal(self, term_state) -> Image.Image:
|
def layout_terminal(self, term_state) -> Image.Image:
|
||||||
# Delegates to the existing draw_terminal() helper for now;
|
|
||||||
# extracted into a method to give the main loop a class-based API.
|
|
||||||
from fb_dashboard import draw_terminal
|
from fb_dashboard import draw_terminal
|
||||||
return draw_terminal(term_state)
|
return draw_terminal(term_state)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,17 @@ log "Installing config files (systemd, udev, apparmor, firstboot)..."
|
||||||
cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/"
|
cp -r "$REPO_ROOT/remote-ui/square/files/." "$ROOT_MNT/"
|
||||||
chmod +x "$ROOT_MNT/usr/local/sbin/firstboot.sh"
|
chmod +x "$ROOT_MNT/usr/local/sbin/firstboot.sh"
|
||||||
|
|
||||||
|
# Install the shared OTG gadget composer (round does this at line 618 of
|
||||||
|
# build-eye-remote-image.sh). secubox-otg-gadget.service ExecStarts this path;
|
||||||
|
# without it the gadget never composes and the Pi 4B's USB-C bus stays silent
|
||||||
|
# → MOCHAbin/host enumeration fails (no descriptor events, xhci timeouts).
|
||||||
|
log "Installing OTG gadget composer at /usr/local/sbin/secubox-otg-gadget.sh..."
|
||||||
|
cp "$REPO_ROOT/remote-ui/common/shell/secubox-otg-gadget.sh" \
|
||||||
|
"$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||||
|
chmod +x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh"
|
||||||
|
test -x "$ROOT_MNT/usr/local/sbin/secubox-otg-gadget.sh" || \
|
||||||
|
{ err "secubox-otg-gadget.sh not executable on rootfs"; exit 2; }
|
||||||
|
|
||||||
# Ship the shared secubox_common package.
|
# Ship the shared secubox_common package.
|
||||||
log "Embedding remote-ui/common/python at /var/www/common/python/..."
|
log "Embedding remote-ui/common/python at /var/www/common/python/..."
|
||||||
mkdir -p "$ROOT_MNT/var/www/common/python"
|
mkdir -p "$ROOT_MNT/var/www/common/python"
|
||||||
|
|
@ -110,6 +121,17 @@ cp -r "$REPO_ROOT/remote-ui/common/python/." "$ROOT_MNT/var/www/common/python/"
|
||||||
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
|
test -d "$ROOT_MNT/var/www/common/python/secubox_common" || \
|
||||||
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
|
{ err "secubox_common not in /var/www/common/python — common/ source incomplete"; exit 2; }
|
||||||
|
|
||||||
|
# Ship the shared icon assets — secubox_common.icons.load_module_icon
|
||||||
|
# resolves at /var/www/common/assets/icons/ first. Without these the pod
|
||||||
|
# cluster falls back to first-letter placeholders.
|
||||||
|
log "Embedding remote-ui/common/assets at /var/www/common/assets/..."
|
||||||
|
mkdir -p "$ROOT_MNT/var/www/common/assets"
|
||||||
|
cp -r "$REPO_ROOT/remote-ui/common/assets/." "$ROOT_MNT/var/www/common/assets/"
|
||||||
|
ICON_COUNT=$(ls "$ROOT_MNT/var/www/common/assets/icons"/*-48.png 2>/dev/null | wc -l)
|
||||||
|
test "$ICON_COUNT" -gt 0 || \
|
||||||
|
{ err "no *-48.png icons in /var/www/common/assets/icons — common/ assets incomplete"; exit 2; }
|
||||||
|
log " → $ICON_COUNT module icons at size 48 shipped"
|
||||||
|
|
||||||
log "Installing Python packages..."
|
log "Installing Python packages..."
|
||||||
mkdir -p "$ROOT_MNT/usr/lib/python3/dist-packages"
|
mkdir -p "$ROOT_MNT/usr/lib/python3/dist-packages"
|
||||||
cp -r "$REPO_ROOT/packages/secubox-eye-square/helper/eye_square_helper" \
|
cp -r "$REPO_ROOT/packages/secubox-eye-square/helper/eye_square_helper" \
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user