From 94ec74caa551b3af2aaade18e9f0b51e17671956 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:59:47 -0400 Subject: [PATCH] fix(display): refuse settings the rgbmatrix library aborts on, on every board The library answers several settings with a NULL matrix or abort() rather than an error, so the display service crash-looped (Restart=on-failure) instead of reaching fallback mode: rows above 64, chain_length above 255 (uint8_t binding setter, documented as "no upper limit"), a misspelled hardware_mapping, and parallel 2-3 on a single-output mapping, reachable from the Display form on the default adafruit-hat(-pwm) mapping. #586 only guarded the Pi 5 subset. - src/matrix_support.py holds the rules for every board (Options::Validate ranges, binding integer types, mapping names and outputs from lib/hardware-mapping.c) plus the Pi 5 ones, and is the one source of the API's numeric ranges. - DisplayManager checks them before building options and raises MatrixSettingsRefused, so a hand-edited config falls back with a logged, reported reason. Emulator mode only warns. - The config API refuses them with a 400 naming the setting; combinations are checked against stored values but reported only when the request sets a field involved. - The hardware status file gains "cause" (settings/library/forced). The fallback log and Display banner give the Pi 5 rebuild hint only for a library failure instead of rebuild + gpio_slowdown advice for every failure; one Pi 5 slowdown recommendation (1-3, start at 1). - The Display form offers classic/classic-pi1 and orientation 90/270 and renders any other stored mapping selected with a warning, so an unrelated save no longer rewrites them; the API accepts 90/270. Audit findings F03, F16, F19, F21. Co-Authored-By: Claude Opus 5 --- src/display_manager.py | 90 +++++--- src/matrix_support.py | 205 ++++++++++++++++++ test/test_api_v3_display_hardware.py | 128 ++++++++++- test/test_display_manager.py | 104 ++++++++- test/test_matrix_support.py | 125 +++++++++++ web_interface/blueprints/api_v3/config.py | 67 +++--- .../templates/v3/partials/display.html | 64 ++++-- 7 files changed, 680 insertions(+), 103 deletions(-) create mode 100644 src/matrix_support.py create mode 100644 test/test_matrix_support.py diff --git a/src/display_manager.py b/src/display_manager.py index a0b105fa..a9f5b785 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -37,9 +37,11 @@ from PIL import Image, ImageDraw, ImageFont from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.display_geometry import ( DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS, - physical_size, resolve_double_sided, + ORIENTATION_ROTATE_DEGREES, compose_pixel_mapper_config, physical_size, + resolve_double_sided, ) -from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings +from src.matrix_support import MatrixSettingsRefused, library_refusals, refusal_message +from src.pi5_matrix_support import is_raspberry_pi_5 import threading import time from collections import OrderedDict @@ -247,28 +249,41 @@ class DisplayManager: # Calendar manager is now initialized by DisplayController # Orientation setting -> rpi-rgb-led-matrix "Rotate:" pixel-mapper suffix. - # "normal" needs no suffix since 0 degrees is the identity transform. - _ORIENTATION_ROTATE_DEGREES = {'normal': None, '90': 90, '180': 180, '270': 270} + _ORIENTATION_ROTATE_DEGREES = ORIENTATION_ROTATE_DEGREES def _build_pixel_mapper_config(self, hardware_config: dict) -> str: - """Compose the raw pixel_mapper_config string with the orientation setting. + """Compose pixel_mapper_config with the orientation setting. - `pixel_mapper_config` stays available as a free-form advanced field (e.g. - for "U-mapper" chain layouts); `orientation` is the user-facing dropdown - for physical mounting (e.g. panels mounted upside down) and is appended as - a "Rotate:" mapper rather than overwriting any existing config. + See :func:`src.display_geometry.compose_pixel_mapper_config`, which the + web preview shares so it sizes the canvas the same way. """ - base_mapper = (hardware_config.get('pixel_mapper_config') or '').strip() - orientation = hardware_config.get('orientation', 'normal') - degrees = self._ORIENTATION_ROTATE_DEGREES.get(orientation) - if degrees is None: - return base_mapper - rotate_mapper = f'Rotate:{degrees}' - return f'{base_mapper};{rotate_mapper}' if base_mapper else rotate_mapper + return compose_pixel_mapper_config(hardware_config) + + @staticmethod + def _fallback_advice(cause: str, error: Exception) -> str: + """What to do about a failed matrix init, for the log. + + Only a library failure gets the rebuild hint: advice about the build + or GPIO timing sends someone whose settings were refused the wrong way. + """ + if cause == "settings": + return (f"{error} Change these in the web interface's Display tab " + "(or display.hardware / display.runtime in config.json) " + "and restart the display service.") + if cause == "forced": + return f"Error: {error}." + advice = (f"Error: {error}. If the rgbmatrix library printed a message " + "just before this, it names the problem.") + if is_raspberry_pi_5(): + advice += (" On a Raspberry Pi 5, an mmap error means the library was " + "built without Pi 5 support: sudo RPI_RGB_FORCE_REBUILD=1 " + "./first_time_install.sh") + return advice def _setup_matrix(self): """Initialize the RGB matrix with configuration settings.""" _init_error_str = None + _init_cause = None try: # Allow callers (e.g., web UI) to force non-hardware fallback mode if getattr(self, '_force_fallback', False): @@ -278,6 +293,18 @@ class DisplayManager: # Hardware configuration hardware_config = self.config.get('display', {}).get('hardware', {}) runtime_config = self.config.get('display', {}).get('runtime', {}) + + # The library has no error path for many settings it can't use: + # it returns no matrix (which the binding doesn't check, so the + # process crashes on its next call) or calls abort(), and systemd + # restarts the service into the same crash. Refuse those first so + # they become a logged, reported fallback (src/matrix_support.py). + refused = refusal_message(library_refusals( + hardware_config, runtime_config, pi5=is_raspberry_pi_5())) + if refused: + if os.getenv("EMULATOR", "false") != "true": + raise MatrixSettingsRefused(refused) + logger.warning("Emulator mode: continuing, but on a real panel the display would not start. %s", refused) # Basic hardware settings options.rows = hardware_config.get('rows', DEFAULT_ROWS) @@ -326,15 +353,6 @@ class DisplayManager: 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}") - # On a Pi 5 the library hands back no matrix for settings its RP1 - # path can't drive, and the binding doesn't check -- the process - # would crash on its next call instead of reaching the fallback - # below. Raise first so it is a logged, reported init failure. - if os.getenv("EMULATOR", "false") != "true" and is_raspberry_pi_5(): - unsupported = pi5_unsupported_settings(hardware_config) - if unsupported: - raise RuntimeError(unsupported) - # Initialize the matrix self.matrix = RGBMatrix(options=options) logger.info("RGB Matrix initialized successfully") @@ -379,7 +397,12 @@ class DisplayManager: except Exception as e: _init_error_str = str(e) - logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True) + if isinstance(e, MatrixSettingsRefused): + _init_cause = "settings" + logger.error("Failed to initialize RGB Matrix: %s", e) + else: + _init_cause = "forced" if getattr(self, '_force_fallback', False) else "library" + logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True) # Create a fallback image for web preview using configured dimensions when available self.matrix = None try: @@ -406,15 +429,18 @@ class DisplayManager: # Best-effort; ignore drawing errors in fallback pass logger.error( - f"Matrix initialization failed — running in fallback/simulation mode " - f"(size {fallback_width}x{fallback_height}). Error: {e}. " - "On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest " - "submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode." - ) + "Matrix initialization failed — running in fallback/simulation mode " + "(size %dx%d). %s", + fallback_width, fallback_height, self._fallback_advice(_init_cause, e)) # Do not raise here; allow fallback mode so web preview and non-hardware environments work # Write hardware status file so the web UI can surface init failures - _hw_status = {"ok": self.matrix is not None, "error": _init_error_str} + # cause: None when ok; "settings" when LEDMatrix refused the config + # (fix the named settings), "library" when the library itself failed, + # "forced" for a caller-requested fallback. The Display tab keys its + # advice on it. + _hw_status = {"ok": self.matrix is not None, "error": _init_error_str, + "cause": None if self.matrix is not None else _init_cause} _status_path = "/tmp/led_matrix_hw_status.json" # nosec B108 try: if os.path.islink(_status_path): diff --git a/src/matrix_support.py b/src/matrix_support.py new file mode 100644 index 00000000..b99d4e08 --- /dev/null +++ b/src/matrix_support.py @@ -0,0 +1,205 @@ +"""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, +} + +#: 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': 90, + '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 diff --git a/test/test_api_v3_display_hardware.py b/test/test_api_v3_display_hardware.py index c367f67b..6e31bc81 100644 --- a/test/test_api_v3_display_hardware.py +++ b/test/test_api_v3_display_hardware.py @@ -1,9 +1,10 @@ """Display hardware settings accept what the rgbmatrix library accepts. -Held to the ranges in the pinned library (RGBMatrix::Options::Validate in -lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc), with -one deliberate exception: rows has no upper bound here, although the library -currently rejects more than 64 per panel. Two ways this used to go wrong: +Held to what the pinned library and its Python binding accept +(src/matrix_support.py: RGBMatrix::Options::Validate in +lib/options-initialize.cc, the gpio_slowdown check in lib/led-matrix.cc, the +mapping table in lib/hardware-mapping.c and the binding's uint8_t setters). +Ways this used to go wrong: - The Display form capped cols at 128, chain_length at 24 and pwm_lsb_nanoseconds at 500, and its submit handler (fixInvalidNumberInputs) @@ -11,6 +12,10 @@ currently rejects more than 64 per panel. Two ways this used to go wrong: a long chain silently saved as the wrong size. - The API checked none of these, so a value the library rejects (odd rows, parallel 4, pwm_dither_bits 3) saved, and the matrix then refused to start. +- After that, rows above 64, chain_length above 255, a misspelled hardware + mapping and parallel 2-3 on a single-output HAT mapping still saved. The + library answers those with no matrix or abort(), not an error, so the + display service crash-looped instead of falling back. Row address type 5 is the SM5368 / B707 row shift register the Waveshare 96x48 V2 needs (Waveshare's own "96X48_1_24_SM5368" panel type in their library fork @@ -102,10 +107,9 @@ def test_waveshare_96x48_v2_settings_all_save(api_v3_client, saved, as_strings): @pytest.mark.parametrize('field,value', [ - ('rows', 8), ('rows', 64), ('rows', 96), ('rows', 128), + ('rows', 8), ('rows', 64), ('cols', 16), ('cols', 192), ('cols', 512), - ('chain_length', 1), ('chain_length', 32), - ('parallel', 3), + ('chain_length', 1), ('chain_length', 32), ('chain_length', 255), ('row_address_type', 0), ('row_address_type', 5), ('row_address_type', 5.0), ('multiplexing', 0), ('multiplexing', 22), ('gpio_slowdown', 0), ('gpio_slowdown', 10), @@ -123,9 +127,9 @@ def test_values_in_range_are_saved(api_v3_client, saved, field, value): @pytest.mark.parametrize('field,value', [ - ('rows', 6), ('rows', 47), ('rows', 97), ('rows', '48.5'), + ('rows', 6), ('rows', 47), ('rows', 66), ('rows', 96), ('rows', 128), ('rows', '48.5'), ('cols', 15), ('cols', 96.5), ('cols', True), ('cols', 'wide'), - ('chain_length', 0), + ('chain_length', 0), ('chain_length', 256), ('chain_length', 300), ('parallel', 0), ('parallel', 4), ('row_address_type', -1), ('row_address_type', 6), ('row_address_type', True), ('row_address_type', 5.5), @@ -146,6 +150,69 @@ def test_values_out_of_range_are_refused(api_v3_client, saved, field, value): assert 'config' not in saved +@pytest.mark.parametrize('body', [ + {'hardware_mapping': 'regular', 'parallel': 3}, + {'hardware_mapping': 'classic', 'parallel': 2}, + {'hardware_mapping': 'Regular', 'parallel': 3}, + {'hardware_mapping': 'classic-pi1'}, + {'hardware_mapping': 'adafruit-hat', 'parallel': 1}, +]) +def test_mappings_the_library_has_are_saved(api_v3_client, saved, body): + response = _post(api_v3_client, body) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + for field, value in body.items(): + assert saved['config']['display']['hardware'][field] == value + + +@pytest.mark.parametrize('body,named', [ + # Framebuffer() abort()s: the HAT mappings define one output. + ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'), + ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'), + ({'hardware_mapping': 'regular-pi1', 'parallel': 2}, 'parallel 2'), + # InitHardwareMapping() abort()s on a name it doesn't have; compute-module + # isn't compiled into the default build. + ({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'), + ({'hardware_mapping': 'compute-module'}, 'compute-module'), + ({'hardware_mapping': 5}, 'hardware mapping'), +]) +def test_combinations_the_library_aborts_on_are_refused(api_v3_client, saved, body, named): + response = _post(api_v3_client, body) + assert response.status_code == 400 + assert named in response.get_json()['message'] + assert 'config' not in saved + + +def test_parallel_is_checked_against_the_stored_mapping(api_v3_client, api_v3_module, saved): + api_v3_module.api_v3.config_manager.load_config.return_value = { + 'display': {'hardware': {'hardware_mapping': 'adafruit-hat'}}} + response = _post(api_v3_client, {'parallel': 2}) + assert response.status_code == 400 + assert 'adafruit-hat' in response.get_json()['message'] + assert 'config' not in saved + + +def test_stored_refusal_does_not_block_unrelated_saves(api_v3_client, api_v3_module, saved): + api_v3_module.api_v3.config_manager.load_config.return_value = { + 'display': {'hardware': {'hardware_mapping': 'adafruit-hat', 'parallel': 2}}} + response = _post(api_v3_client, {'brightness': 70}) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + + +def test_a_request_value_is_checked_not_the_stored_one(api_v3_client, api_v3_module, saved): + """Fixing a stored bad value in the same save as a mapping change must work.""" + api_v3_module.api_v3.config_manager.load_config.return_value = { + 'display': {'hardware': {'rows': 128, 'parallel': 2}}} + response = _post(api_v3_client, {'rows': 64, 'hardware_mapping': 'regular'}) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + + +@pytest.mark.parametrize('orientation', ['normal', '90', '180', '270']) +def test_every_orientation_display_manager_applies_is_saved(api_v3_client, saved, orientation): + response = _post(api_v3_client, {'orientation': orientation}) + assert response.status_code == 200, response.get_data(as_text=True)[:200] + assert saved['config']['display']['hardware']['orientation'] == orientation + + @pytest.fixture def display_page(monkeypatch): """Render the Display settings partial for a given config.""" @@ -231,6 +298,38 @@ def test_waveshare_96x48_v2_config_renders_back_unchanged(display_page): assert _attr(_input_tag(body, input_id), 'value') == str(WAVESHARE_96X48_V2[input_id]), input_id +@pytest.mark.parametrize('hardware,select_id,expected', [ + ({'hardware_mapping': 'classic'}, 'hardware_mapping', 'classic'), + ({'hardware_mapping': 'classic-pi1'}, 'hardware_mapping', 'classic-pi1'), + ({'hardware_mapping': 'adafruit-hat'}, 'hardware_mapping', 'adafruit-hat'), + ({'hardware_mapping': 'Regular'}, 'hardware_mapping', 'regular'), + ({'hardware_mapping': ''}, 'hardware_mapping', 'regular'), + ({'orientation': '90'}, 'orientation', '90'), + ({'orientation': '270'}, 'orientation', '270'), +]) +def test_stored_mapping_and_orientation_render_back_unchanged(display_page, hardware, select_id, expected): + """With nothing selected the browser posts the first option, so one unrelated + Display save turned classic into adafruit-hat-pwm and 90 degrees into normal.""" + body = display_page(_config_with(hardware=hardware)) + assert _selected_option(body, select_id) == [expected] + assert "saved hardware mapping" not in body + + +def test_missing_mapping_renders_display_managers_default(display_page): + config = _config_with() + del config['display']['hardware']['hardware_mapping'] + assert _selected_option(display_page(config), 'hardware_mapping') == ['adafruit-hat-pwm'] + + +@pytest.mark.parametrize('stored', ['compute-module', 'adafruit-hat-pwn']) +def test_unusable_stored_mapping_renders_selected_with_a_warning(display_page, stored): + """Kept selected rather than silently swapped for the first option; the API + refuses it on save, and the warning says why.""" + body = display_page(_config_with(hardware={'hardware_mapping': stored})) + assert _selected_option(body, 'hardware_mapping') == [stored] + assert f'Your saved hardware mapping ("{stored}")' in body + + @pytest.mark.parametrize('field,section', [ ('gpio_slowdown', 'runtime'), ('pwm_dither_bits', 'hardware'), ('limit_refresh_rate_hz', 'hardware'), @@ -248,7 +347,7 @@ def test_a_stored_zero_renders_as_zero(display_page, field, section): @pytest.mark.parametrize('body', [ {'row_address_type': 5}, {'row_address_type': '1'}, - {'hardware_mapping': 'compute-module'}, + {'hardware_mapping': 'classic-pi1'}, ]) def test_pi5_refuses_what_its_library_cannot_drive(api_v3_client, saved, board, body): board(PI5_MODEL) @@ -296,6 +395,15 @@ def test_pi5_form_offers_only_supported_row_address_types(display_page, board): assert "can't be used on this Raspberry Pi 5" not in body +def test_pi5_form_offers_only_mappings_it_can_drive(display_page, board): + board(PI5_MODEL) + body = display_page(_config_with()) + assert 'classic-pi1' not in _option_values(body, 'hardware_mapping') + body = display_page(_config_with(hardware={'hardware_mapping': 'classic-pi1'})) + assert _selected_option(body, 'hardware_mapping') == ['classic-pi1'] + assert 'can use on a Raspberry Pi 5' in body + + def test_other_boards_offer_every_row_address_type(display_page): body = display_page(_config_with()) assert _option_values(body, 'row_address_type') == ['0', '1', '2', '3', '4', '5'] diff --git a/test/test_display_manager.py b/test/test_display_manager.py index f583015c..d579ac85 100644 --- a/test/test_display_manager.py +++ b/test/test_display_manager.py @@ -265,6 +265,14 @@ class TestDisplayManagerOrientation: options = mock_rgb_matrix['options_class'].return_value assert options.pixel_mapper_config == '' + @pytest.mark.parametrize('orientation', ['90', '270']) + def test_sideways_orientation_appends_rotate_mapper(self, mock_rgb_matrix, orientation): + DisplayManager._instance = None + with patch.dict('os.environ', {'EMULATOR': 'false'}): + DisplayManager(self._config(orientation=orientation), suppress_test_pattern=True) + options = mock_rgb_matrix['options_class'].return_value + assert options.pixel_mapper_config == f'Rotate:{orientation}' + def test_orientation_180_appends_rotate_mapper(self, mock_rgb_matrix): DisplayManager._instance = None with patch.dict('os.environ', {'EMULATOR': 'false'}): @@ -281,10 +289,11 @@ class TestDisplayManagerOrientation: assert options.pixel_mapper_config == 'U-mapper;Rotate:180' -class TestDisplayManagerPi5Guard: - """On a Pi 5 the library returns no matrix for settings its RP1 path can't - drive, and the binding doesn't check, so the process would crash on its next - call. DisplayManager has to refuse before creating the matrix and fall back.""" +class TestDisplayManagerLibraryGuard: + """For many settings it can't use the library returns no matrix (which the + binding doesn't check, so the process crashes on its next call) or calls + abort() -- on every board, with more on a Pi 5. DisplayManager has to refuse + before creating the matrix and fall back, reporting why.""" def _config(self, **hardware_overrides): config = { @@ -327,10 +336,95 @@ class TestDisplayManagerPi5Guard: mock_rgb_matrix['matrix_class'].assert_called_once() assert dm.matrix is not None - def test_other_boards_are_left_to_the_library(self, mock_rgb_matrix, board): + def test_pi5_only_limits_do_not_apply_to_other_boards(self, mock_rgb_matrix, board): board('Raspberry Pi 4 Model B Rev 1.5') DisplayManager._instance = None with patch.dict('os.environ', {'EMULATOR': 'false'}): dm = DisplayManager(self._config(row_address_type=5), suppress_test_pattern=True) mock_rgb_matrix['matrix_class'].assert_called_once() assert dm.matrix is not None + + @pytest.fixture + def hw_status(self, tmp_path, monkeypatch): + """What DisplayManager writes to /tmp/led_matrix_hw_status.json.""" + import json as _json + import tempfile as _tempfile + from src import display_manager as dm_module + written = {} + real_replace = os.replace + real_mkstemp = _tempfile.mkstemp + + def fake_mkstemp(dir=None, prefix=None): + return real_mkstemp(dir=str(tmp_path), prefix=prefix) + + def fake_replace(src, dst): + if str(dst).endswith('led_matrix_hw_status.json'): + with open(src) as f: + written.update(_json.load(f)) + os.remove(src) + else: + real_replace(src, dst) + + monkeypatch.setattr(dm_module.tempfile, 'mkstemp', fake_mkstemp) + monkeypatch.setattr(dm_module.os, 'replace', fake_replace) + monkeypatch.setattr(dm_module.os.path, 'islink', lambda _p: False) + return written + + @pytest.mark.parametrize('overrides,named', [ + ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, 'parallel 2'), + ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, 'parallel 3'), + ({'hardware_mapping': 'adafruit-hat-pwn'}, 'adafruit-hat-pwn'), + ({'rows': 128}, 'rows 128'), + ({'chain_length': 300}, 'chain_length 300'), + ]) + def test_hand_edited_setting_the_library_refuses_falls_back_on_any_board( + self, mock_rgb_matrix, board, hw_status, overrides, named): + board('Raspberry Pi 4 Model B Rev 1.5') + DisplayManager._instance = None + with patch.dict('os.environ', {'EMULATOR': 'false'}): + dm = DisplayManager(self._config(**overrides), suppress_test_pattern=True) + mock_rgb_matrix['matrix_class'].assert_not_called() + assert dm.matrix is None + assert hw_status['ok'] is False + assert hw_status['cause'] == 'settings' + assert named in hw_status['error'] + + def test_library_failure_is_reported_as_the_library(self, mock_rgb_matrix, board, hw_status): + board('Raspberry Pi 4 Model B Rev 1.5') + mock_rgb_matrix['matrix_class'].side_effect = RuntimeError('boom') + DisplayManager._instance = None + with patch.dict('os.environ', {'EMULATOR': 'false'}): + dm = DisplayManager(self._config(), suppress_test_pattern=True) + assert dm.matrix is None + assert hw_status == {'ok': False, 'error': 'boom', 'cause': 'library'} + + def test_success_reports_no_cause(self, mock_rgb_matrix, board, hw_status): + board('Raspberry Pi 4 Model B Rev 1.5') + DisplayManager._instance = None + with patch.dict('os.environ', {'EMULATOR': 'false'}): + DisplayManager(self._config(), suppress_test_pattern=True) + assert hw_status == {'ok': True, 'error': None, 'cause': None} + + def test_emulator_warns_but_still_starts(self, mock_rgb_matrix, board, caplog): + board('Raspberry Pi 4 Model B Rev 1.5') + DisplayManager._instance = None + with patch.dict('os.environ', {'EMULATOR': 'true'}): + dm = DisplayManager(self._config(rows=128), suppress_test_pattern=True) + mock_rgb_matrix['matrix_class'].assert_called_once() + assert dm.matrix is not None + assert 'rows 128' in caplog.text + + def test_refused_settings_advice_does_not_send_users_to_rebuild(self, board): + """The Pi 5 rebuild / GPIO slowdown hint used to follow every failure, + including settings LEDMatrix itself refused.""" + from src.matrix_support import MatrixSettingsRefused + board('Raspberry Pi 5 Model B Rev 1.0') + advice = DisplayManager._fallback_advice( + 'settings', MatrixSettingsRefused('row address type 5')) + assert 'Display tab' in advice + assert 'first_time_install' not in advice and 'slowdown' not in advice + library = DisplayManager._fallback_advice('library', RuntimeError('mmap failed')) + assert 'RPI_RGB_FORCE_REBUILD=1' in library + board('Raspberry Pi 4 Model B Rev 1.5') + assert 'RPI_RGB_FORCE_REBUILD' not in DisplayManager._fallback_advice( + 'library', RuntimeError('boom')) diff --git a/test/test_matrix_support.py b/test/test_matrix_support.py new file mode 100644 index 00000000..8f82a44f --- /dev/null +++ b/test/test_matrix_support.py @@ -0,0 +1,125 @@ +"""The library-refusal rules in src/matrix_support.py mirror the pinned library. + +rpi-rgb-led-matrix-master at 1ee4f76: RGBMatrix::Options::Validate in +lib/options-initialize.cc, the runtime checks in RGBMatrix::CreateFromOptions +(lib/led-matrix.cc), the mapping table in lib/hardware-mapping.c with the +abort()s in lib/framebuffer.cc, and the setter types in +bindings/python/rgbmatrix/core.pyx. When the submodule is bumped and those +change, these are the cases to revisit. +""" +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src import matrix_support as ms # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _messages(hardware, runtime=None, pi5=False): + return [r.message for r in ms.library_refusals(hardware, runtime, pi5=pi5)] + + +def test_the_config_template_starts(): + template = json.loads((REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8')) + display = template['display'] + assert ms.library_refusals(display['hardware'], display['runtime']) == [] + assert ms.library_refusals(display['hardware'], display['runtime'], pi5=True) == [] + + +def test_display_manager_defaults_start(): + assert ms.library_refusals({}) == [] + + +@pytest.mark.parametrize('hardware', [ + {'rows': 8}, {'rows': 64}, {'cols': 16}, {'cols': 1024}, + {'chain_length': 1}, {'chain_length': 255}, + {'hardware_mapping': 'regular', 'parallel': 3}, + {'hardware_mapping': 'classic', 'parallel': 3}, + {'hardware_mapping': 'REGULAR', 'parallel': 2}, + {'hardware_mapping': '', 'parallel': 3}, # empty reads as "regular" + {'hardware_mapping': 'classic-pi1'}, + {'hardware_mapping': 'regular-pi1', 'parallel': 1}, + {'pwm_bits': 11}, {'pwm_dither_bits': 2}, {'pwm_lsb_nanoseconds': 3000}, + {'row_address_type': 5}, {'multiplexing': 22}, {'scan_mode': 1}, + {'brightness': 1}, {'limit_refresh_rate_hz': 0}, + {'led_rgb_sequence': 'bgr'}, {'led_rgb_sequence': 'GBR'}, + # Not a number: left to the binding, which raises a TypeError. + {'rows': 'thirty-two'}, +]) +def test_what_the_library_starts_with(hardware): + assert ms.library_refusals(hardware) == [] + + +@pytest.mark.parametrize('hardware,runtime,named', [ + ({'rows': 66}, None, 'rows 66'), + ({'rows': 128}, None, 'rows 128'), + ({'rows': 31}, None, 'rows 31'), + ({'cols': 8}, None, 'cols 8'), + ({'chain_length': 0}, None, 'chain_length 0'), + ({'chain_length': 256}, None, 'chain_length 256'), # uint8_t setter + ({'hardware_mapping': 'regular', 'parallel': 4}, None, 'parallel 4'), + ({'hardware_mapping': 'adafruit-hat-pwm', 'parallel': 2}, None, 'which has 1 output'), + ({'hardware_mapping': 'adafruit-hat', 'parallel': 3}, None, 'which has 1 output'), + ({'hardware_mapping': 'regular-pi1', 'parallel': 2}, None, 'which has 1 output'), + ({'hardware_mapping': 'classic-pi1', 'parallel': 2}, None, 'which has 1 output'), + ({'parallel': 2}, None, 'adafruit-hat-pwm'), # DisplayManager's default mapping + ({'hardware_mapping': 'adafruit-hat-pwn'}, None, '"adafruit-hat-pwn"'), + ({'hardware_mapping': 'compute-module'}, None, '"compute-module"'), + ({'hardware_mapping': None}, None, 'hardware mapping None'), + ({'brightness': 0}, None, 'brightness 0'), + ({'pwm_bits': 12}, None, 'pwm_bits 12'), + ({'pwm_dither_bits': 3}, None, 'pwm_dither_bits 3'), + ({'pwm_lsb_nanoseconds': 49}, None, 'pwm_lsb_nanoseconds 49'), + ({'row_address_type': 6}, None, 'row_address_type 6'), + ({'multiplexing': 23}, None, 'multiplexing 23'), + ({'scan_mode': 2}, None, 'scan_mode 2'), + ({'led_rgb_sequence': 'RGBW'}, None, 'LED RGB sequence'), + ({'led_rgb_sequence': 'RRB'}, None, 'LED RGB sequence'), + ({}, {'gpio_slowdown': 11}, 'gpio_slowdown 11'), + ({}, {'gpio_slowdown': -1}, 'gpio_slowdown -1'), + ({}, {'rp1_rio': 2}, 'rp1_rio 2'), +]) +def test_what_it_refuses(hardware, runtime, named): + messages = _messages(hardware, runtime) + assert any(named in m for m in messages), messages + + +def test_pi5_limits_apply_only_on_a_pi5(): + assert ms.library_refusals({'row_address_type': 5}) == [] + refusals = ms.library_refusals({'row_address_type': 5}, pi5=True) + assert len(refusals) == 1 and refusals[0].pi5 + assert set(refusals[0].fields) == {'row_address_type', 'parallel', 'hardware_mapping'} + + +def test_refusal_names_the_fields_involved(): + (refusal,) = ms.library_refusals({'hardware_mapping': 'adafruit-hat', 'parallel': 2}) + assert set(refusal.fields) == {'parallel', 'hardware_mapping'} + (refusal,) = ms.library_refusals({'rows': 128}) + assert refusal.fields == ('rows',) + + +def test_message_names_every_problem(): + message = ms.refusal_message(ms.library_refusals( + {'rows': 128, 'row_address_type': 5}, {'gpio_slowdown': 11}, pi5=True)) + assert message.startswith("The installed rgbmatrix library can't start") + assert 'rows 128' in message and 'gpio_slowdown 11' in message + assert 'Raspberry Pi 5' in message and 'row address type 5' in message + assert ms.refusal_message([]) is None + + +def test_non_mapping_sections_are_treated_as_empty(): + assert ms.library_refusals('oops', ['a']) == [] + + +def test_display_manager_defaults_match_display_manager(): + """The guard fills missing keys the way DisplayManager._setup_matrix does.""" + source = (REPO_ROOT / 'src' / 'display_manager.py').read_text(encoding='utf-8') + for field, value in ms.DISPLAY_MANAGER_DEFAULTS.items(): + if field in ('rows', 'cols', 'chain_length', 'parallel'): + continue # read through display_geometry's DEFAULT_* constants + assert f"get('{field}', {value!r})" in source, field diff --git a/web_interface/blueprints/api_v3/config.py b/web_interface/blueprints/api_v3/config.py index d1bd34d7..64ed15ef 100644 --- a/web_interface/blueprints/api_v3/config.py +++ b/web_interface/blueprints/api_v3/config.py @@ -12,7 +12,9 @@ from web_interface.blueprints.api_v3 import ( success_response, ) from src.common.path_safety import resolve_under -from src.pi5_matrix_support import is_raspberry_pi_5, pi5_unsupported_settings +from src.display_geometry import ORIENTATION_ROTATE_DEGREES +from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message +from src.pi5_matrix_support import is_raspberry_pi_5 import web_interface.blueprints.api_v3 as _pkg # Read through the module rather than bound by value: tests patch these # as module attributes, and a value binding would not see the patch. @@ -561,28 +563,20 @@ def save_main_config(): return jsonify({'status': 'error', 'message': 'pixel_mapper_config must be a string (e.g. "U-mapper;Rotate:90" or empty)'}), 400 # Validate orientation (physical mounting rotation; composed onto pixel_mapper_config at runtime) - ORIENTATION_ALLOWED = {'normal', '180'} + ORIENTATION_ALLOWED = set(ORIENTATION_ROTATE_DEGREES) if 'orientation' in data and data['orientation'] not in ORIENTATION_ALLOWED: return jsonify({'status': 'error', 'message': f"Invalid orientation '{data['orientation']}'. Allowed values: {', '.join(sorted(ORIENTATION_ALLOWED))}"}), 400 - # Panel geometry, PWM and GPIO timing, held to what the rgbmatrix library - # accepts (RGBMatrix::Options::Validate in lib/options-initialize.cc, - # the gpio_slowdown check in lib/led-matrix.cc). Outside those ranges - # the config used to save, then the matrix refused to start and the - # display dropped to fallback mode. cols, chain_length and - # limit_refresh_rate_hz (0 = no cap) have no upper bound in the - # library. rows has none here by choice: the library currently - # rejects more than 64 per panel, and that limit is left to it so a - # library that lifts it needs no change here. - def _hardware_int_error(field, low, high=None, even=False): + # Panel geometry, PWM and GPIO timing, held to what the rgbmatrix + # library and its Python binding accept (src/matrix_support.py: + # Options::Validate, the gpio_slowdown check, and the binding's + # uint8_t setters, which cap chain_length at 255). Outside those + # ranges the library returns no matrix and the display service + # crash-loops rather than falling back, so they never save. + def _hardware_int_error(field, low, high, even=False): """A 400 response if data[field] is not an allowed integer, else None.""" raw = data[field] - kind = "an even integer" if even else "an integer" - if high is None: - allowed = f"{kind} of at least {low}" - else: - allowed = f"{kind} from {low} to {high}" - rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {allowed}."}), 400) + rejection = (jsonify({'status': 'error', 'message': f"Invalid {field} '{raw}'. Must be {describe_range(low, high, even)}."}), 400) # int() would quietly turn true into 1 and 48.5 into 48. if isinstance(raw, bool) or (isinstance(raw, float) and not raw.is_integer()): return rejection @@ -590,34 +584,31 @@ def save_main_config(): value = int(raw) except (ValueError, TypeError, OverflowError): return rejection - if value < low or (high is not None and value > high) or (even and value % 2): + if value < low or value > high or (even and value % 2): return rejection return None - for field, low, high, even in (('rows', 8, None, True), ('cols', 16, None, False), - ('chain_length', 1, None, False), ('parallel', 1, 3, False), - ('brightness', 1, 100, False), ('scan_mode', 0, 1, False), - ('pwm_bits', 1, 11, False), ('pwm_dither_bits', 0, 2, False), - ('pwm_lsb_nanoseconds', 50, 3000, False), - ('limit_refresh_rate_hz', 0, None, False), - ('row_address_type', 0, 5, False), ('multiplexing', 0, 22, False), - ('gpio_slowdown', 0, 10, False)): - if field in data: + # rp1_rio has its own check below. + for field, (_section, low, high, even) in INT_SETTING_LIMITS.items(): + if field in data and field != 'rp1_rio': error = _hardware_int_error(field, low, high, even) if error: return error - # A Pi 5 can't drive every combination (src/pi5_matrix_support.py), - # and one it can't crashes the display service instead of falling - # back. Checked only when this request sets one of those fields, so - # a combination already stored doesn't block unrelated saves. - pi5_fields = ('row_address_type', 'parallel', 'hardware_mapping') - if any(k in data for k in pi5_fields) and is_raspberry_pi_5(): + # Combinations the library can't start with: a hardware mapping it + # doesn't have, more parallel chains than the mapping has outputs, + # and on a Pi 5 its narrower RP1 support. Reported only when this + # request sets one of the settings involved, so a problem already + # stored doesn't block unrelated saves. + combination_fields = ('hardware_mapping', 'parallel', 'row_address_type') + if any(k in data for k in combination_fields): effective = dict(current_config['display']['hardware']) - effective.update({k: data[k] for k in pi5_fields if k in data}) - unsupported = pi5_unsupported_settings(effective) - if unsupported: - return jsonify({'status': 'error', 'message': unsupported}), 400 + effective.update({k: v for k, v in data.items() + if k in combination_fields or k in INT_SETTING_LIMITS}) + refusals = [r for r in library_refusals(effective, pi5=is_raspberry_pi_5()) + if any(f in data for f in r.fields)] + if refusals: + return jsonify({'status': 'error', 'message': refusal_message(refusals)}), 400 # Handle hardware settings for field in ['rows', 'cols', 'chain_length', 'parallel', 'brightness', 'hardware_mapping', 'scan_mode', diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index f80f93bf..0fa80a17 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -8,21 +8,26 @@ {{ ui.settings_filter('Filter display settings…') }} - @@ -41,7 +46,7 @@
- +
- +
- + + {#- Saving posts what is selected, so a stored value must render selected + even when it isn't one of the usual choices. The library matches + names case-insensitively and reads an empty name as "regular". -#} + {% set stored_mapping = main_config.display.hardware.get('hardware_mapping', 'adafruit-hat-pwm') %} + {% set mapping_key = (stored_mapping or 'regular')|lower if stored_mapping is string else stored_mapping|string %} + {% set mapping_labels = {'adafruit-hat-pwm': 'Adafruit HAT PWM', 'adafruit-hat': 'Adafruit HAT', 'regular': 'Regular', 'regular-pi1': 'Regular Pi1', 'classic': 'Classic (legacy wiring)', 'classic-pi1': 'Classic Pi1 (legacy wiring)'} %} + {% set mapping_values = ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic'] if is_pi5 else ['adafruit-hat-pwm', 'adafruit-hat', 'regular', 'regular-pi1', 'classic', 'classic-pi1'] %} + {% if mapping_key not in mapping_values %} +

Your saved hardware mapping ("{{ stored_mapping }}") isn't one the installed rgbmatrix library {% if is_pi5 and mapping_key in mapping_labels %}can use on a Raspberry Pi 5{% else %}has{% endif %}, so the display won't start with it. Choose your board's mapping before saving.

+ {% endif %}
- +
@@ -218,7 +237,7 @@
- + // Live "Your display: W x H" readout - width = cols x chain_length, - // height = rows x parallel (same math as the chain-length tooltip). + // height = rows x parallel (same math as the chain-length tooltip), + // swapped for a 90/270 orientation. A custom pixel mapper config can + // change it further; src/display_geometry.py models those. (function () { const ids = ['rows', 'cols', 'chain_length', 'parallel']; const out = document.getElementById('display-resolution-value'); @@ -389,12 +410,19 @@ out.textContent = '—'; return; } - out.textContent = (v.cols * v.chain_length) + ' × ' + (v.rows * v.parallel) + ' pixels'; + let w = v.cols * v.chain_length, h = v.rows * v.parallel; + const orientation = document.getElementById('orientation'); + if (orientation && (orientation.value === '90' || orientation.value === '270')) { + [w, h] = [h, w]; + } + out.textContent = w + ' × ' + h + ' pixels'; } for (const id of ids) { const el = document.getElementById(id); if (el) el.addEventListener('input', recompute); } + const orientationSelect = document.getElementById('orientation'); + if (orientationSelect) orientationSelect.addEventListener('change', recompute); recompute(); })();