114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""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
|