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), }, }