初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -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, ®isters) == 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, ®isters) == 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, ®isters) == 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, ®isters) == 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
|
||||
Reference in New Issue
Block a user