fix(scripts): scroll_speeds.py opens the panel with the service's options

--measure and --demo built RGBMatrixOptions from a private copy of the
display service's builder that had drifted: gpio_slowdown came from
display.hardware (default 2) instead of display.runtime (default 3), and
rp1_rio, panel_type, disable_hardware_pulsing, inverse_colors,
pixel_mapper_config and orientation were skipped, with different defaults
(hardware_mapping "regular", pwm_bits 11). A panel needing a high slowdown
was measured -- or garbled -- in a setup the service never drives.

The option filling in DisplayManager._setup_matrix moves, unchanged, into
DisplayManager.apply_matrix_options(options, config), which _setup_matrix
calls and the script reuses (overriding only limit_refresh_rate_hz for
--measure). The script now loads the whole config rather than the hardware
block. Tests pin the script's options to the service's attribute for
attribute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-17 09:01:12 -04:00
co-authored by Claude Opus 5
parent 33c1bbe8d9
commit f867fafa08
3 changed files with 200 additions and 86 deletions
+37 -42
View File
@@ -47,52 +47,47 @@ from src.common import scroll_config # noqa: E402
CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json" CONFIG = Path(__file__).resolve().parent.parent / "config" / "config.json"
def load_hardware(): def load_config():
"""The whole config.json, or {} when it is missing or unreadable."""
try: try:
with open(CONFIG, encoding="utf-8") as handle: with open(CONFIG, encoding="utf-8") as handle:
return (json.load(handle).get("display") or {}).get("hardware") or {} config = json.load(handle)
except (OSError, ValueError): except (OSError, ValueError):
return {} return {}
return config if isinstance(config, dict) else {}
def build_options(hardware, refresh_override=None): def hardware_of(config):
from rgbmatrix import RGBMatrixOptions return (config.get("display") or {}).get("hardware") or {}
o = RGBMatrixOptions()
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
)
o.rows = int(hardware.get("rows", DEFAULT_ROWS))
o.cols = int(hardware.get("cols", DEFAULT_COLS))
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
o.brightness = int(hardware.get("brightness", 80))
o.hardware_mapping = hardware.get("hardware_mapping", "regular")
o.pwm_bits = int(hardware.get("pwm_bits", 11))
o.pwm_dither_bits = int(hardware.get("pwm_dither_bits", 0))
o.pwm_lsb_nanoseconds = int(hardware.get("pwm_lsb_nanoseconds", 130))
o.led_rgb_sequence = hardware.get("led_rgb_sequence", "RGB")
o.scan_mode = int(hardware.get("scan_mode", 0))
o.row_address_type = int(hardware.get("row_address_type", 0))
o.multiplexing = int(hardware.get("multiplexing", 0))
o.gpio_slowdown = int(hardware.get("gpio_slowdown", 2))
o.limit_refresh_rate_hz = (
int(refresh_override) if refresh_override is not None
else int(hardware.get("limit_refresh_rate_hz", 0))
)
return o
def open_matrix(hardware, refresh_override=None): def build_options(config, refresh_override=None):
"""The matrix options the display service would use for this config.
Built by DisplayManager.apply_matrix_options, not a copy of it, so the
measurement and the demo drive the panel exactly as the service does
(runtime gpio_slowdown, rp1_rio, panel_type, orientation, defaults).
``refresh_override`` replaces limit_refresh_rate_hz; 0 means uncapped.
"""
from src.display_manager import DisplayManager, RGBMatrixOptions
options = DisplayManager.apply_matrix_options(RGBMatrixOptions(), config)
if refresh_override is not None:
options.limit_refresh_rate_hz = int(refresh_override)
return options
def open_matrix(config, refresh_override=None):
"""Construct the matrix, or explain why it will not open.""" """Construct the matrix, or explain why it will not open."""
if os.geteuid() != 0: if os.geteuid() != 0:
sys.exit("this needs root for GPIO access - rerun with sudo") sys.exit("this needs root for GPIO access - rerun with sudo")
try: try:
from rgbmatrix import RGBMatrix from src.display_manager import RGBMatrix
except ImportError: except ImportError as exc:
sys.exit("rgbmatrix is not installed on this machine") sys.exit("could not load the display stack ({}); is rgbmatrix "
"installed on this machine?".format(exc))
try: try:
return RGBMatrix(options=build_options(hardware, refresh_override)) return RGBMatrix(options=build_options(config, refresh_override))
except Exception as exc: # pragma: no cover - hardware dependent except Exception as exc: # pragma: no cover - hardware dependent
sys.exit( sys.exit(
"could not open the panel ({}).\n" "could not open the panel ({}).\n"
@@ -101,7 +96,7 @@ def open_matrix(hardware, refresh_override=None):
) )
def measure_refresh(hardware, seconds=6.0): def measure_refresh(config, seconds=6.0):
"""Actual refresh rate, by running uncapped and timing the swaps. """Actual refresh rate, by running uncapped and timing the swaps.
SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop SwapOnVSync blocks until the panel's next refresh, so an unthrottled loop
@@ -109,7 +104,7 @@ def measure_refresh(hardware, seconds=6.0):
chain will really give you, as opposed to whatever limit_refresh_rate_hz chain will really give you, as opposed to whatever limit_refresh_rate_hz
optimistically asks for. optimistically asks for.
""" """
matrix = open_matrix(hardware, refresh_override=0) matrix = open_matrix(config, refresh_override=0)
canvas = matrix.CreateFrameCanvas() canvas = matrix.CreateFrameCanvas()
canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup
frames = 0 frames = 0
@@ -122,17 +117,17 @@ def measure_refresh(hardware, seconds=6.0):
return measured return measured
def demo(hardware, target, seconds): def demo(config, target, seconds):
"""Scroll text at the crisp speed nearest `target`.""" """Scroll text at the crisp speed nearest `target`."""
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import load_truetype from src.common.font_layout import load_truetype
hz = float(hardware.get("limit_refresh_rate_hz") or scroll_config.DEFAULT_REFRESH_HZ) hz = scroll_config.refresh_hz_from_config(config)
choice = scroll_config.solve_crisp(target, hz) choice = scroll_config.solve_crisp(target, hz)
print("asked for {:.0f} px/s -> {}".format(target, choice.describe())) print("asked for {:.0f} px/s -> {}".format(target, choice.describe()))
matrix = open_matrix(hardware) matrix = open_matrix(config)
canvas = matrix.CreateFrameCanvas() canvas = matrix.CreateFrameCanvas()
W, H = canvas.width, canvas.height W, H = canvas.width, canvas.height
@@ -214,15 +209,15 @@ def main():
help="highlight the entry nearest this speed") help="highlight the entry nearest this speed")
args = ap.parse_args() args = ap.parse_args()
hardware = load_hardware() config = load_config()
configured = float(hardware.get("limit_refresh_rate_hz") or 0) configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0)
if args.demo is not None: if args.demo is not None:
demo(hardware, args.demo, args.seconds) demo(config, args.demo, args.seconds)
return return
if args.measure: if args.measure:
measured = measure_refresh(hardware) measured = measure_refresh(config)
print("measured panel refresh: {:.1f}Hz".format(measured)) print("measured panel refresh: {:.1f}Hz".format(measured))
if configured: if configured:
print("configured limit_refresh_rate_hz: {:.0f}".format(configured)) print("configured limit_refresh_rate_hz: {:.0f}".format(configured))
+65 -44
View File
@@ -279,50 +279,9 @@ class DisplayManager:
hardware_config = self.config.get('display', {}).get('hardware', {}) hardware_config = self.config.get('display', {}).get('hardware', {})
runtime_config = self.config.get('display', {}).get('runtime', {}) runtime_config = self.config.get('display', {}).get('runtime', {})
# Basic hardware settings # Every option comes from display.hardware / display.runtime, in
options.rows = hardware_config.get('rows', DEFAULT_ROWS) # one place that scripts/scroll_speeds.py shares.
options.cols = hardware_config.get('cols', DEFAULT_COLS) self.apply_matrix_options(options, self.config)
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings
options.brightness = hardware_config.get('brightness', 90)
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
options.pixel_mapper_config = self._build_pixel_mapper_config(hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions
options.drop_privileges = False
# Additional settings from config
if 'scan_mode' in hardware_config:
options.scan_mode = hardware_config.get('scan_mode')
if 'pwm_dither_bits' in hardware_config:
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
if 'inverse_colors' in hardware_config:
options.inverse_colors = hardware_config.get('inverse_colors')
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
if 'rp1_rio' in runtime_config:
if hasattr(options, 'rp1_rio'):
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
)
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}") logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
@@ -1370,6 +1329,68 @@ class DisplayManager:
return dt.strftime(f"%b %-d{suffix}") return dt.strftime(f"%b %-d{suffix}")
@classmethod
def apply_matrix_options(cls, options, config: Dict[str, Any]):
"""Fill ``options`` (an ``RGBMatrixOptions``) from the LEDMatrix config.
This is exactly what the display service drives the panel with, so a
tool that opens the matrix itself (``scripts/scroll_speeds.py``) gets
the same panel -- same runtime ``gpio_slowdown``, ``rp1_rio``,
``panel_type``, orientation and defaults -- rather than a private copy
that drifts. Does not open the matrix. Returns ``options``.
"""
display = config.get('display', {}) if isinstance(config, dict) else {}
hardware_config = display.get('hardware', {})
runtime_config = display.get('runtime', {})
# Basic hardware settings
options.rows = hardware_config.get('rows', DEFAULT_ROWS)
options.cols = hardware_config.get('cols', DEFAULT_COLS)
options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings
options.brightness = hardware_config.get('brightness', 90)
options.pwm_bits = hardware_config.get('pwm_bits', 10)
options.pwm_lsb_nanoseconds = hardware_config.get('pwm_lsb_nanoseconds', 150)
options.led_rgb_sequence = hardware_config.get('led_rgb_sequence', 'RGB')
# _build_pixel_mapper_config reads only class attributes, so the class
# stands in for an instance here.
options.pixel_mapper_config = cls._build_pixel_mapper_config(cls, hardware_config)
options.row_address_type = hardware_config.get('row_address_type', 0)
options.multiplexing = hardware_config.get('multiplexing', 0)
options.panel_type = hardware_config.get('panel_type', '')
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
# Disable internal privilege dropping - we manage this via systemd or remain root
# This prevents the library from dropping to 'daemon' user which breaks file permissions
options.drop_privileges = False
# Additional settings from config
if 'scan_mode' in hardware_config:
options.scan_mode = hardware_config.get('scan_mode')
if 'pwm_dither_bits' in hardware_config:
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
if 'inverse_colors' in hardware_config:
options.inverse_colors = hardware_config.get('inverse_colors')
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
if 'rp1_rio' in runtime_config:
if hasattr(options, 'rp1_rio'):
options.rp1_rio = runtime_config.get('rp1_rio')
else:
logger.warning(
"rp1_rio is set in config but the installed rgbmatrix library does "
"not support it — the library was likely built without Pi 5 RP1 "
"support (mmap to 0x3f000000 instead of RP1 chip). "
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
)
return options
@property @property
def refresh_hz(self) -> float: def refresh_hz(self) -> float:
"""The panel's refresh rate in Hz, from the hardware config. """The panel's refresh rate in Hz, from the hardware config.
+98
View File
@@ -0,0 +1,98 @@
"""scripts/scroll_speeds.py must drive the panel the way the service does.
--measure and --demo open the matrix themselves. They used to build the
options from a private copy of DisplayManager's builder that had drifted: it
read gpio_slowdown from display.hardware (the service reads display.runtime),
and skipped rp1_rio, panel_type, orientation and more. On a panel that needs a
high slowdown that measured -- or garbled -- a panel the service never drives.
"""
import importlib.util
import os
from pathlib import Path
from unittest.mock import patch
import pytest
os.environ.setdefault("EMULATOR", "true")
import src.display_manager as display_manager # noqa: E402
from src.display_manager import DisplayManager # noqa: E402
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "scroll_speeds.py"
@pytest.fixture(scope="module")
def script():
spec = importlib.util.spec_from_file_location("scroll_speeds_script", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class _Options:
"""Records what is set; declares rp1_rio like a Pi 5-capable binding."""
rp1_rio = None
CONFIG = {
"display": {
"hardware": {
"rows": 64, "cols": 64, "chain_length": 2, "parallel": 1,
"hardware_mapping": "adafruit-hat-pwm",
"brightness": 70, "pwm_bits": 9, "pwm_lsb_nanoseconds": 130,
"row_address_type": 5, "panel_type": "FM6126A",
"limit_refresh_rate_hz": 120, "orientation": "180",
"pixel_mapper_config": "U-mapper",
"gpio_slowdown": 2, # a stale key in the wrong block
},
"runtime": {"gpio_slowdown": 7, "rp1_rio": 1},
}
}
def _script_options(script, config, **kwargs):
with patch.object(display_manager, "RGBMatrixOptions", _Options):
return script.build_options(config, **kwargs)
def test_runtime_settings_are_used(script):
options = _script_options(script, CONFIG)
assert options.gpio_slowdown == 7
assert options.rp1_rio == 1
assert options.panel_type == "FM6126A"
assert options.row_address_type == 5
assert options.pixel_mapper_config == "U-mapper;Rotate:180"
assert options.limit_refresh_rate_hz == 120
def test_the_options_match_the_display_service(script):
"""Same config in, same options out, attribute for attribute."""
service = DisplayManager.apply_matrix_options(_Options(), CONFIG)
assert vars(_script_options(script, CONFIG)) == vars(service)
def test_measure_runs_uncapped(script):
assert _script_options(script, CONFIG, refresh_override=0).limit_refresh_rate_hz == 0
def test_an_empty_config_gets_the_service_defaults(script):
options = _script_options(script, {})
assert vars(options) == vars(DisplayManager.apply_matrix_options(_Options(), {}))
assert options.gpio_slowdown == 3
def test_the_service_uses_the_shared_builder(test_config):
"""DisplayManager._setup_matrix fills its options through the same call."""
with patch.object(display_manager, "RGBMatrix"), \
patch.object(display_manager, "RGBMatrixOptions", _Options), \
patch.object(display_manager, "freetype"), \
patch.object(DisplayManager, "apply_matrix_options",
wraps=DisplayManager.apply_matrix_options) as shared, \
patch.dict(os.environ, {"EMULATOR": "false"}):
DisplayManager._instance = None
try:
DisplayManager(test_config)
finally:
DisplayManager._instance = None
shared.assert_called_once()