mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* ci: mypy ratchet -- keep type-clean modules clean mypy-clean.txt lists the 71 modules under src/ that type-check clean; scripts/check_types.py runs mypy (--follow-imports=silent) on exactly those files and fails on any error or a missing/unsorted/duplicate entry. A new "Type check (mypy ratchet)" CI job runs it with mypy 1.20.2 and pinned stubs; the manual pre-commit mypy hook now runs the same script (a local hook, so mypy sees the installed requirements like CI does). 35 modules were made clean with annotation-only fixes: hints, typing.cast, TYPE_CHECKING imports, implicit-Optional defaults made explicit, and annotations widened (never guards removed) where mypy called a defensive isinstance check unreachable. No runtime behaviour change. mypy.ini: numpy and orjson are treated as Any (follow_imports=skip, also for stubs). numpy 2.3+ stubs use 3.12 `type` statements that mypy won't parse at python_version 3.10, and orjson is optional, so seeing its stubs made the result depend on whether it was installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: annotate check_types.py's list-form mypy subprocess Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Display size from config: the one computation the size readers share.
|
|
|
|
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
|
|
``display.double_sided``. The web preview endpoints (``/display/current``, the
|
|
SSE fallback), the Starlark magnify default and ``scripts/dev/vegas_audit.py``
|
|
used to re-derive that size themselves, each with its own defaults
|
|
(``chain_length`` fell back to 2 in one place and 1 in others) and none of
|
|
them applying double-sided mode. They now call this module. (The multi-display
|
|
sync handshake doesn't compute a size; it shares only
|
|
``DEFAULT_CHAIN_LENGTH``.)
|
|
|
|
The size includes what the rgbmatrix library's pixel mappers do to it --
|
|
``orientation`` and ``pixel_mapper_config`` (``Rotate:90`` swaps the axes,
|
|
``U-mapper`` folds the chain) -- because ``RGBMatrix.width``/``height``, which
|
|
``DisplayManager`` reports on hardware, are measured after them.
|
|
|
|
Kept free of hardware imports on purpose: the web interface imports it, and
|
|
``display_manager`` pulls in ``rgbmatrix``.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Match config/config.template.json's display.hardware block.
|
|
DEFAULT_ROWS = 32
|
|
DEFAULT_COLS = 64
|
|
DEFAULT_CHAIN_LENGTH = 2
|
|
DEFAULT_PARALLEL = 1
|
|
|
|
#: ``display.hardware.orientation`` -> degrees of the ``Rotate`` mapper
|
|
#: DisplayManager appends to ``pixel_mapper_config`` (None: no mapper).
|
|
ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270}
|
|
|
|
|
|
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
|
# A hand-edited config.json can hold anything here; treat a non-mapping
|
|
# like a missing block so callers get the defaults, not AttributeError.
|
|
display = (config or {}).get('display')
|
|
return display if isinstance(display, Mapping) else {}
|
|
|
|
|
|
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
|
|
hw = _display(config).get('hardware')
|
|
return hw if isinstance(hw, Mapping) else {}
|
|
|
|
|
|
def compose_pixel_mapper_config(hardware: Mapping[str, Any]) -> str:
|
|
"""The ``pixel_mapper_config`` DisplayManager hands the library.
|
|
|
|
``pixel_mapper_config`` stays a free-form advanced field (e.g. "U-mapper"
|
|
for chain layouts); ``orientation`` is the user-facing mounting rotation,
|
|
appended as a trailing ``Rotate:<deg>`` mapper rather than overwriting it.
|
|
"""
|
|
base = hardware.get('pixel_mapper_config') or ''
|
|
base = base.strip() if isinstance(base, str) else ''
|
|
degrees = ORIENTATION_ROTATE_DEGREES.get(hardware.get('orientation', 'normal'))
|
|
if degrees is None:
|
|
return base
|
|
rotate = f'Rotate:{degrees}'
|
|
return f'{base};{rotate}' if base else rotate
|
|
|
|
|
|
def _c_div(a: int, b: int) -> int:
|
|
"""C integer division (truncates toward zero)."""
|
|
q = abs(a) // abs(b)
|
|
return q if (a >= 0) == (b >= 0) else -q
|
|
|
|
|
|
def _c_strtol(text: str) -> Tuple[int, str]:
|
|
"""strtol(text, &end, 10): the parsed value (0 if none) and the rest."""
|
|
match = re.match(r'\s*([+-]?\d+)', text)
|
|
if not match:
|
|
return 0, text
|
|
return int(match.group(1)), text[match.end():]
|
|
|
|
|
|
def _remap_size(param: Optional[str], width: int, height: int,
|
|
chain: int, parallel: int) -> Optional[Tuple[int, int]]:
|
|
"""RemapMapper::SetParameters and GetSizeMapping (lib/pixel-mapper.cc)."""
|
|
if not param:
|
|
return None
|
|
new_w, rest = _c_strtol(param)
|
|
if not rest.startswith(','):
|
|
return None
|
|
new_h, rest = _c_strtol(rest[1:])
|
|
if not rest.startswith('|'):
|
|
return None
|
|
rest = rest[1:]
|
|
tiles = []
|
|
while rest:
|
|
x, rest = _c_strtol(rest)
|
|
if not rest.startswith(','):
|
|
return None
|
|
y, rest = _c_strtol(rest[1:])
|
|
if not rest or rest[0].lower() not in 'neswx':
|
|
return None
|
|
tiles.append((x, y, rest[0].lower()))
|
|
rest = rest[1:]
|
|
if rest.startswith('|'):
|
|
rest = rest[1:]
|
|
elif rest:
|
|
return None
|
|
if len(tiles) != chain * parallel:
|
|
return None
|
|
panel_w, panel_h = _c_div(width, chain), _c_div(height, parallel)
|
|
for x, y, kind in tiles:
|
|
if kind == 'x':
|
|
continue
|
|
# MapTile::MapToVisible of the panel's (0, 0) and far corner.
|
|
x0, y0, x1, y1 = {
|
|
'n': (x, y, x + panel_w - 1, y + panel_h - 1),
|
|
'w': (x, y + panel_w - 1, x + panel_h - 1, y),
|
|
's': (x + panel_w - 1, y + panel_h - 1, x, y),
|
|
'e': (x + panel_h - 1, y, x, y + panel_w - 1),
|
|
}[kind]
|
|
if x1 < 0 or x0 >= new_w or y1 < 0 or y0 >= new_h:
|
|
return None
|
|
return new_w, new_h
|
|
|
|
|
|
def apply_pixel_mappers(width: int, height: int, mapper_config: str,
|
|
chain: int, parallel: int) -> Tuple[int, int]:
|
|
"""The canvas size after the library applies ``mapper_config``.
|
|
|
|
Mirrors ``RGBMatrix::Impl::ApplyNamedPixelMappers`` and each built-in
|
|
mapper's ``SetParameters``/``GetSizeMapping`` in the pinned
|
|
``lib/pixel-mapper.cc``: mappers apply left to right, and one the library
|
|
doesn't know or can't configure is skipped and leaves the size alone.
|
|
``multiplexing`` isn't modelled: its mappers give back the configured
|
|
size for the panel sizes they are made for.
|
|
"""
|
|
param: Optional[str]
|
|
for entry in (mapper_config or '').split(';'):
|
|
name, colon, param = entry.partition(':')
|
|
name = name.lower()
|
|
param = param if colon else None
|
|
if name == 'rotate':
|
|
if not param:
|
|
continue
|
|
angle, rest = _c_strtol(param)
|
|
if rest or angle % 90:
|
|
continue
|
|
if angle % 180:
|
|
width, height = height, width
|
|
elif name == 'u-mapper':
|
|
if chain < 2 or chain % 2 or height % parallel:
|
|
continue
|
|
width, height = _c_div(width, 64) * 32, 2 * height
|
|
elif name == 'v-mapper':
|
|
width, height = (_c_div(width * parallel, chain),
|
|
_c_div(height * chain, parallel))
|
|
elif name == 'stacktorow':
|
|
if param and any(c not in 'ZzFf, ' for c in param):
|
|
continue
|
|
width, height = width * parallel, _c_div(height, parallel)
|
|
elif name == 'remap':
|
|
size = _remap_size(param, width, height, chain, parallel)
|
|
if size is not None:
|
|
width, height = size
|
|
# "mirror" keeps the size; the library skips names it doesn't know.
|
|
return width, height
|
|
|
|
|
|
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
|
|
"""Width and height of the whole panel chain, in pixels.
|
|
|
|
``cols * chain_length`` by ``rows * parallel``, then through the pixel
|
|
mappers ``orientation`` and ``pixel_mapper_config`` set up -- what
|
|
``RGBMatrix.width``/``height`` report. Raises ``ValueError`` or
|
|
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
|
|
decide their own fallback.
|
|
|
|
A non-finite value (``Infinity``, which Python's JSON parser accepts in a
|
|
hand-edited config.json) raises ``ValueError`` too, not ``OverflowError``,
|
|
so every caller's existing fallback catches it.
|
|
"""
|
|
hw = _hardware(config)
|
|
try:
|
|
rows = int(hw.get('rows', DEFAULT_ROWS))
|
|
cols = int(hw.get('cols', DEFAULT_COLS))
|
|
chain_length = int(hw.get('chain_length', DEFAULT_CHAIN_LENGTH))
|
|
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
|
|
except OverflowError as e:
|
|
raise ValueError(f"display.hardware size is not finite: {e}") from e
|
|
width, height = max(1, cols * chain_length), max(1, rows * parallel)
|
|
if chain_length >= 1 and parallel >= 1:
|
|
width, height = apply_pixel_mappers(
|
|
width, height, compose_pixel_mapper_config(hw), chain_length, parallel)
|
|
return max(1, width), max(1, height)
|
|
|
|
|
|
def resolve_double_sided(physical_width: int, physical_height: int,
|
|
ds_config: Dict[str, Any],
|
|
quiet: bool = False) -> Optional[Dict[str, Any]]:
|
|
"""Validate the ``display.double_sided`` config against the physical size.
|
|
|
|
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
|
feature is enabled and the physical panel divides evenly into ``copies``
|
|
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
|
config is logged and disabled rather than raised — a misconfigured panel
|
|
should still light up.
|
|
|
|
Only pixels are checked, not whole panels: ``chain_length`` and
|
|
``parallel`` don't say which axis a panel lies on once an orientation
|
|
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
|
|
|
|
``quiet`` suppresses the log lines, for callers that run on every web
|
|
request and would otherwise repeat them on each poll.
|
|
"""
|
|
def _log(level, *args):
|
|
if not quiet:
|
|
logger.log(level, *args)
|
|
|
|
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
|
return None
|
|
|
|
copies = ds_config.get('copies', 2)
|
|
if not isinstance(copies, int) or copies < 2:
|
|
_log(logging.WARNING,
|
|
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
|
"disabling double-sided mode", copies)
|
|
return None
|
|
|
|
axis = ds_config.get('axis', 'horizontal')
|
|
if axis not in ('horizontal', 'vertical'):
|
|
_log(logging.WARNING,
|
|
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
|
"(got %r); defaulting to 'horizontal'", axis)
|
|
axis = 'horizontal'
|
|
|
|
# Horizontal splits the chain (panels side by side); vertical splits the
|
|
# parallel outputs (panels stacked). The split axis must divide evenly.
|
|
if axis == 'horizontal':
|
|
if physical_width % copies != 0:
|
|
_log(logging.WARNING,
|
|
"double_sided: physical width %d is not divisible by copies "
|
|
"%d; disabling double-sided mode", physical_width, copies)
|
|
return None
|
|
logical_width = physical_width // copies
|
|
logical_height = physical_height
|
|
else:
|
|
if physical_height % copies != 0:
|
|
_log(logging.WARNING,
|
|
"double_sided: physical height %d is not divisible by copies "
|
|
"%d; disabling double-sided mode", physical_height, copies)
|
|
return None
|
|
logical_width = physical_width
|
|
logical_height = physical_height // copies
|
|
|
|
_log(logging.INFO,
|
|
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
|
"tiled across physical %dx%d", copies, axis, logical_width,
|
|
logical_height, physical_width, physical_height)
|
|
return {
|
|
'copies': copies,
|
|
'axis': axis,
|
|
'logical_width': logical_width,
|
|
'logical_height': logical_height,
|
|
}
|
|
|
|
|
|
def logical_size(config: Optional[Mapping[str, Any]],
|
|
quiet: bool = True) -> Tuple[int, int]:
|
|
"""The size plugins draw at and the web preview shows.
|
|
|
|
The physical size, divided by ``double_sided.copies`` along its axis when
|
|
double-sided mode is enabled and valid — the same answer
|
|
``DisplayManager.width``/``height`` give.
|
|
"""
|
|
width, height = physical_size(config)
|
|
ds = resolve_double_sided(width, height,
|
|
_display(config).get('double_sided') or {},
|
|
quiet=quiet)
|
|
if ds is not None:
|
|
return ds['logical_width'], ds['logical_height']
|
|
return width, height
|