初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,2 @@
"""Display abstraction and drivers."""
@@ -0,0 +1,43 @@
from __future__ import annotations
import re
from typing import Any
RGB = tuple[int, int, int]
HEX_RE = re.compile(r"^#([0-9a-fA-F]{6})$")
def parse_color(value: Any) -> RGB:
if isinstance(value, str):
match = HEX_RE.match(value.strip())
if not match:
raise ValueError("color must be #RRGGBB")
raw = match.group(1)
return int(raw[0:2], 16), int(raw[2:4], 16), int(raw[4:6], 16)
if isinstance(value, dict):
try:
return validate_rgb((value["r"], value["g"], value["b"]))
except KeyError as exc:
raise ValueError("color object must contain r, g, and b") from exc
if isinstance(value, (list, tuple)) and len(value) == 3:
return validate_rgb(value)
raise ValueError("color must be #RRGGBB or {r,g,b}")
def validate_rgb(value: Any) -> RGB:
try:
channels = tuple(int(channel) for channel in value)
except Exception as exc:
raise ValueError("RGB channels must be integers") from exc
if len(channels) != 3:
raise ValueError("RGB color must have three channels")
if any(channel < 0 or channel > 255 for channel in channels):
raise ValueError("RGB channels must be in 0..255")
return channels # type: ignore[return-value]
@@ -0,0 +1,133 @@
from __future__ import annotations
from PIL import Image, ImageDraw
from .text_renderer import TextOptions, render_text
DIAGNOSTIC_MODES = {
"corners_lines",
"row_bands",
"address_check",
"address_bit_0",
"address_bit_1",
"address_bit_2",
"address_bit_3",
"address_bit_4",
"text_ok123",
"clear",
}
def render_diagnostic(mode: str, size: tuple[int, int] = (64, 64)) -> Image.Image:
if mode not in DIAGNOSTIC_MODES:
allowed = ", ".join(sorted(DIAGNOSTIC_MODES))
raise ValueError(f"diagnostic mode must be one of: {allowed}")
if mode == "clear":
return Image.new("RGB", size, (0, 0, 0))
if mode == "corners_lines":
return _render_corners_lines(size)
if mode == "row_bands":
return _render_row_bands(size)
if mode == "address_check":
return _render_address_check(size)
if mode.startswith("address_bit_"):
return _render_address_bit(int(mode.rsplit("_", 1)[1]), size)
return render_text(TextOptions(text="OK123", size=12, x=4, y=24), size=size)
def _render_corners_lines(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
for y, color in ((0, (80, 80, 80)), (31, (255, 180, 0)), (32, (0, 180, 255)), (63, (80, 80, 80))):
if y < height:
draw.line((0, y, width - 1, y), fill=color)
for x in (0, width - 1):
draw.line((x, 0, x, height - 1), fill=(80, 80, 80))
marker = 4
draw.rectangle((0, 0, marker, marker), fill=(255, 0, 0))
draw.rectangle((width - marker - 1, 0, width - 1, marker), fill=(0, 255, 0))
draw.rectangle((0, height - marker - 1, marker, height - 1), fill=(0, 0, 255))
draw.rectangle((width - marker - 1, height - marker - 1, width - 1, height - 1), fill=(255, 255, 255))
return image
def _render_row_bands(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
colors = (
(255, 0, 0),
(255, 120, 0),
(255, 255, 0),
(0, 255, 0),
(0, 255, 255),
(0, 0, 255),
(180, 0, 255),
(255, 255, 255),
)
band_height = max(1, height // len(colors))
for index, color in enumerate(colors):
y0 = index * band_height
y1 = height - 1 if index == len(colors) - 1 else min(height - 1, y0 + band_height - 1)
draw.rectangle((0, y0, width - 1, y1), fill=color)
draw.line((0, y0, width - 1, y0), fill=(0, 0, 0))
return image
def _render_address_check(size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
bit_colors = (
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
)
segment_width = max(1, width // len(bit_colors))
for y in range(height):
for bit, color in enumerate(bit_colors):
if y & (1 << bit):
x0 = bit * segment_width
x1 = width - 1 if bit == len(bit_colors) - 1 else min(width - 1, x0 + segment_width - 2)
draw.line((x0, y, x1, y), fill=color)
diagonal_x = min(width - 1, y)
draw.point((diagonal_x, y), fill=(255, 255, 255))
draw.point((width - 1 - diagonal_x, y), fill=(80, 80, 80))
for y in (0, 31, 32, 63):
if y < height:
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
return image
def _render_address_bit(bit: int, size: tuple[int, int]) -> Image.Image:
width, height = size
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
colors = (
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(255, 0, 255),
)
color = colors[bit]
for y in range(height):
if y & (1 << bit):
draw.line((0, y, width - 1, y), fill=color)
for y in (0, 31, 32, 63):
if y < height:
draw.line((0, y, width - 1, y), fill=(255, 255, 255))
return image
@@ -0,0 +1,24 @@
from __future__ import annotations
from PIL import Image, ImageDraw
BACKGROUND = (0, 0, 0)
RED = (255, 0, 0)
def render_low_voltage_indicator() -> Image.Image:
"""Render the fixed 64x64 critical-battery indicator."""
image = Image.new("RGB", (64, 64), BACKGROUND)
draw = ImageDraw.Draw(image)
# A thick, empty battery outline. The terminal deliberately touches the
# body so it remains legible after the existing orientation transform.
draw.rectangle((9, 17, 49, 21), fill=RED)
draw.rectangle((9, 42, 49, 46), fill=RED)
draw.rectangle((9, 17, 13, 46), fill=RED)
draw.rectangle((45, 17, 49, 46), fill=RED)
draw.rectangle((50, 27, 56, 36), fill=RED)
# Keep the interior otherwise empty, with one short low-charge bar.
draw.rectangle((17, 34, 22, 40), fill=RED)
return image
@@ -0,0 +1,297 @@
from __future__ import annotations
import ctypes
import logging
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
NATIVE_LIBRARY = Path(__file__).with_name("native") / "libh618_hub75.so"
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
@dataclass(frozen=True)
class MatrixDriverOptions:
rows: int = 64
cols: int = 64
scan_rows: int = 32
row_address_bits: int = 5
pwm_bits: int = 7
hardware_mapping: str = HARDWARE_MAPPING
brightness: int = 40
limit_refresh_rate_hz: int = 100
pio_base: str = "0x0300B000"
pi_bank_offset: str = "0x120"
data_offset: str = "0x10"
cpu_affinity: int = 3
realtime_priority: int = 50
def to_dict(self) -> dict:
return asdict(self)
def get_default_driver_options(
brightness: int,
limit_refresh_rate_hz: int = 100,
) -> MatrixDriverOptions:
options = MatrixDriverOptions(
brightness=brightness,
limit_refresh_rate_hz=limit_refresh_rate_hz,
)
validate_driver_options(options)
return options
def get_matrix_driver_options(
brightness: int,
limit_refresh_rate_hz: int = 100,
) -> MatrixDriverOptions:
# The panel geometry, GPIO mapping and PWM depth are hardware contracts,
# not environment-variable tuning knobs. Keeping this function preserves
# the DisplayService boundary while making every deployment deterministic.
return get_default_driver_options(brightness, limit_refresh_rate_hz)
def validate_driver_options(options: MatrixDriverOptions) -> None:
if type(options.brightness) is not int or not 1 <= options.brightness <= 100:
raise ValueError("brightness must be in 1..100")
if options.limit_refresh_rate_hz not in REFRESH_RATE_LIMITS:
raise ValueError(
"limit_refresh_rate_hz must be one of 15, 20, 30, 45, 60, 80, or 100"
)
fixed = {
"rows": (options.rows, 64),
"cols": (options.cols, 64),
"scan_rows": (options.scan_rows, 32),
"row_address_bits": (options.row_address_bits, 5),
"pwm_bits": (options.pwm_bits, 7),
"hardware_mapping": (options.hardware_mapping, HARDWARE_MAPPING),
}
for name, (actual, expected) in fixed.items():
if actual != expected:
raise ValueError(f"{name} is fixed at {expected!r} for this hardware")
class _NativeConfig(ctypes.Structure):
_fields_ = [
("brightness", ctypes.c_int),
("refresh_rate_hz", ctypes.c_int),
("cpu_affinity", ctypes.c_int),
("realtime_priority", ctypes.c_int),
("use_dev_mem", ctypes.c_int),
("fake_pwm5_mode", ctypes.c_int),
("fake_pwm4_stuck", ctypes.c_int),
]
class _NativeStats(ctypes.Structure):
_fields_ = [
("actual_refresh_rate_hz", ctypes.c_double),
("panel_scan_rate_hz", ctypes.c_double),
("completed_frames", ctypes.c_uint64),
("completed_scans", ctypes.c_uint64),
("deadline_misses", ctypes.c_uint64),
("buffer_swaps", ctypes.c_uint64),
("submitted_frames", ctypes.c_uint64),
("oe_pulse_faults", ctypes.c_uint64),
("oe_forced_blanks", ctypes.c_uint64),
("max_programmed_oe_ns", ctypes.c_uint64),
("cpu_affinity", ctypes.c_int),
("cpu_affinity_active", ctypes.c_int),
("realtime_priority", ctypes.c_int),
("realtime_priority_active", ctypes.c_int),
("memory_locked", ctypes.c_int),
("running", ctypes.c_int),
("brightness", ctypes.c_int),
("refresh_rate_hz", ctypes.c_int),
("scans_per_frame", ctypes.c_int),
("governor", ctypes.c_char * 32),
("affinity_error", ctypes.c_char * 128),
("realtime_error", ctypes.c_char * 128),
("memory_lock_error", ctypes.c_char * 128),
("oe_timing_backend", ctypes.c_char * 32),
("oe_timing_error", ctypes.c_char * 256),
("safeoff_error", ctypes.c_char * 256),
("last_error", ctypes.c_char * 256),
]
def _decode_c_string(value: bytes | ctypes.Array) -> str | None:
raw = bytes(value).split(b"\0", 1)[0]
return raw.decode("utf-8", errors="replace") or None
def _load_native_library() -> ctypes.CDLL:
if not NATIVE_LIBRARY.is_file():
raise RuntimeError(
f"WalnutPi native HUB75 library is missing: {NATIVE_LIBRARY}; "
"run app/display/native/Makefile on the AArch64 target"
)
library = ctypes.CDLL(str(NATIVE_LIBRARY))
library.hub75_hardware_mapping.argtypes = []
library.hub75_hardware_mapping.restype = ctypes.c_char_p
native_mapping = library.hub75_hardware_mapping()
if native_mapping is None or native_mapping.decode("ascii", errors="replace") != HARDWARE_MAPPING:
raise RuntimeError(
"WalnutPi native HUB75 hardware mapping ABI mismatch; output remains blank"
)
library.hub75_create.argtypes = [
ctypes.POINTER(_NativeConfig),
ctypes.c_char_p,
ctypes.c_size_t,
]
library.hub75_create.restype = ctypes.c_void_p
library.hub75_start.argtypes = [ctypes.c_void_p]
library.hub75_start.restype = ctypes.c_int
library.hub75_submit_rgb.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t]
library.hub75_submit_rgb.restype = ctypes.c_int
library.hub75_set_brightness.argtypes = [ctypes.c_void_p, ctypes.c_int]
library.hub75_set_brightness.restype = ctypes.c_int
library.hub75_set_refresh_rate.argtypes = [ctypes.c_void_p, ctypes.c_int]
library.hub75_set_refresh_rate.restype = ctypes.c_int
library.hub75_clear.argtypes = [ctypes.c_void_p]
library.hub75_clear.restype = ctypes.c_int
library.hub75_get_stats.argtypes = [ctypes.c_void_p, ctypes.POINTER(_NativeStats)]
library.hub75_get_stats.restype = ctypes.c_int
library.hub75_destroy.argtypes = [ctypes.c_void_p]
library.hub75_destroy.restype = None
return library
class MatrixDisplayDriver:
name = "walnutpi-h618-hub75"
hardware_mapping = HARDWARE_MAPPING
def __init__(self, brightness: int, limit_refresh_rate_hz: int = 100) -> None:
self.driver_options = get_matrix_driver_options(
brightness,
limit_refresh_rate_hz,
)
self._library = _load_native_library()
self._context: int | None = None
error = ctypes.create_string_buffer(512)
config = _NativeConfig(
brightness=self.driver_options.brightness,
refresh_rate_hz=self.driver_options.limit_refresh_rate_hz,
cpu_affinity=self.driver_options.cpu_affinity,
realtime_priority=self.driver_options.realtime_priority,
use_dev_mem=1,
fake_pwm5_mode=0,
fake_pwm4_stuck=0,
)
context = self._library.hub75_create(
ctypes.byref(config),
error,
ctypes.sizeof(error),
)
if not context:
reason = _decode_c_string(error) or "unknown native initialization error"
raise RuntimeError(f"WalnutPi native HUB75 initialization failed: {reason}")
self._context = context
if self._library.hub75_start(context) != 0:
reason = self._stats_error() or "native refresh thread failed to start"
self.close()
raise RuntimeError(reason)
self.available = True
self.brightness = brightness
logger.info(
"Initialized %s with options: %s",
self.name,
self.driver_options.to_dict(),
)
def _require_context(self) -> int:
if self._context is None:
raise RuntimeError("WalnutPi native HUB75 driver is closed")
return self._context
def _stats(self) -> _NativeStats:
stats = _NativeStats()
if self._library.hub75_get_stats(self._require_context(), ctypes.byref(stats)) != 0:
raise RuntimeError("Failed to read WalnutPi native HUB75 status")
return stats
def _stats_error(self) -> str | None:
try:
return _decode_c_string(self._stats().last_error)
except Exception:
return None
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
if image.mode != "RGB" or image.size != (64, 64):
raise ValueError("native HUB75 frame must be a 64x64 RGB image")
frame = image.tobytes()
buffer = (ctypes.c_uint8 * len(frame)).from_buffer_copy(frame)
if self._library.hub75_submit_rgb(
self._require_context(),
ctypes.cast(buffer, ctypes.c_void_p),
len(frame),
) != 0:
raise RuntimeError(self._stats_error() or "Native RGB frame submission failed")
def clear(self) -> None:
if self._library.hub75_clear(self._require_context()) != 0:
raise RuntimeError(self._stats_error() or "Native display clear failed")
def set_brightness(self, value: int) -> None:
if type(value) is not int or not 1 <= value <= 100:
raise ValueError("brightness must be in 1..100")
if self._library.hub75_set_brightness(self._require_context(), value) != 0:
raise RuntimeError(self._stats_error() or "Runtime brightness update failed")
self.brightness = value
self.driver_options = replace(self.driver_options, brightness=value)
def set_refresh_rate_limit(self, value: int) -> None:
if value not in REFRESH_RATE_LIMITS:
raise ValueError("unsupported refresh-rate limit")
if self._library.hub75_set_refresh_rate(self._require_context(), value) != 0:
raise RuntimeError(self._stats_error() or "Runtime refresh-rate update failed")
self.driver_options = replace(self.driver_options, limit_refresh_rate_hz=value)
def close(self) -> None:
context = self._context
if context is None:
return
self._context = None
self._library.hub75_destroy(context)
def get_status(self) -> dict:
stats = self._stats()
return {
"driver": self.name,
"driver_available": True,
"driver_error": _decode_c_string(stats.last_error),
"hardware_mapping": self.hardware_mapping,
"driver_options": self.driver_options.to_dict(),
"driver_status": {
"actual_refresh_rate_hz": round(stats.actual_refresh_rate_hz, 3),
"panel_scan_rate_hz": round(stats.panel_scan_rate_hz, 3),
"completed_frames": stats.completed_frames,
"completed_scans": stats.completed_scans,
"scans_per_frame": stats.scans_per_frame,
"deadline_misses": stats.deadline_misses,
"buffer_swaps": stats.buffer_swaps,
"submitted_frames": stats.submitted_frames,
"oe_timing_backend": _decode_c_string(stats.oe_timing_backend),
"oe_pulse_faults": stats.oe_pulse_faults,
"oe_forced_blanks": stats.oe_forced_blanks,
"max_programmed_oe_ns": stats.max_programmed_oe_ns,
"oe_timing_error": _decode_c_string(stats.oe_timing_error),
"safeoff_error": _decode_c_string(stats.safeoff_error),
"cpu_affinity": stats.cpu_affinity,
"cpu_affinity_active": bool(stats.cpu_affinity_active),
"cpu_affinity_error": _decode_c_string(stats.affinity_error),
"realtime_priority": stats.realtime_priority,
"realtime_priority_active": bool(stats.realtime_priority_active),
"realtime_priority_error": _decode_c_string(stats.realtime_error),
"memory_locked": bool(stats.memory_locked),
"memory_lock_error": _decode_c_string(stats.memory_lock_error),
"running": bool(stats.running),
"governor": _decode_c_string(stats.governor),
"last_error": _decode_c_string(stats.last_error),
},
}
@@ -0,0 +1,97 @@
from __future__ import annotations
import logging
from dataclasses import replace
from pathlib import Path
from PIL import Image
from .matrix_driver import MatrixDriverOptions, get_default_driver_options
logger = logging.getLogger(__name__)
class MockDisplayDriver:
name = "mock"
hardware_mapping = "walnutpi-pi-bank-pwm-oe-v2"
def __init__(
self,
data_dir: Path,
brightness: int = 40,
limit_refresh_rate_hz: int = 100,
reason: str | None = None,
driver_options: MatrixDriverOptions | None = None,
) -> None:
self.data_dir = data_dir
self.reason = reason
self.available = reason is None
self.last_frame_path = self.data_dir / "last_frame.png"
self.brightness = brightness
self.driver_options = driver_options or get_default_driver_options(
brightness,
limit_refresh_rate_hz,
)
self.data_dir.mkdir(parents=True, exist_ok=True)
if reason:
logger.warning("Using mock display driver: %s", reason)
else:
logger.info("Using mock display driver")
def set_image(self, image: Image.Image, *, synchronized: bool = False) -> None:
image.save(self.last_frame_path)
logger.debug("Saved mock frame to %s", self.last_frame_path)
def clear(self) -> None:
if self.last_frame_path.exists():
Image.new("RGB", (64, 64), (0, 0, 0)).save(self.last_frame_path)
def set_brightness(self, value: int) -> None:
self.brightness = value
self.driver_options = replace(self.driver_options, brightness=value)
def set_refresh_rate_limit(self, value: int) -> None:
self.driver_options = replace(
self.driver_options,
limit_refresh_rate_hz=value,
)
def close(self) -> None:
self.clear()
def get_status(self) -> dict:
return {
"driver": self.name,
"driver_available": self.available,
"driver_error": self.reason,
"hardware_mapping": self.driver_options.hardware_mapping,
"driver_options": self.driver_options.to_dict(),
"driver_status": {
"actual_refresh_rate_hz": None,
"panel_scan_rate_hz": None,
"completed_frames": None,
"completed_scans": None,
"scans_per_frame": None,
"deadline_misses": None,
"buffer_swaps": None,
"submitted_frames": None,
"oe_timing_backend": None,
"oe_pulse_faults": None,
"oe_forced_blanks": None,
"max_programmed_oe_ns": None,
"oe_timing_error": None,
"safeoff_error": None,
"cpu_affinity": None,
"cpu_affinity_active": False,
"cpu_affinity_error": None,
"realtime_priority": None,
"realtime_priority_active": False,
"realtime_priority_error": None,
"memory_locked": False,
"memory_lock_error": None,
"running": False,
"governor": None,
"last_error": self.reason,
},
"last_frame": str(self.last_frame_path),
}
@@ -0,0 +1,31 @@
CC ?= gcc
CFLAGS ?= -O3 -std=c11 -Wall -Wextra -Werror -fPIC -pthread
LDFLAGS ?= -pthread
LIBRARY := libh618_hub75.so
BENCHMARK := hub75_benchmark
UNIT_TEST := hub75_native_test
SAFEOFF := hub75_safeoff
.PHONY: all clean test
all: $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
$(LIBRARY): h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) -shared h618_hub75.c -o $@ $(LDFLAGS)
$(BENCHMARK): benchmark.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) benchmark.c h618_hub75.c -o $@ $(LDFLAGS)
$(UNIT_TEST): native_test.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) native_test.c h618_hub75.c -o $@ $(LDFLAGS)
$(SAFEOFF): safeoff.c h618_hub75.c h618_hub75.h
$(CC) $(CFLAGS) safeoff.c h618_hub75.c -o $@ $(LDFLAGS)
test: $(UNIT_TEST) $(SAFEOFF)
./$(UNIT_TEST)
./$(SAFEOFF) --fake
clean:
rm -f -- $(LIBRARY) $(BENCHMARK) $(UNIT_TEST) $(SAFEOFF)
@@ -0,0 +1,30 @@
# Third-party notices
The H618 PIO register layout and WalnutPi 40-pin physical mapping used by
`h618_hub75.c` were derived from:
- Project: `walnutpi/gpioc`
- Source: https://github.com/walnutpi/gpioc
- Commit: `220a46267469cc9cb1cbc35cd72f35eb1699a6e8`
- License: MIT
- Copyright: Copyright (c) 2023 酥饼
## MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,188 @@
#define _POSIX_C_SOURCE 200809L
#include "h618_hub75.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
static double monotonic_seconds(void) {
struct timespec value;
clock_gettime(CLOCK_MONOTONIC, &value);
return (double)value.tv_sec + (double)value.tv_nsec / 1000000000.0;
}
static int integer_argument(const char *value, const char *name) {
char *end = NULL;
long parsed = strtol(value, &end, 10);
if (value[0] == '\0' || end == NULL || *end != '\0' || parsed < 0 || parsed > 1000000) {
fprintf(stderr, "invalid %s: %s\n", name, value);
exit(2);
}
return (int)parsed;
}
static int hex_nibble(char value) {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
if (value >= 'A' && value <= 'F') return value - 'A' + 10;
return -1;
}
static void color_argument(const char *value, uint8_t output[3]) {
if (strlen(value) != 6) {
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
exit(2);
}
for (unsigned channel = 0; channel < 3; ++channel) {
int high = hex_nibble(value[channel * 2]);
int low = hex_nibble(value[channel * 2 + 1]);
if (high < 0 || low < 0) {
fprintf(stderr, "invalid color: %s (expected RRGGBB)\n", value);
exit(2);
}
output[channel] = (uint8_t)((high << 4) | low);
}
}
int main(int argc, char **argv) {
int duration = 10;
int refresh_rate = 100;
int brightness = 40;
int use_dev_mem = 0;
int use_static_color = 0;
uint8_t static_color[3] = {0};
for (int index = 1; index < argc; ++index) {
if (strcmp(argv[index], "--duration") == 0 && index + 1 < argc) {
duration = integer_argument(argv[++index], "duration");
} else if (strcmp(argv[index], "--refresh-rate") == 0 && index + 1 < argc) {
refresh_rate = integer_argument(argv[++index], "refresh rate");
} else if (strcmp(argv[index], "--brightness") == 0 && index + 1 < argc) {
brightness = integer_argument(argv[++index], "brightness");
} else if (strcmp(argv[index], "--dev-mem") == 0) {
use_dev_mem = 1;
} else if (strcmp(argv[index], "--color") == 0 && index + 1 < argc) {
color_argument(argv[++index], static_color);
use_static_color = 1;
} else {
fprintf(stderr, "usage: %s [--duration N] [--refresh-rate N] [--brightness N] [--color RRGGBB] [--dev-mem]\n", argv[0]);
return 2;
}
}
if (duration < 1) {
fputs("duration must be at least one second\n", stderr);
return 2;
}
hub75_config config = {
.brightness = brightness,
.refresh_rate_hz = refresh_rate,
.cpu_affinity = 3,
.realtime_priority = 50,
.use_dev_mem = use_dev_mem,
};
char error[512] = "";
hub75_context *context = hub75_create(&config, error, sizeof(error));
if (context == NULL) {
fprintf(stderr, "create failed: %s\n", error);
return 1;
}
if (hub75_start(context) != 0) {
fputs("start failed\n", stderr);
hub75_destroy(context);
return 1;
}
uint8_t frame[HUB75_FRAME_BYTES];
if (use_static_color) {
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
memcpy(frame + pixel * 3u, static_color, sizeof(static_color));
}
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
fputs("initial static submit failed\n", stderr);
hub75_destroy(context);
return 1;
}
}
double started = monotonic_seconds();
unsigned submitted = 0;
while (monotonic_seconds() - started < duration) {
if (!use_static_color) {
for (unsigned pixel = 0; pixel < HUB75_WIDTH * HUB75_HEIGHT; ++pixel) {
frame[pixel * 3] = (uint8_t)(pixel + submitted);
frame[pixel * 3 + 1] = (uint8_t)(pixel * 3u + submitted);
frame[pixel * 3 + 2] = (uint8_t)(pixel * 7u + submitted);
}
if (hub75_submit_rgb(context, frame, sizeof(frame)) != 0) {
fputs("submit failed\n", stderr);
hub75_destroy(context);
return 1;
}
++submitted;
}
struct timespec delay = {.tv_sec = 0, .tv_nsec = 20000000};
nanosleep(&delay, NULL);
}
hub75_stats stats;
if (hub75_get_stats(context, &stats) != 0) {
fputs("stats failed\n", stderr);
hub75_destroy(context);
return 1;
}
hub75_destroy(context);
int expected_scans = hub75_scans_per_frame_for_brightness(brightness);
double miss_rate = stats.completed_frames == 0 ? 100.0 :
(double)stats.deadline_misses * 100.0 / (double)stats.completed_frames;
printf(
"{\"mode\":\"%s\",\"configured_hz\":%d,\"actual_hz\":%.3f,"
"\"panel_scan_hz\":%.3f,\"completed_frames\":%llu,"
"\"completed_scans\":%llu,\"scans_per_frame\":%d,"
"\"deadline_misses\":%llu,"
"\"deadline_miss_percent\":%.6f,\"buffer_swaps\":%llu,"
"\"submitted_frames\":%llu,\"oe_timing_backend\":\"%s\","
"\"oe_pulse_faults\":%llu,\"oe_forced_blanks\":%llu,"
"\"max_programmed_oe_ns\":%llu,\"oe_timing_error\":\"%s\","
"\"cpu_affinity_active\":%s,"
"\"realtime_priority_active\":%s,\"memory_locked\":%s,"
"\"governor\":\"%s\",\"affinity_error\":\"%s\","
"\"realtime_error\":\"%s\",\"memory_lock_error\":\"%s\","
"\"last_error\":\"%s\"}\n",
use_dev_mem ? "dev-mem" : "fake-registers",
refresh_rate,
stats.actual_refresh_rate_hz,
stats.panel_scan_rate_hz,
(unsigned long long)stats.completed_frames,
(unsigned long long)stats.completed_scans,
stats.scans_per_frame,
(unsigned long long)stats.deadline_misses,
miss_rate,
(unsigned long long)stats.buffer_swaps,
(unsigned long long)stats.submitted_frames,
stats.oe_timing_backend,
(unsigned long long)stats.oe_pulse_faults,
(unsigned long long)stats.oe_forced_blanks,
(unsigned long long)stats.max_programmed_oe_ns,
stats.oe_timing_error,
stats.cpu_affinity_active ? "true" : "false",
stats.realtime_priority_active ? "true" : "false",
stats.memory_locked ? "true" : "false",
stats.governor,
stats.affinity_error,
stats.realtime_error,
stats.memory_lock_error,
stats.last_error
);
if (
stats.completed_frames == 0 ||
stats.actual_refresh_rate_hz <= 0.0 ||
stats.panel_scan_rate_hz <= 0.0 ||
stats.scans_per_frame != expected_scans ||
stats.completed_scans != stats.completed_frames * (uint64_t)expected_scans ||
stats.oe_pulse_faults != 0 ||
strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) != 0
) return 1;
return 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
/*
* Native HUB75 refresh driver for WalnutPi ZeroW / Allwinner H618.
*
* GPIO register layout and physical-pin mapping are derived from WalnutPi's
* MIT-licensed gpioc project, commit 220a46267469cc9cb1cbc35cd72f35eb1699a6e8:
* https://github.com/walnutpi/gpioc
*
* This project keeps the upstream MIT license in THIRD_PARTY_NOTICES.md.
*/
#ifndef WALNUTPI_H618_HUB75_H
#define WALNUTPI_H618_HUB75_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define HUB75_WIDTH 64
#define HUB75_HEIGHT 64
#define HUB75_SCAN_ROWS 32
#define HUB75_PWM_BITS 7
#define HUB75_INTERNAL_PWM_BITS 11
#define HUB75_PWM_PLANE_OFFSET (HUB75_INTERNAL_PWM_BITS - HUB75_PWM_BITS)
#define HUB75_SCANS_PER_FRAME 1
#define HUB75_FRAME_BYTES (HUB75_WIDTH * HUB75_HEIGHT * 3)
#define HUB75_BITPLANE_WORDS (HUB75_SCAN_ROWS * HUB75_PWM_BITS * HUB75_WIDTH)
#define HUB75_HARDWARE_MAPPING "walnutpi-pi-bank-pwm-oe-v2"
#define HUB75_OE_TIMING_BACKEND "h618-pwm4"
#define HUB75_OE_PWM_TICK_NS 125u
typedef struct hub75_context hub75_context;
typedef struct hub75_config {
int brightness;
int refresh_rate_hz;
int cpu_affinity;
int realtime_priority;
int use_dev_mem;
int fake_pwm5_mode;
int fake_pwm4_stuck;
} hub75_config;
typedef struct hub75_stats {
double actual_refresh_rate_hz;
double panel_scan_rate_hz;
uint64_t completed_frames;
uint64_t completed_scans;
uint64_t deadline_misses;
uint64_t buffer_swaps;
uint64_t submitted_frames;
uint64_t oe_pulse_faults;
uint64_t oe_forced_blanks;
uint64_t max_programmed_oe_ns;
int cpu_affinity;
int cpu_affinity_active;
int realtime_priority;
int realtime_priority_active;
int memory_locked;
int running;
int brightness;
int refresh_rate_hz;
int scans_per_frame;
char governor[32];
char affinity_error[128];
char realtime_error[128];
char memory_lock_error[128];
char oe_timing_backend[32];
char oe_timing_error[256];
char safeoff_error[256];
char last_error[256];
} hub75_stats;
hub75_context *hub75_create(const hub75_config *config, char *error, size_t error_size);
int hub75_start(hub75_context *context);
int hub75_submit_rgb(hub75_context *context, const uint8_t *rgb, size_t length);
int hub75_set_brightness(hub75_context *context, int brightness);
int hub75_set_refresh_rate(hub75_context *context, int refresh_rate_hz);
int hub75_clear(hub75_context *context);
int hub75_get_stats(hub75_context *context, hub75_stats *stats);
void hub75_destroy(hub75_context *context);
const char *hub75_hardware_mapping(void);
int hub75_force_safeoff(int use_dev_mem, char *error, size_t error_size);
/* Pure conversion hook used by tests; output words contain PI0..PI5 colors. */
int hub75_build_bitplanes(
const uint8_t *rgb,
size_t length,
uint16_t *output,
size_t output_words
);
/* Raspberry-Pi-compatible CIE1931 brightness mapping into 11-bit color. */
int hub75_build_bitplanes_for_brightness(
const uint8_t *rgb,
size_t length,
int brightness,
uint16_t *output,
size_t output_words
);
/* Pure 8-bit RGB channel to 11-bit CIE1931 mapping hook. */
uint16_t hub75_map_channel_for_brightness(uint8_t value, int brightness);
/* Pure scan-count hook used by native tests and the benchmark. */
int hub75_scans_per_frame_for_brightness(int brightness);
/* Fixed binary OE weights matching internal bitplanes 4..10. */
uint32_t hub75_oe_ticks_for_plane(
uint64_t scan_slot_ns,
int brightness,
unsigned plane,
uint64_t *remainder
);
typedef struct hub75_fake_registers {
uint32_t mux0;
uint32_t mux1;
uint32_t data;
uint32_t pwm_pccr45;
uint32_t pwm_per;
uint32_t pwm_pcr4;
uint32_t pwm_ppr4;
uint32_t pwm_pcr5;
uint32_t pwm_ppr5;
} hub75_fake_registers;
int hub75_get_fake_registers(
hub75_context *context,
hub75_fake_registers *registers
);
#ifdef __cplusplus
}
#endif
#endif
@@ -0,0 +1,195 @@
#define _POSIX_C_SOURCE 200809L
#include "h618_hub75.h"
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
enum {
PI_R1 = 1u << 0,
PI_G1 = 1u << 1,
PI_B1 = 1u << 2,
PI_R2 = 1u << 3,
PI_G2 = 1u << 4,
PI_B2 = 1u << 5,
};
static size_t word_index(unsigned row, unsigned plane, unsigned column) {
return ((size_t)row * HUB75_PWM_BITS + plane) * HUB75_WIDTH + column;
}
int main(void) {
assert(strcmp(hub75_hardware_mapping(), HUB75_HARDWARE_MAPPING) == 0);
assert(HUB75_INTERNAL_PWM_BITS == 11);
assert(HUB75_PWM_PLANE_OFFSET == 4);
assert(HUB75_SCANS_PER_FRAME == 1);
assert(hub75_scans_per_frame_for_brightness(1) == 1);
assert(hub75_scans_per_frame_for_brightness(20) == 1);
assert(hub75_scans_per_frame_for_brightness(100) == 1);
assert(hub75_scans_per_frame_for_brightness(0) == -1);
assert(hub75_scans_per_frame_for_brightness(101) == -1);
uint16_t previous_mapped = 0;
for (int brightness = 1; brightness <= 100; ++brightness) {
uint16_t mapped = hub75_map_channel_for_brightness(255, brightness);
assert(mapped >= previous_mapped);
previous_mapped = mapped;
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
uint64_t remainder = 123u;
assert(hub75_oe_ticks_for_plane(
10250000, brightness, plane, &remainder
) == (1u << (plane + HUB75_PWM_PLANE_OFFSET)));
assert(remainder == 0);
}
}
assert(previous_mapped == 2047u);
assert(hub75_map_channel_for_brightness(255, 0) == UINT16_MAX);
uint64_t invalid_remainder = 0;
assert(hub75_oe_ticks_for_plane(5000000, 0, 0, &invalid_remainder) == UINT32_MAX);
assert(hub75_oe_ticks_for_plane(5000000, 1, HUB75_PWM_BITS, &invalid_remainder) == UINT32_MAX);
uint64_t full_scale_remainder = 0;
assert(hub75_oe_ticks_for_plane(
10250000, 100, HUB75_PWM_BITS - 1, &full_scale_remainder
) == 1024u);
uint8_t *frame = calloc(1, HUB75_FRAME_BYTES);
uint16_t *planes = calloc(HUB75_BITPLANE_WORDS, sizeof(*planes));
assert(frame != NULL);
assert(planes != NULL);
/* Compare extracted planes with the Raspberry-compatible 11-bit map. */
frame[0] = 0xff;
frame[1] = 0x80;
frame[2] = 0x40;
size_t bottom = (size_t)HUB75_SCAN_ROWS * HUB75_WIDTH * 3u;
frame[bottom] = 0x20;
frame[bottom + 1] = 0x10;
frame[bottom + 2] = 0x08;
assert(hub75_build_bitplanes(
frame,
HUB75_FRAME_BYTES,
planes,
HUB75_BITPLANE_WORDS
) == 0);
const uint8_t values[] = {0xff, 0x80, 0x40, 0x20, 0x10, 0x08};
const uint16_t bits[] = {PI_R1, PI_G1, PI_B1, PI_R2, PI_G2, PI_B2};
for (unsigned plane = 0; plane < HUB75_PWM_BITS; ++plane) {
uint16_t expected = 0;
for (unsigned component = 0; component < 6; ++component) {
uint16_t mapped = hub75_map_channel_for_brightness(values[component], 100);
if ((mapped >> (plane + HUB75_PWM_PLANE_OFFSET)) & 1u) {
expected |= bits[component];
}
}
assert(planes[word_index(0, plane, 0)] == expected);
}
memset(frame, 0xff, HUB75_FRAME_BYTES);
assert(hub75_build_bitplanes(
frame,
HUB75_FRAME_BYTES,
planes,
HUB75_BITPLANE_WORDS
) == 0);
for (size_t index = 0; index < HUB75_BITPLANE_WORDS; ++index) {
assert(planes[index] == (PI_R1 | PI_G1 | PI_B1 | PI_R2 | PI_G2 | PI_B2));
}
/* Brightness changes bitplane data rather than OE pulse duration. */
memset(frame, 0, HUB75_FRAME_BYTES);
frame[0] = 0xff;
frame[1] = 0x4f;
frame[2] = 0xa3;
uint16_t low_planes[HUB75_BITPLANE_WORDS];
assert(hub75_build_bitplanes_for_brightness(
frame, HUB75_FRAME_BYTES, 100, planes, HUB75_BITPLANE_WORDS
) == 0);
assert(hub75_build_bitplanes_for_brightness(
frame, HUB75_FRAME_BYTES, 20, low_planes, HUB75_BITPLANE_WORDS
) == 0);
assert(memcmp(low_planes, planes, sizeof(low_planes)) != 0);
assert(hub75_build_bitplanes(frame, 1, planes, HUB75_BITPLANE_WORDS) == -1);
assert(hub75_build_bitplanes(frame, HUB75_FRAME_BYTES, planes, 1) == -1);
hub75_config config = {
.brightness = 40,
.refresh_rate_hz = 100,
.cpu_affinity = 0,
.realtime_priority = 1,
.use_dev_mem = 0,
};
char error[512] = {0};
hub75_context *context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
hub75_fake_registers registers;
assert(hub75_get_fake_registers(context, &registers) == 0);
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x5u);
assert(((registers.mux1 >> ((15u - 8u) * 4u)) & 0xfu) == 0x1u);
assert((registers.pwm_pccr45 & (1u << 4)) != 0);
assert((registers.pwm_pccr45 & ((0xfu) | (1u << 5) | (0x3u << 7))) == 0);
assert((registers.pwm_per & (1u << 4)) != 0);
assert((registers.pwm_pcr4 & 0x1ffu) == 2u);
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
assert(registers.pwm_ppr4 == (65535u << 16));
hub75_stats stats;
assert(hub75_get_stats(context, &stats) == 0);
assert(strcmp(stats.oe_timing_backend, HUB75_OE_TIMING_BACKEND) == 0);
assert(stats.oe_pulse_faults == 0);
memset(frame, 0xff, HUB75_FRAME_BYTES);
assert(hub75_submit_rgb(context, frame, HUB75_FRAME_BYTES) == 0);
assert(hub75_set_brightness(context, 100) == 0);
assert(hub75_start(context) == 0);
struct timespec pulse_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
nanosleep(&pulse_test_delay, NULL);
assert(hub75_get_stats(context, &stats) == 0);
assert(stats.max_programmed_oe_ns == 128000u);
assert(stats.oe_pulse_faults == 0);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_pcr4 & (1u << 9)) != 0);
assert((registers.pwm_pcr4 & (1u << 10)) == 0);
assert((registers.pwm_per & (1u << 4)) != 0);
assert((registers.pwm_ppr4 & 0xffffu) != 0);
assert((registers.pwm_ppr4 >> 16) + 1u ==
(registers.pwm_ppr4 & 0xffffu));
hub75_destroy(context);
config.fake_pwm4_stuck = 1;
context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
assert(hub75_start(context) == 0);
struct timespec stuck_test_delay = {.tv_sec = 0, .tv_nsec = 100000000};
nanosleep(&stuck_test_delay, NULL);
assert(hub75_get_stats(context, &stats) == 0);
assert(stats.oe_pulse_faults == 1);
assert(stats.oe_forced_blanks == 1);
assert(strstr(stats.oe_timing_error, "start bit did not clear") != NULL);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_per & (1u << 4)) == 0);
assert(((registers.mux1 >> ((14u - 8u) * 4u)) & 0xfu) == 0x1u);
hub75_destroy(context);
config.fake_pwm4_stuck = 0;
config.fake_pwm5_mode = 1;
context = hub75_create(&config, error, sizeof(error));
assert(context != NULL);
assert(hub75_get_fake_registers(context, &registers) == 0);
assert((registers.pwm_per & (1u << 5)) != 0);
assert((registers.pwm_pccr45 & (1u << 6)) != 0);
assert(registers.pwm_pcr5 == 0x100u);
assert(registers.pwm_ppr5 == 0x000b0006u);
hub75_destroy(context);
config.fake_pwm5_mode = 2;
context = hub75_create(&config, error, sizeof(error));
assert(context == NULL);
assert(strstr(error, "incompatible shared PWM4/5 clock") != NULL);
assert(hub75_force_safeoff(0, error, sizeof(error)) == 0);
free(planes);
free(frame);
puts("native_bitplane_tests=PASS");
return 0;
}
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Read-only H618 PI-bank snapshot for no-load safety verification."""
from __future__ import annotations
import json
import mmap
import os
import struct
from pathlib import Path
PIO_BASE = 0x0300B000
PIO_LENGTH = 0x400
PI_OFFSET = 0x120
DATA_OFFSET = 0x10
OWNED_BITS = tuple([*range(0, 7), *range(9, 16)])
RESERVED_BITS = (7, 8, 16)
def read_u32(mapped: mmap.mmap, offset: int) -> int:
return struct.unpack_from("<I", mapped, offset)[0]
def mux_mode(mapped: mmap.mmap, pin: int) -> int:
register = PI_OFFSET + (pin // 8) * 4
return (read_u32(mapped, register) >> ((pin % 8) * 4)) & 0xF
def device_tree_contract() -> dict:
node = Path("/proc/device-tree/soc/pinctrl@300b000")
compatible = (node / "compatible").read_bytes().rstrip(b"\0").decode("ascii")
values = struct.unpack(">II", (node / "reg").read_bytes())
if compatible != "allwinner,sun50i-h616-pinctrl" or values != (PIO_BASE, PIO_LENGTH):
raise RuntimeError(f"unexpected pinctrl contract: {compatible=} {values=}")
return {"compatible": compatible, "base": f"0x{values[0]:08X}", "length": values[1]}
def main() -> None:
contract = device_tree_contract()
descriptor = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
try:
with mmap.mmap(descriptor, PIO_LENGTH, access=mmap.ACCESS_READ, offset=PIO_BASE) as mapped:
data = read_u32(mapped, PI_OFFSET + DATA_OFFSET)
modes = {f"PI{pin}": mux_mode(mapped, pin) for pin in range(17)}
finally:
os.close(descriptor)
owned_mask = sum(1 << bit for bit in OWNED_BITS)
reserved_mask = sum(1 << bit for bit in RESERVED_BITS)
print(json.dumps({
"device_tree": contract,
"data_register": f"0x{data:08X}",
"owned_data_bits": f"0x{data & owned_mask:08X}",
"reserved_data_bits": f"0x{data & reserved_mask:08X}",
"modes": modes,
}, sort_keys=True))
if __name__ == "__main__":
main()
@@ -0,0 +1,22 @@
#include "h618_hub75.h"
#include <stdio.h>
#include <string.h>
int main(int argc, char **argv) {
int use_dev_mem = 1;
if (argc == 2 && strcmp(argv[1], "--fake") == 0) {
use_dev_mem = 0;
} else if (argc != 1) {
fprintf(stderr, "usage: %s [--fake]\n", argv[0]);
return 2;
}
char error[512] = {0};
if (hub75_force_safeoff(use_dev_mem, error, sizeof(error)) != 0) {
fprintf(stderr, "HUB75 safeoff failed: %s\n", error[0] ? error : "unknown error");
return 1;
}
puts("HUB75 safeoff complete");
return 0;
}
@@ -0,0 +1,113 @@
"""Pure-Python reference model for the fixed WalnutPi HUB75 mapping.
The production refresh loop is C. This module is intentionally small and
deterministic so mapping and bitplane contracts can be tested on any host.
"""
from __future__ import annotations
from dataclasses import dataclass
WIDTH = 64
HEIGHT = 64
SCAN_ROWS = 32
PWM_BITS = 7
PIO_BASE = 0x0300B000
PI_BANK_OFFSET = 0x120
PI_DATA_OFFSET = 0x10
PWM_BASE = 0x0300A000
HARDWARE_MAPPING = "walnutpi-pi-bank-pwm-oe-v2"
OE_PWM_TICK_NS = 125
INTERNAL_PWM_BITS = 11
PWM_PLANE_OFFSET = INTERNAL_PWM_BITS - PWM_BITS
@dataclass(frozen=True)
class Hub75Pin:
signal: str
physical: int
gpio: str
bit: int
HUB75_PINS = (
Hub75Pin("R1", 29, "PI0", 0),
Hub75Pin("G1", 31, "PI1", 1),
Hub75Pin("B1", 33, "PI2", 2),
Hub75Pin("R2", 35, "PI3", 3),
Hub75Pin("G2", 37, "PI4", 4),
Hub75Pin("B2", 8, "PI5", 5),
Hub75Pin("A", 10, "PI6", 6),
Hub75Pin("B", 28, "PI9", 9),
Hub75Pin("C", 27, "PI10", 10),
Hub75Pin("D", 15, "PI11", 11),
Hub75Pin("E", 16, "PI12", 12),
Hub75Pin("CLK", 38, "PI13", 13),
Hub75Pin("OE", 40, "PI14/PWM4", 14),
Hub75Pin("LAT", 36, "PI15", 15),
)
RESERVED_PI_BITS = frozenset({7, 8, 16})
REFRESH_RATE_LIMITS = frozenset({15, 20, 30, 45, 60, 80, 100})
def address_mask(row: int) -> int:
if type(row) is not int or not 0 <= row < SCAN_ROWS:
raise ValueError("row must be in 0..31")
mapping = (6, 9, 10, 11, 12)
return sum(1 << gpio_bit for row_bit, gpio_bit in enumerate(mapping) if row & (1 << row_bit))
def map_channel(value: int, brightness: int) -> int:
if type(value) is not int or not 0 <= value <= 255:
raise ValueError("value must be in 0..255")
if type(brightness) is not int or not 1 <= brightness <= 100:
raise ValueError("brightness must be in 1..100")
output_factor = (1 << INTERNAL_PWM_BITS) - 1
v = value * brightness / 255.0
luminance = v / 902.3 if v <= 8 else ((v + 16) / 116.0) ** 3
return int(output_factor * luminance + 0.5)
def build_bitplanes(frame: bytes, brightness: int = 100) -> tuple[int, ...]:
if len(frame) != WIDTH * HEIGHT * 3:
raise ValueError("frame must contain 64x64 RGB888 bytes")
mapped = tuple(map_channel(value, brightness) for value in range(256))
output = [0] * (SCAN_ROWS * PWM_BITS * WIDTH)
for row in range(SCAN_ROWS):
for plane in range(PWM_BITS):
for column in range(WIDTH):
top = (row * WIDTH + column) * 3
bottom = ((row + SCAN_ROWS) * WIDTH + column) * 3
word = 0
for component in range(3):
if mapped[frame[top + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
word |= 1 << component
if mapped[frame[bottom + component]] & (1 << (plane + PWM_PLANE_OFFSET)):
word |= 1 << (component + 3)
index = ((row * PWM_BITS + plane) * WIDTH) + column
output[index] = word
return tuple(output)
def bitplane_index(row: int, plane: int, column: int) -> int:
if not 0 <= row < SCAN_ROWS or not 0 <= plane < PWM_BITS or not 0 <= column < WIDTH:
raise ValueError("bitplane coordinate out of range")
return ((row * PWM_BITS + plane) * WIDTH) + column
def oe_ticks_for_plane(
scan_slot_ns: int,
brightness: int,
plane: int,
remainder: int,
) -> tuple[int, int]:
if type(scan_slot_ns) is not int or scan_slot_ns <= 0:
raise ValueError("scan_slot_ns must be positive")
if type(brightness) is not int or not 1 <= brightness <= 100:
raise ValueError("brightness must be in 1..100")
if type(plane) is not int or not 0 <= plane < PWM_BITS:
raise ValueError("plane must be in 0..6")
if type(remainder) is not int or remainder < 0:
raise ValueError("remainder must be non-negative")
return 1 << (plane + PWM_PLANE_OFFSET), 0
@@ -0,0 +1,98 @@
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
OTA_INDICATOR_BRIGHTNESS = 40
OTA_CANVAS_SIZE = 64
OTA_CONTENT_SIZE = 58
OTA_CONTENT_OFFSET = (OTA_CANVAS_SIZE - OTA_CONTENT_SIZE) // 2
def _draw_binary_text(
image: Image.Image,
position: tuple[int, int],
text: str,
*,
font: ImageFont.ImageFont,
fill: tuple[int, int, int],
) -> tuple[int, int, int, int]:
probe = ImageDraw.Draw(image)
bbox = probe.textbbox((0, 0), text, font=font)
width = max(1, bbox[2] - bbox[0])
height = max(1, bbox[3] - bbox[1])
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), text, font=font, fill=255)
# Small system labels must be LED pixels, not dim antialiasing samples.
mask = mask.point(lambda value: 255 if value >= 64 else 0)
left = position[0] + bbox[0]
top = position[1] + bbox[1]
image.paste(fill, (left, top), mask)
return left, top, left + width, top + height
def _resize_preserving_lit_pixels(image: Image.Image, size: tuple[int, int]) -> Image.Image:
"""Shrink a pixel composition without dropping one-pixel strokes."""
source = image.convert("RGB")
target = Image.new("RGB", size, (0, 0, 0))
source_pixels = source.load()
target_pixels = target.load()
source_width, source_height = source.size
target_width, target_height = size
# Forward-map every lit source pixel once. Pillow's inverse nearest-neighbor
# shrink skips some source rows/columns, which is exactly what breaks thin
# strokes such as the top of "T".
for source_y in range(source_height):
target_y = min(target_height - 1, int((source_y + 0.5) * target_height / source_height))
for source_x in range(source_width):
pixel = source_pixels[source_x, source_y]
if pixel == (0, 0, 0):
continue
target_x = min(target_width - 1, int((source_x + 0.5) * target_width / source_width))
if max(pixel) > max(target_pixels[target_x, target_y]):
target_pixels[target_x, target_y] = pixel
return target
def render_ota_indicator(percent: int = 0, stage: str = "准备更新") -> Image.Image:
value = max(0, min(100, int(percent)))
content = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
draw = ImageDraw.Draw(content)
cyan = (34, 220, 220)
dim = (24, 70, 76)
white = (245, 250, 250)
draw.rounded_rectangle((15, 4, 48, 32), radius=5, outline=cyan, width=2)
draw.polygon(((32, 8), (23, 18), (28, 18), (28, 27), (36, 27), (36, 18), (41, 18)), fill=cyan)
font = ImageFont.load_default()
# One source-pixel space prevents the coverage shrink from joining adjacent
# glyph edges into the misleading "OIA" seen on the device.
ota_label = "O T A"
label_box = draw.textbbox((0, 0), ota_label, font=font)
label_width = label_box[2] - label_box[0]
_draw_binary_text(
content,
((OTA_CANVAS_SIZE - label_width) // 2, 35),
ota_label,
font=font,
fill=white,
)
draw.rectangle((6, 48, 57, 55), outline=dim, width=1)
fill_width = round(49 * value / 100)
if fill_width:
draw.rectangle((7, 49, 6 + fill_width, 54), fill=cyan)
label = f"{value}%"
percent_box = draw.textbbox((0, 0), label, font=font)
percent_width = percent_box[2] - percent_box[0]
left = (OTA_CANVAS_SIZE - percent_width) // 2
# Position from the measured bottom so the source glyph is never clipped.
top = OTA_CANVAS_SIZE - percent_box[3]
_draw_binary_text(content, (left, top), label, font=font, fill=white)
# The stage changes a tiny activity marker even when the percentage is fixed.
marker = sum(stage.encode("utf-8")) % 4
draw.rectangle((52 + marker * 2, 37, 53 + marker * 2, 39), fill=cyan)
scaled = _resize_preserving_lit_pixels(content, (OTA_CONTENT_SIZE, OTA_CONTENT_SIZE))
image = Image.new("RGB", (OTA_CANVAS_SIZE, OTA_CANVAS_SIZE), (0, 0, 0))
image.paste(scaled, (OTA_CONTENT_OFFSET, OTA_CONTENT_OFFSET))
return image
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
from __future__ import annotations
import json
import logging
from pathlib import Path
from uuid import uuid4
from PIL import Image, ImageDraw
from app.persistence import atomic_write_bytes
logger = logging.getLogger(__name__)
STARTUP_BRIGHTNESS = 50
OPEN_EYE_SECONDS = 1.3
CLOSED_EYE_SECONDS = 0.2
BACKGROUND = (255, 255, 255)
PINK = (255, 79, 163)
_PROCESS_BOOT_ID = f"process-{uuid4()}"
def render_startup_smile(*, eyes_closed: bool = False) -> Image.Image:
image = Image.new("RGB", (64, 64), BACKGROUND)
draw = ImageDraw.Draw(image)
if eyes_closed:
draw.rectangle((14, 23, 25, 26), fill=PINK)
draw.rectangle((38, 23, 49, 26), fill=PINK)
else:
draw.rectangle((16, 18, 23, 29), fill=PINK)
draw.rectangle((40, 18, 47, 29), fill=PINK)
for box in (
(14, 36, 17, 39),
(18, 40, 21, 43),
(22, 44, 25, 47),
(26, 48, 37, 51),
(38, 44, 41, 47),
(42, 40, 45, 43),
(46, 36, 49, 39),
):
draw.rectangle(box, fill=PINK)
return image
def read_boot_id() -> str:
try:
value = Path("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
if value:
return value
except (OSError, UnicodeError):
logger.info("Linux boot ID is unavailable; using the current process session")
return _PROCESS_BOOT_ID
class StartupSessionStore:
def __init__(self, data_dir: Path, boot_id: str | None = None) -> None:
self.path = data_dir / "startup_indicator.json"
self.boot_id = boot_id or read_boot_id()
def should_show(self) -> bool:
if not self.path.exists():
return True
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("startup indicator state must be an object")
return raw.get("dismissed_boot_id") != self.boot_id
except (OSError, ValueError, TypeError):
logger.exception("Startup indicator state is invalid; showing the indicator")
return True
def mark_dismissed(self) -> None:
atomic_write_bytes(
self.path,
(
json.dumps(
{"dismissed_boot_id": self.boot_id},
ensure_ascii=False,
indent=2,
)
+ "\n"
).encode("utf-8"),
)
@@ -0,0 +1,322 @@
from __future__ import annotations
import os
import subprocess
import unicodedata
from functools import lru_cache
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw, ImageFont, features
from .colors import RGB
@dataclass(frozen=True)
class TextOptions:
text: str
font: str = "default"
size: int = 12
x: int = 0
y: int = 0
align: str = "left"
color: RGB = (255, 255, 255)
background: RGB = (0, 0, 0)
DEFAULT_FONT_CANDIDATES = (
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
"/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/segoeui.ttf",
"C:/Windows/Fonts/msyh.ttc",
"C:/Windows/Fonts/msjh.ttc",
"C:/Windows/Fonts/YuGothR.ttc",
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/LeelUIsl.ttf",
)
FONTCONFIG_LANGUAGES = (
"en", "ru", "el", "zh-cn", "ja", "ko", "ar", "he", "hi",
"bn", "ta", "te", "th",
)
PREFERRED_FONT_CANDIDATES: dict[str, tuple[tuple[str, int], ...]] = {
"en": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"ru": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"el": (("/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"zh-cn": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), ("C:/Windows/Fonts/msyh.ttc", 0)),
"ja": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 0), ("C:/Windows/Fonts/YuGothR.ttc", 0)),
"ko": (("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 1), ("C:/Windows/Fonts/malgun.ttf", 0)),
"ar": (("/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"he": (("/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", 0), ("C:/Windows/Fonts/arial.ttf", 0)),
"hi": (("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf", 0),),
"bn": (("/usr/share/fonts/truetype/noto/NotoSansBengali-Regular.ttf", 0),),
"ta": (("/usr/share/fonts/truetype/noto/NotoSansTamil-Regular.ttf", 0),),
"te": (("/usr/share/fonts/truetype/noto/NotoSansTelugu-Regular.ttf", 0),),
"th": (("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf", 0), ("C:/Windows/Fonts/LeelUIsl.ttf", 0)),
}
@dataclass(frozen=True)
class FontFace:
path: str
index: int = 0
FontResolver = Callable[[str], tuple[FontFace, ...]]
def render_text_layer(
options: TextOptions,
size: tuple[int, int] = (64, 64),
*,
font_resolver: FontResolver | None = None,
) -> Image.Image:
if options.size < 1 or options.size > 64:
raise ValueError("text size must be in 1..64")
if options.align not in {"left", "center", "right"}:
raise ValueError("align must be left, center, or right")
image = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
font = load_font(options.font, options.size, options.text, font_resolver=font_resolver)
x = int(options.x)
y = int(options.y)
bbox = draw.textbbox((0, 0), options.text, font=font)
text_width = bbox[2] - bbox[0]
if options.align == "center":
x = x - text_width // 2
elif options.align == "right":
x = x - text_width
draw.text((x, y), options.text, fill=(*options.color, 255), font=font)
return image
def render_text(
options: TextOptions,
size: tuple[int, int] = (64, 64),
*,
font_resolver: FontResolver | None = None,
) -> Image.Image:
background = Image.new("RGBA", size, (*options.background, 255))
layer = render_text_layer(options, size=size, font_resolver=font_resolver)
return Image.alpha_composite(background, layer).convert("RGB")
def _required_codepoints(text: str) -> frozenset[int]:
return frozenset(
ord(character)
for character in text
if not character.isspace()
and unicodedata.category(character) not in {"Cc", "Cf"}
and not 0xFE00 <= ord(character) <= 0xFE0F
and not 0xE0100 <= ord(character) <= 0xE01EF
)
def _contains_default_emoji(codepoints: frozenset[int]) -> bool:
# Default automatic text intentionally stays deterministic across Debian
# images. Some DejaVu releases contain a few monochrome emoji glyphs while
# others do not, so accepting them based on the host cmap makes the API
# change after an OS/font update. Explicitly imported fonts remain eligible.
return any(0x1F000 <= codepoint <= 0x1FAFF for codepoint in codepoints)
@lru_cache(maxsize=256)
def _font_coverage(face: FontFace) -> frozenset[int]:
try:
# Fontconfig encodes variable-font named instances above the base
# collection index. FontTools needs the base face while Pillow uses the
# complete FreeType face index.
with TTFont(face.path, fontNumber=face.index & 0xFFFF, lazy=True) as font:
cmap = font.getBestCmap() or {}
return frozenset(cmap)
except Exception:
return frozenset()
@lru_cache(maxsize=32)
def _fontconfig_face(language: str) -> FontFace | None:
try:
result = subprocess.run(
["fc-match", "--format=%{file}\n%{index}\n", f":lang={language}"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=2,
)
lines = result.stdout.splitlines()
if not lines:
return None
path = lines[0].strip()
index = int(lines[1].strip() or "0") if len(lines) > 1 else 0
if path and Path(path).is_file():
return FontFace(path, index)
except (FileNotFoundError, OSError, subprocess.SubprocessError, ValueError):
pass
return None
def _fontconfig_faces() -> list[FontFace]:
return [face for language in FONTCONFIG_LANGUAGES if (face := _fontconfig_face(language))]
def _preferred_font_face(language: str) -> FontFace | None:
for candidate, index in PREFERRED_FONT_CANDIDATES.get(language, ()):
path = Path(candidate)
if path.is_file():
return FontFace(str(path.resolve()), index)
face = _fontconfig_face(language)
if face and Path(face.path).name == "NotoSansCJK-Regular.ttc":
indexes = {"ja": 0, "ko": 1, "zh-cn": 2}
return FontFace(face.path, indexes.get(language, face.index))
return face
def _preferred_font_language(text: str) -> str | None:
codepoints = _required_codepoints(text)
script_ranges = (
("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xAC00, 0xD7AF))),
("ja", ((0x3040, 0x30FF), (0x31F0, 0x31FF))),
("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF))),
("he", ((0x0590, 0x05FF),)),
("hi", ((0x0900, 0x097F),)),
("bn", ((0x0980, 0x09FF),)),
("ta", ((0x0B80, 0x0BFF),)),
("te", ((0x0C00, 0x0C7F),)),
("th", ((0x0E00, 0x0E7F),)),
("el", ((0x0370, 0x03FF),)),
("ru", ((0x0400, 0x052F),)),
("zh-cn", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF))),
)
for language, ranges in script_ranges:
if any(start <= codepoint <= end for codepoint in codepoints for start, end in ranges):
return language
return "en" if codepoints else None
def _configured_faces() -> list[FontFace]:
raw = os.environ.get("MATRIX_FONT_PATHS", "")
return [
FontFace(str(Path(value).expanduser()))
for value in raw.split(os.pathsep)
if value.strip() and Path(value.strip()).expanduser().is_file()
]
@lru_cache(maxsize=1)
def fallback_font_faces() -> tuple[FontFace, ...]:
faces = _configured_faces() + _fontconfig_faces()
faces.extend(
FontFace(str(path))
for candidate in DEFAULT_FONT_CANDIDATES
if (path := Path(candidate)).is_file()
)
unique: list[FontFace] = []
seen: set[tuple[str, int]] = set()
for face in faces:
key = (str(Path(face.path).resolve()), face.index)
if key not in seen:
seen.add(key)
unique.append(FontFace(key[0], face.index))
return tuple(unique)
def _custom_font_faces(font: str) -> tuple[FontFace, ...]:
if not font or font == "default":
return ()
path = Path(font).expanduser()
if not path.is_file():
return ()
# Existing requests do not carry a TTC index. Trying the indexes returned by
# Fontconfig first retains useful custom TTC behavior without changing the API.
indexes = [face.index for face in fallback_font_faces() if Path(face.path) == path.resolve()]
indexes.append(0)
return tuple(FontFace(str(path.resolve()), index) for index in dict.fromkeys(indexes))
def resolve_font_face(
font: str,
text: str,
*,
font_resolver: FontResolver | None = None,
) -> FontFace | None:
required = _required_codepoints(text)
if font == "default" and _contains_default_emoji(required):
return None
language = _preferred_font_language(text)
preferred = _preferred_font_face(language) if language else None
preferred_faces = (preferred,) if preferred is not None else ()
resolved_faces = font_resolver(font) if font_resolver and font != "default" else ()
primary_candidates = resolved_faces + _custom_font_faces(font) + preferred_faces
if not required and primary_candidates:
return primary_candidates[0]
for face in primary_candidates:
if required <= _font_coverage(face):
return face
candidates = primary_candidates + fallback_font_faces()
if not candidates:
return None
if not required:
return candidates[0]
for face in candidates[len(primary_candidates):]:
if required <= _font_coverage(face):
return face
missing = sorted(
codepoint
for codepoint in required
if not any(codepoint in _font_coverage(face) for face in candidates)
)
if not missing:
missing = sorted(required)
codes = ", ".join(f"U+{codepoint:04X}" for codepoint in missing[:12])
if len(missing) > 12:
codes += ", ..."
raise ValueError(f"no installed font covers Unicode code points: {codes}")
@lru_cache(maxsize=256)
def _load_truetype(face: FontFace, size: int) -> ImageFont.FreeTypeFont:
layout_engine = ImageFont.Layout.RAQM if features.check_feature("raqm") else ImageFont.Layout.BASIC
return ImageFont.truetype(
face.path,
size=size,
index=face.index,
layout_engine=layout_engine,
)
def load_font(
font: str,
size: int,
text: str = "",
*,
font_resolver: FontResolver | None = None,
) -> ImageFont.ImageFont:
face = resolve_font_face(font, text, font_resolver=font_resolver)
if face is not None:
return _load_truetype(face, size)
if _required_codepoints(text):
codes = ", ".join(f"U+{value:04X}" for value in sorted(_required_codepoints(text)))
raise ValueError(f"no installed font covers Unicode code points: {codes}")
try:
return ImageFont.load_default(size=size)
except TypeError:
return ImageFont.load_default()
@@ -0,0 +1,62 @@
from __future__ import annotations
import os
from PIL import Image
VALID_ORIENTATIONS = {0, 90, 180, 270}
DEFAULT_ROW_BIT_ORDER = (0, 1, 2, 3, 4)
def apply_orientation(image: Image.Image, orientation: int) -> Image.Image:
if orientation not in VALID_ORIENTATIONS:
raise ValueError("orientation must be one of 0, 90, 180, 270")
if orientation == 0:
return image.copy()
if orientation == 90:
return image.transpose(Image.Transpose.ROTATE_270)
if orientation == 180:
return image.transpose(Image.Transpose.ROTATE_180)
return image.transpose(Image.Transpose.ROTATE_90)
def get_row_bit_order() -> tuple[int, int, int, int, int]:
raw = os.environ.get("MATRIX_ROW_BIT_ORDER", "").strip()
if not raw:
return DEFAULT_ROW_BIT_ORDER
try:
values = tuple(int(part.strip()) for part in raw.split(","))
except ValueError as exc:
raise ValueError("MATRIX_ROW_BIT_ORDER must be five comma-separated integers") from exc
if len(values) != 5 or sorted(values) != [0, 1, 2, 3, 4]:
raise ValueError("MATRIX_ROW_BIT_ORDER must be a permutation of 0,1,2,3,4")
return values
def apply_row_bit_order(image: Image.Image, bit_order: tuple[int, int, int, int, int]) -> Image.Image:
if bit_order == DEFAULT_ROW_BIT_ORDER:
return image.copy()
width, height = image.size
if height % 32 != 0:
raise ValueError("row bit remap requires a display height divisible by 32")
remapped = Image.new("RGB", image.size)
for y in range(height):
scan_row = y & 31
half_offset = y - scan_row
physical_scan_row = _permute_low_five_bits(scan_row, bit_order)
source_y = half_offset + physical_scan_row
remapped.paste(image.crop((0, source_y, width, source_y + 1)), (0, y))
return remapped
def _permute_low_five_bits(value: int, bit_order: tuple[int, int, int, int, int]) -> int:
result = 0
for driver_bit, physical_bit in enumerate(bit_order):
if value & (1 << driver_bit):
result |= 1 << physical_bit
return result
@@ -0,0 +1,66 @@
from __future__ import annotations
from PIL import Image, ImageDraw, ImageFont
from .text_renderer import load_font
WIFI_INDICATOR_BRIGHTNESS = 50
WIFI_SCROLL_PIXELS_PER_SECOND = 16.0
WIFI_SCROLL_FPS = 20.0
WIFI_SCROLL_GAP_PIXELS = 16
WIFI_TEXT_SIZE = 10
def _draw_binary_text(
image: Image.Image,
position: tuple[int, int],
message: str,
*,
font: ImageFont.ImageFont,
) -> None:
probe = ImageDraw.Draw(image)
bbox = probe.textbbox((0, 0), message, font=font)
width = max(1, bbox[2] - bbox[0])
height = max(1, bbox[3] - bbox[1])
mask = Image.new("L", (width, height), 0)
ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), message, font=font, fill=255)
# Promote every covered font sample to a complete LED pixel. At this size,
# retaining grayscale antialiasing makes thin strokes disappear at 50%.
mask = mask.point(lambda value: 255 if value >= 64 else 0)
image.paste((255, 255, 255), (position[0] + bbox[0], position[1] + bbox[1]), mask)
def text_width(message: str) -> int:
font = load_font("default", WIFI_TEXT_SIZE, message)
draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
box = draw.textbbox((0, 0), message, font=font)
return max(1, box[2] - box[0])
def _draw_disconnected_icon(draw: ImageDraw.ImageDraw) -> None:
# A compact WiFi fan with a red slash remains recognizable on 64x64.
white = (255, 255, 255)
red = (255, 48, 48)
draw.arc((19, 4, 45, 28), 205, 335, fill=white, width=2)
draw.arc((23, 10, 41, 28), 205, 335, fill=white, width=2)
draw.arc((28, 17, 36, 27), 205, 335, fill=white, width=2)
draw.rectangle((31, 27, 33, 29), fill=white)
draw.line((19, 4, 45, 30), fill=red, width=3)
def render_wifi_indicator(
message: str,
*,
offset: float,
connected: bool,
size: tuple[int, int] = (64, 64),
) -> Image.Image:
image = Image.new("RGB", size, (0, 0, 0))
draw = ImageDraw.Draw(image)
font = load_font("default", WIFI_TEXT_SIZE, message)
if not connected:
_draw_disconnected_icon(draw)
x = size[0] - int(offset)
y = 26 if connected else 42
_draw_binary_text(image, (x, y), message, font=font)
return image