298 lines
12 KiB
Python
298 lines
12 KiB
Python
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),
|
|
},
|
|
}
|