Files
LEDMatrix/src/matrix_support.py
T
ChuckandClaude Opus 5.5 6f45ff5e63 fix(display): thread-safety for deferred updates, BDF faces and follower image; one refresh default (#652)
- DisplayManager.defer_update()/process_deferred_updates(): one lock around
  every queue mutation (appends from the update thread were lost to the
  render thread's filter/slice reassignments); callables run outside it.
- FontManager and element_style no longer cache BDF freetype.Face objects
  process-wide (load_bdf_face caches them per thread); element_style's LRU
  is locked against get/move_to_end vs eviction races.
- limit_refresh_rate_hz default is one constant, DEFAULT_REFRESH_LIMIT_HZ =
  100 (the template's), for the library options, refresh_hz, the matrix
  guard, Vegas and scroll_config. Previously a missing key capped the panel
  at 90 while pacing assumed 100.
- Sync follower: the TCP thread queues the leader's scroll image; the render
  thread swaps image/array/width in between frames.
- update_display() error log rate-limited (traceback first, then once a
  minute with a count); swallowed DisplayController exceptions log at DEBUG.
- Root display_controller.py runs run.py via runpy.
- stream_manager: correct the RLock release comments; merge duplicate if.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:39:40 -04:00

213 lines
9.2 KiB
Python

"""Display settings the pinned rgbmatrix library refuses, on every board.
The library does not raise on a setting it can't use. For most it returns no
matrix -- ``RGBMatrix::CreateFromOptions()`` gives back NULL when
``Options::Validate()`` (``lib/options-initialize.cc``) or its GPIO slowdown
check (``lib/led-matrix.cc``) fails -- and the Python binding stores that
pointer without checking, so the display process crashes on its first call
into the matrix. For two it calls ``abort()``: an unknown hardware mapping
name, and more parallel chains than the mapping has outputs
(``Framebuffer::InitHardwareMapping()`` and the ``Framebuffer`` constructor in
``lib/framebuffer.cc``). Either way systemd restarts the service into the same
crash, and no fallback or hardware status is ever reported.
``DisplayManager`` runs :func:`library_refusals` before creating the matrix,
turning those crashes into a logged error and the usual fallback mode. The
config API uses the same rules to refuse the settings up front, and
:data:`INT_SETTING_LIMITS` is the one place the numeric ranges live.
Pi 5-only limits come from :mod:`src.pi5_matrix_support` and are added when
``pi5=True``.
**Re-check this when the submodule is bumped** (pinned at 1ee4f76): the
ranges in ``Options::Validate()``, the mapping table in
``lib/hardware-mapping.c`` and the setter types in
``bindings/python/rgbmatrix/core.pyx``. A stale rule here blocks settings a
new library accepts; a missing one lets the display service crash-loop.
"""
from typing import Any, Dict, List, Mapping, NamedTuple, Optional, Tuple
from src.pi5_matrix_support import pi5_unsupported_settings
#: The binding's setters for rows, chain_length, parallel and the other small
#: integers are declared ``uint8_t`` (``core.pyx``), cols ``uint32_t``, and
#: limit_refresh_rate_hz lands in a C ``int``; a larger value raises
#: ``OverflowError`` before the library sees it.
UINT8_MAX = 255
UINT32_MAX = 2 ** 32 - 1
INT32_MAX = 2 ** 31 - 1
#: field -> (config section, lowest, highest, must be even). The library's own
#: ranges where it has one, otherwise the binding's integer type.
INT_SETTING_LIMITS: Dict[str, Tuple[str, int, int, bool]] = {
'rows': ('hardware', 8, 64, True),
'cols': ('hardware', 16, UINT32_MAX, False),
'chain_length': ('hardware', 1, UINT8_MAX, False),
# 3 is the most any mapping in the default build has (see MAPPING_OUTPUTS).
'parallel': ('hardware', 1, 3, False),
'brightness': ('hardware', 1, 100, False),
'scan_mode': ('hardware', 0, 1, False),
'pwm_bits': ('hardware', 1, 11, False),
'pwm_dither_bits': ('hardware', 0, 2, False),
'pwm_lsb_nanoseconds': ('hardware', 50, 3000, False),
# 0 = no cap; the library has no upper bound.
'limit_refresh_rate_hz': ('hardware', 0, INT32_MAX, False),
'row_address_type': ('hardware', 0, 5, False),
# 22 registered multiplexers (lib/multiplex-mappers.cc).
'multiplexing': ('hardware', 0, 22, False),
'gpio_slowdown': ('runtime', 0, 10, False),
'rp1_rio': ('runtime', 0, 1, False),
}
#: Hardware mapping name -> parallel chains it has outputs for, as
#: ``lib/hardware-mapping.c`` defines them. ``compute-module`` exists only when
#: the library is built with ENABLE_WIDE_GPIO_COMPUTE_MODULE, which the pinned
#: Makefile leaves commented out and the installer does not set, so the library
#: LEDMatrix installs aborts on it like any other unknown name.
MAPPING_OUTPUTS: Dict[str, int] = {
'regular': 3,
'adafruit-hat': 1,
'adafruit-hat-pwm': 1,
'regular-pi1': 1,
'classic': 3,
'classic-pi1': 1,
}
#: The refresh cap (``display.hardware.limit_refresh_rate_hz``) when config
#: omits it -- config/config.template.json's value. DisplayManager passes it to
#: the library and reports it as ``refresh_hz`` for scroll pacing, so the two
#: must be the same number: they were 90 and 100, and pacing solved against a
#: rate the panel was capped below.
DEFAULT_REFRESH_LIMIT_HZ = 100
#: What DisplayManager passes when a key is missing from display.hardware /
#: display.runtime. Config migration normally fills these from
#: config/config.template.json first, so they rarely apply.
DISPLAY_MANAGER_DEFAULTS: Dict[str, Any] = {
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90, 'pwm_bits': 10,
'pwm_lsb_nanoseconds': 150, 'led_rgb_sequence': 'RGB',
'row_address_type': 0, 'multiplexing': 0, 'limit_refresh_rate_hz': DEFAULT_REFRESH_LIMIT_HZ,
'gpio_slowdown': 3,
}
class Refusal(NamedTuple):
"""One reason the library can't start with a config.
``fields`` are the settings involved; the config API reports a refusal
only when the request sets one of them, so a problem already stored
doesn't block an unrelated save.
"""
fields: Tuple[str, ...]
message: str
#: A Raspberry Pi 5-only limit, whose message is already a full sentence.
pi5: bool = False
class MatrixSettingsRefused(RuntimeError):
"""Raised by DisplayManager instead of handing the library such a config."""
def _as_int(value: Any) -> Optional[int]:
"""The integer the binding would receive, or None if it isn't one.
A value that isn't a number is left to the binding, which raises a
``TypeError`` DisplayManager already turns into fallback mode.
"""
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return None
def _setting(hardware: Mapping[str, Any], runtime: Mapping[str, Any], field: str) -> Any:
section = runtime if INT_SETTING_LIMITS.get(field, ('hardware',))[0] == 'runtime' else hardware
if field in section:
return section[field]
return DISPLAY_MANAGER_DEFAULTS.get(field)
def describe_range(low: int, high: int, even: bool = False) -> str:
kind = "an even integer" if even else "an integer"
if high >= INT32_MAX:
return f"{kind} of at least {low}"
return f"{kind} from {low} to {high}"
def library_refusals(hardware: Optional[Mapping[str, Any]],
runtime: Optional[Mapping[str, Any]] = None,
pi5: bool = False) -> List[Refusal]:
"""Every reason the pinned library would refuse (NULL, abort or overflow)
to start with this ``display.hardware`` / ``display.runtime`` config.
Missing keys take DisplayManager's defaults. ``pi5`` adds the Raspberry
Pi 5 limits from :func:`pi5_unsupported_settings`.
"""
hardware = hardware if isinstance(hardware, Mapping) else {}
runtime = runtime if isinstance(runtime, Mapping) else {}
refusals: List[Refusal] = []
for field, (section, low, high, even) in INT_SETTING_LIMITS.items():
# DisplayManager sets these only when present, and scan_mode /
# pwm_dither_bits / rp1_rio have no default of its own.
source = runtime if section == 'runtime' else hardware
if field not in source and field not in DISPLAY_MANAGER_DEFAULTS:
continue
value = _as_int(_setting(hardware, runtime, field))
if value is None:
continue
if not low <= value <= high or (even and value % 2):
refusals.append(Refusal(
(field,), f"{field} {value} (must be {describe_range(low, high, even)})"))
mapping = _setting(hardware, runtime, 'hardware_mapping')
outputs = None
if not isinstance(mapping, str):
refusals.append(Refusal(
('hardware_mapping',), f"hardware mapping {mapping!r} (must be a mapping name)"))
else:
# The library matches names case-insensitively and reads an empty
# name as "regular".
outputs = MAPPING_OUTPUTS.get((mapping or 'regular').lower())
if outputs is None:
refusals.append(Refusal(
('hardware_mapping',),
f'hardware mapping "{mapping}" (the installed library has '
+ ", ".join(MAPPING_OUTPUTS) + ")"))
parallel = _as_int(_setting(hardware, runtime, 'parallel'))
if outputs is not None and parallel is not None and 1 <= parallel <= 3 and parallel > outputs:
refusals.append(Refusal(
('parallel', 'hardware_mapping'),
f'parallel {parallel} with hardware mapping "{mapping}", which has '
f'{outputs} output{"s" if outputs != 1 else ""}'))
sequence = _setting(hardware, runtime, 'led_rgb_sequence')
if isinstance(sequence, str) and not (
len(sequence) == 3 and set(sequence.upper()) == {'R', 'G', 'B'}):
refusals.append(Refusal(
('led_rgb_sequence',),
f'LED RGB sequence "{sequence}" (must be R, G and B in some order)'))
if pi5:
unsupported = pi5_unsupported_settings(hardware)
if unsupported:
refusals.append(Refusal(
('row_address_type', 'parallel', 'hardware_mapping'), unsupported, pi5=True))
return refusals
def refusal_message(refusals: List[Refusal]) -> Optional[str]:
"""One sentence naming every refusal, or None when there are none."""
general = [r.message for r in refusals if not r.pi5]
pi5 = [r.message for r in refusals if r.pi5]
parts = []
if general:
parts.append("The installed rgbmatrix library can't start with these "
"display settings: " + "; ".join(general) + ".")
parts.extend(pi5)
return " ".join(parts) or None