diff --git a/scripts/scroll_speeds.py b/scripts/scroll_speeds.py index e824b19f..ed4e9030 100644 --- a/scripts/scroll_speeds.py +++ b/scripts/scroll_speeds.py @@ -47,52 +47,47 @@ from src.common import scroll_config # noqa: E402 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: 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): return {} + return config if isinstance(config, dict) else {} -def build_options(hardware, refresh_override=None): - from rgbmatrix import RGBMatrixOptions - - 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 hardware_of(config): + return (config.get("display") or {}).get("hardware") or {} -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.""" if os.geteuid() != 0: sys.exit("this needs root for GPIO access - rerun with sudo") try: - from rgbmatrix import RGBMatrix - except ImportError: - sys.exit("rgbmatrix is not installed on this machine") + from src.display_manager import RGBMatrix + except ImportError as exc: + sys.exit("could not load the display stack ({}); is rgbmatrix " + "installed on this machine?".format(exc)) 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 sys.exit( "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. 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 optimistically asks for. """ - matrix = open_matrix(hardware, refresh_override=0) + matrix = open_matrix(config, refresh_override=0) canvas = matrix.CreateFrameCanvas() canvas = matrix.SwapOnVSync(canvas) # discard the first, it includes setup frames = 0 @@ -122,17 +117,17 @@ def measure_refresh(hardware, seconds=6.0): return measured -def demo(hardware, target, seconds): +def demo(config, target, seconds): """Scroll text at the crisp speed nearest `target`.""" from PIL import Image, ImageDraw, ImageFont 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) print("asked for {:.0f} px/s -> {}".format(target, choice.describe())) - matrix = open_matrix(hardware) + matrix = open_matrix(config) canvas = matrix.CreateFrameCanvas() W, H = canvas.width, canvas.height @@ -214,15 +209,15 @@ def main(): help="highlight the entry nearest this speed") args = ap.parse_args() - hardware = load_hardware() - configured = float(hardware.get("limit_refresh_rate_hz") or 0) + config = load_config() + configured = float(hardware_of(config).get("limit_refresh_rate_hz") or 0) if args.demo is not None: - demo(hardware, args.demo, args.seconds) + demo(config, args.demo, args.seconds) return if args.measure: - measured = measure_refresh(hardware) + measured = measure_refresh(config) print("measured panel refresh: {:.1f}Hz".format(measured)) if configured: print("configured limit_refresh_rate_hz: {:.0f}".format(configured)) diff --git a/src/display_manager.py b/src/display_manager.py index 7014ec6e..708b0d71 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -279,50 +279,9 @@ class DisplayManager: hardware_config = self.config.get('display', {}).get('hardware', {}) runtime_config = self.config.get('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') - 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" - ) + # Every option comes from display.hardware / display.runtime, in + # one place that scripts/scroll_speeds.py shares. + self.apply_matrix_options(options, self.config) 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}") + @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 def refresh_hz(self) -> float: """The panel's refresh rate in Hz, from the hardware config. diff --git a/test/test_scroll_speeds_script.py b/test/test_scroll_speeds_script.py new file mode 100644 index 00000000..b101670e --- /dev/null +++ b/test/test_scroll_speeds_script.py @@ -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()