"""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