mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-13 02:26:37 +00:00
Merge audit16-core/scroll into the September 16 core audit fixes
This commit is contained in:
+33
-13
@@ -22,12 +22,27 @@ shimmer) or repeat frames (which reads as judder). :func:`resolve` warns when
|
||||
the requested speed will not divide evenly, because that is a real display
|
||||
artefact and not a rounding detail.
|
||||
|
||||
Speed is always expressed to the helper as pixels per second and applied in
|
||||
time-based mode. Frame-based stepping gates motion on a wall clock at
|
||||
``1/scroll_delay`` steps per second; plugins set ``scroll_delay`` to the frame
|
||||
period, which puts that comparison exactly on its own threshold and makes the
|
||||
step count flip on sub-millisecond jitter. Accumulating elapsed time keeps
|
||||
position proportional to real time instead.
|
||||
How the speed is applied
|
||||
------------------------
|
||||
:func:`configure` snaps the requested speed to a :class:`CrispSpeed` -- a whole
|
||||
number of pixels per presented frame, with each frame held for ``frame_hold``
|
||||
panel refreshes -- and puts the helper in fixed-step mode
|
||||
(``ScrollHelper.set_pixels_per_frame``). In that mode the helper consults no
|
||||
clock: every ``update_scroll_position()`` call moves exactly that many pixels.
|
||||
``SwapOnVSync`` blocks until the panel has taken each frame, so the frame count
|
||||
is the clock, and the speed on the panel is::
|
||||
|
||||
px/s = refresh_hz / frame_hold * pixels_per_frame
|
||||
|
||||
That makes the hold part of the speed. The caller must pass
|
||||
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True, ...)``;
|
||||
a caller that omits it is presented every refresh and scrolls ``frame_hold``
|
||||
times too fast. The refresh is the panel's (``display_manager.refresh_hz``,
|
||||
i.e. ``display.hardware.limit_refresh_rate_hz``) -- never the global
|
||||
``target_fps``, which no longer paces anything.
|
||||
|
||||
With ``snap_to_crisp=False`` there is no fixed step: the helper is set to
|
||||
px/s and advances by elapsed time, and the hold is 1.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -330,17 +345,18 @@ def configure(
|
||||
) -> ScrollSettings:
|
||||
"""Resolve the config and apply it to ``scroll_helper``.
|
||||
|
||||
Applied in time-based mode: see the module docstring for why frame-based
|
||||
stepping is not used. ``hasattr`` guards keep this usable against older
|
||||
ScrollHelper builds that a plugin may be running on.
|
||||
Frame-based mode is switched off, the (snapped) speed is set, and with
|
||||
``snap_to_crisp`` the helper steps a fixed whole-pixel amount per presented
|
||||
frame -- see the module docstring. ``hasattr`` guards keep this usable
|
||||
against older ScrollHelper builds that a plugin may be running on.
|
||||
|
||||
:param display_manager: consulted for the panel's refresh rate only (it can
|
||||
see display.hardware; a plugin cannot). The frame hold is NOT applied
|
||||
here -- see the note in the body. The caller must pass
|
||||
``settings.frame_hold`` to ``display_manager.set_scrolling_state(True,
|
||||
...)`` when it starts scrolling, or a sub-refresh speed still presents
|
||||
a new frame every refresh and the motion falls back to fractional
|
||||
pixels.
|
||||
...)`` when it scrolls. The helper moves its fixed step on every call,
|
||||
so without the hold each step is presented every refresh and the
|
||||
scroll runs ``frame_hold`` times faster than the resolved speed.
|
||||
:param snap_to_crisp: move the requested speed to the nearest speed the
|
||||
panel can show in whole pixels. On by default because a speed that does
|
||||
not divide evenly has no good rendering, only a choice of artefacts.
|
||||
@@ -357,7 +373,8 @@ def configure(
|
||||
# refresh to fill in target_fps, pixels_per_frame and the judder warning,
|
||||
# so deriving it afterwards described a 100Hz panel to everyone running at
|
||||
# 60 -- and with snap_to_crisp=False nothing downstream corrected it, so
|
||||
# set_target_fps() paced the helper to 100 FPS on a 60Hz panel.
|
||||
# the settings and the helper's (informational) target_fps said 100 FPS on
|
||||
# a 60Hz panel.
|
||||
hz = _coerce(refresh_hz)
|
||||
if hz is None and display_manager is not None:
|
||||
hz = _coerce(getattr(display_manager, "refresh_hz", None))
|
||||
@@ -399,6 +416,9 @@ def configure(
|
||||
if hasattr(scroll_helper, "set_pixels_per_frame"):
|
||||
scroll_helper.set_pixels_per_frame(
|
||||
choice.pixels_per_frame if choice else None)
|
||||
# Informational only: nothing in the helper paces off target_fps. It is
|
||||
# still recorded because plugins read it back (ledmatrix-elections'
|
||||
# test_scroll_pacing.py asserts it equals the crisp presentation rate).
|
||||
if choice and hasattr(scroll_helper, "set_target_fps"):
|
||||
scroll_helper.set_target_fps(choice.frames_per_second)
|
||||
elif settings.target_fps and hasattr(scroll_helper, "set_target_fps"):
|
||||
|
||||
+60
-44
@@ -22,13 +22,6 @@ from typing import Optional, Dict, Any
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Try to import scipy for sub-pixel interpolation, fallback to simpler method if not available
|
||||
try:
|
||||
from scipy.ndimage import shift
|
||||
HAS_SCIPY = True
|
||||
except ImportError:
|
||||
HAS_SCIPY = False
|
||||
|
||||
|
||||
# How often the frame-stats line is emitted, and therefore also the ceiling
|
||||
# on a believable frame time: a scroll that renders at all cannot take this
|
||||
@@ -138,21 +131,22 @@ class ScrollHelper:
|
||||
# blend (blur) or repeat frames (judder); blending is the worse of the
|
||||
# two here. Vegas mode still opts in via set_sub_pixel_scrolling().
|
||||
self.sub_pixel_scrolling = False
|
||||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||||
|
||||
|
||||
# Frame-based scrolling settings
|
||||
self.frame_based_scrolling = False
|
||||
#: Whole pixels to advance per presented frame, or None to pace
|
||||
#: off elapsed time. See set_pixels_per_frame.
|
||||
self.fixed_pixels_per_frame = None # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||||
|
||||
self.fixed_pixels_per_frame = None
|
||||
self.last_step_time = 0.0 # Time of the last position update
|
||||
|
||||
# Time tracking for scroll updates
|
||||
self.last_update_time: Optional[float] = None
|
||||
|
||||
# High FPS settings
|
||||
self.target_fps = 120 # Target 120 FPS for smooth scrolling
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
|
||||
#: Informational only: the presentation rate scroll_config chose
|
||||
#: (panel refresh / frame hold). Nothing paces off it -- the helper
|
||||
#: steps per call and SwapOnVSync paces the calls. Kept because
|
||||
#: plugins and their tests read it back.
|
||||
self.target_fps = 120
|
||||
|
||||
# Dynamic duration settings
|
||||
self.dynamic_duration_enabled = True
|
||||
@@ -288,7 +282,14 @@ class ScrollHelper:
|
||||
|
||||
def update_scroll_position(self) -> None:
|
||||
"""
|
||||
Update scroll position with high FPS control and handle wrap-around.
|
||||
Advance the scroll by one presented frame and handle wrap-around.
|
||||
|
||||
With a fixed per-frame step (set_pixels_per_frame, which
|
||||
scroll_config.configure sets for a crisp speed) every call moves
|
||||
exactly that many pixels and no clock is read; the caller's
|
||||
vsync-blocking swap, held for ``frame_hold`` refreshes, sets the rate.
|
||||
Otherwise the position advances by elapsed time at the configured
|
||||
speed.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
return
|
||||
@@ -461,10 +462,9 @@ class ScrollHelper:
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||
consulted the flag, and the scipy fallback would not have interpolated
|
||||
anyway.
|
||||
installed on the target devices, and the old scipy-based sub-pixel path
|
||||
was dead code -- get_visible_portion never consulted the flag. The scipy
|
||||
import was removed with it; installing scipy has no effect.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
@@ -830,11 +830,13 @@ class ScrollHelper:
|
||||
|
||||
def set_scroll_speed(self, speed: float) -> None:
|
||||
"""
|
||||
Set the scroll speed.
|
||||
|
||||
In time-based mode: pixels per second (typically 10-200)
|
||||
In frame-based mode: pixels per frame (typically 0.5-5 for smooth scrolling)
|
||||
|
||||
Set the scroll speed, and leave fixed-step mode.
|
||||
|
||||
In time-based mode: pixels per second (clamped to 1-500).
|
||||
In frame-based mode: pixels per ``scroll_delay`` seconds (clamped to
|
||||
0.1-5), still applied by elapsed time as scroll_speed / scroll_delay
|
||||
px/s.
|
||||
|
||||
Args:
|
||||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||||
"""
|
||||
@@ -896,14 +898,19 @@ class ScrollHelper:
|
||||
|
||||
def set_target_fps(self, fps: float) -> None:
|
||||
"""
|
||||
Set the target frames per second for scrolling.
|
||||
|
||||
Record the presentation rate, for diagnostics only.
|
||||
|
||||
Nothing paces off this value: with a fixed per-frame step the helper
|
||||
advances once per call, and without one it advances by elapsed time.
|
||||
The rate frames are shown at is the panel refresh divided by the frame
|
||||
hold passed to ``display_manager.set_scrolling_state``. scroll_config
|
||||
sets it to that rate so it can be read back.
|
||||
|
||||
Args:
|
||||
fps: Target FPS (typically 30-200, default 120)
|
||||
fps: Frames per second (clamped to 30-200)
|
||||
"""
|
||||
self.target_fps = max(30.0, min(200.0, fps))
|
||||
self.frame_time_target = 1.0 / self.target_fps
|
||||
self.logger.debug(f"Target FPS set to: {self.target_fps} FPS (frame_time_target: {self.frame_time_target:.4f}s)")
|
||||
self.logger.debug("Target FPS recorded: %s FPS (informational)", self.target_fps)
|
||||
|
||||
def set_sub_pixel_scrolling(self, enabled: bool) -> None:
|
||||
"""
|
||||
@@ -914,7 +921,7 @@ class ScrollHelper:
|
||||
When disabled, uses integer pixel positioning (faster but may skip pixels).
|
||||
|
||||
Args:
|
||||
enabled: True to enable sub-pixel scrolling (default: True)
|
||||
enabled: True to enable sub-pixel scrolling (default: False)
|
||||
"""
|
||||
self.sub_pixel_scrolling = enabled
|
||||
self.logger.debug(f"Sub-pixel scrolling {'enabled' if enabled else 'disabled'}")
|
||||
@@ -923,10 +930,12 @@ class ScrollHelper:
|
||||
"""
|
||||
Enable or disable frame-based scrolling.
|
||||
|
||||
When enabled, update_scroll_position() respects scroll_delay and moves
|
||||
scroll_speed pixels per step. This provides a "stepped" look similar to
|
||||
traditional tickers and can be visually smoother on LED matrices.
|
||||
|
||||
This does not step. When enabled, ``scroll_speed`` is read as pixels
|
||||
per ``scroll_delay`` seconds (set_scroll_speed clamps it to 0.1-5), and
|
||||
update_scroll_position() still advances by elapsed time at
|
||||
``scroll_speed / scroll_delay`` px/s. A fixed per-frame step set by
|
||||
set_pixels_per_frame() takes precedence over both modes.
|
||||
|
||||
Args:
|
||||
enabled: True to enable frame-based scrolling (default: False)
|
||||
"""
|
||||
@@ -985,11 +994,7 @@ class ScrollHelper:
|
||||
# the real stall rates being measured, so the number could not be
|
||||
# trusted at all. Seed the clock and take no sample.
|
||||
if self.last_frame_time is None:
|
||||
self.last_frame_time = current_time
|
||||
# Restart the window with the scroll. Otherwise the boundary is
|
||||
# already long overdue when the second frame arrives, and the new
|
||||
# scroll opens by reporting a window of exactly one frame.
|
||||
self.last_fps_log_time = current_time
|
||||
self._restart_stats_window(current_time)
|
||||
return
|
||||
|
||||
# Calculate instantaneous frame time
|
||||
@@ -998,9 +1003,10 @@ class ScrollHelper:
|
||||
# A caller that scrolls without ever calling reset_scroll() never arms
|
||||
# the sentinel above, so catch the same gap by its size. Nothing that
|
||||
# renders a scroll produces a frame longer than the log interval; a
|
||||
# sample that large is an idle period, not a frame.
|
||||
# sample that large is an idle period, not a frame. It starts a new
|
||||
# scroll exactly as the sentinel does, window timer included.
|
||||
if frame_time >= FPS_LOG_INTERVAL:
|
||||
self.last_frame_time = current_time
|
||||
self._restart_stats_window(current_time)
|
||||
return
|
||||
|
||||
self.frame_times.append(frame_time)
|
||||
@@ -1034,7 +1040,17 @@ class ScrollHelper:
|
||||
|
||||
self.last_frame_time = current_time
|
||||
self.frame_count += 1
|
||||
|
||||
|
||||
def _restart_stats_window(self, current_time: float) -> None:
|
||||
"""Seed the frame clock at the start of a scroll, taking no sample.
|
||||
|
||||
The window timer restarts with it. Otherwise the 5s boundary is
|
||||
already long overdue when the next frame arrives, and the new scroll
|
||||
opens by reporting a window of exactly one frame.
|
||||
"""
|
||||
self.last_frame_time = current_time
|
||||
self.last_fps_log_time = current_time
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""
|
||||
Clear the cached scrolling image.
|
||||
|
||||
+47
-41
@@ -18,10 +18,15 @@ Promoting the content layer would be exactly the mistake
|
||||
``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that
|
||||
same-named methods are the same method. Same name, different job.
|
||||
|
||||
The one behavior this module adds over the plugin copies is native support for
|
||||
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
|
||||
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
|
||||
plugin inheriting from here gets it for free.
|
||||
What this module adds over the plugin copies is pacing through
|
||||
:mod:`src.common.scroll_config`, the resolver every other scroller uses: the
|
||||
configured px/s is snapped to a whole-pixel speed for the panel's refresh, the
|
||||
helper steps a fixed number of pixels per presented frame, and each drawn frame
|
||||
publishes the frame hold to the display manager. Speed depends only on
|
||||
``scroll_speed`` and the panel refresh
|
||||
(``display.hardware.limit_refresh_rate_hz``) -- not on the global
|
||||
``target_fps``, and not on ``scroll_delay``, which is kept in the settings for
|
||||
compatibility only.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -94,9 +99,9 @@ class SportsScrollDisplay:
|
||||
:param display_manager: the core display manager
|
||||
:param config: the plugin's configuration
|
||||
:param custom_logger: the plugin's logger, so scroll lines are attributed
|
||||
:param global_config: the LEDMatrix global config — the source of
|
||||
``target_fps``. Optional so an older caller that does not pass it
|
||||
keeps working at the config-derived pacing.
|
||||
:param global_config: the LEDMatrix global config, consulted for the
|
||||
panel refresh when the display manager cannot report one.
|
||||
Optional so an older caller that does not pass it keeps working.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
@@ -196,21 +201,6 @@ class SportsScrollDisplay:
|
||||
return {**settings, **override}
|
||||
return settings
|
||||
|
||||
def _resolve_target_fps(self) -> Optional[float]:
|
||||
"""The global smooth-scrolling FPS target, or None to keep config pacing.
|
||||
|
||||
Coerced before use: a malformed value in the global config must degrade
|
||||
to the existing ``scroll_delay`` pacing, never raise on a display path.
|
||||
"""
|
||||
raw = self.global_config.get("target_fps") or self.global_config.get(
|
||||
"scroll_target_fps"
|
||||
)
|
||||
try:
|
||||
return float(raw) if raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
self.logger.debug("Ignoring unusable target_fps: %r", raw)
|
||||
return None
|
||||
|
||||
def _coerce_float(self, value: Any, default: float) -> float:
|
||||
"""A usable float from config, or ``default``.
|
||||
|
||||
@@ -246,8 +236,8 @@ class SportsScrollDisplay:
|
||||
# settings dict: the two read the same key names with different
|
||||
# meanings, and the collision is a factor of 1/scroll_delay.
|
||||
#
|
||||
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is only the
|
||||
# frame period used to reach px/frame.
|
||||
# sports_scroll: scroll_speed is px/SECOND; scroll_delay is ignored
|
||||
# for pacing (see _resolve_pixels_per_second).
|
||||
# scroll_config: scroll_speed is px per STEP, so px/s = speed/delay.
|
||||
#
|
||||
# Passing {"scroll_speed": 50.0, "scroll_delay": 0.01} straight through
|
||||
@@ -276,8 +266,13 @@ class SportsScrollDisplay:
|
||||
def _resolve_pixels_per_second(self, settings: Dict[str, Any]) -> float:
|
||||
"""This module's config shape, expressed as plain pixels per second.
|
||||
|
||||
``scroll_speed`` is already px/s here. ``scroll_delay`` only matters
|
||||
when a caller supplied px/frame instead, which the 0 case covers.
|
||||
``scroll_speed`` is already px/s here, and it is the whole answer.
|
||||
``scroll_delay`` is kept in the settings for compatibility but is
|
||||
ignored for pacing: the frame rate is the panel refresh divided by the
|
||||
frame hold scroll_config picks, so no positive delay changes the speed.
|
||||
The one exception is a delay of exactly 0 (below every scoreboard
|
||||
schema's minimum), which is read as ``scroll_speed`` being px per
|
||||
frame at ``ASSUMED_FPS_WHEN_UNPACED``.
|
||||
"""
|
||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||
@@ -285,19 +280,29 @@ class SportsScrollDisplay:
|
||||
return scroll_speed * ASSUMED_FPS_WHEN_UNPACED
|
||||
return scroll_speed
|
||||
|
||||
def _resolve_refresh_hz(self) -> Optional[float]:
|
||||
def _resolve_refresh_hz(self) -> float:
|
||||
"""The panel refresh the crisp ladder should be computed against.
|
||||
|
||||
Prefers the configured hardware refresh. Falls back to the global
|
||||
``target_fps``/``scroll_target_fps`` this module has always honoured:
|
||||
under the old model that key *was* the rate frames were presented at,
|
||||
so it is the faithful translation for anyone who set it. Returning
|
||||
None lets scroll_config apply its own default.
|
||||
This has to be the rate frames are actually presented at, because the
|
||||
helper advances a fixed number of whole pixels per presented frame and
|
||||
the display manager holds each frame for ``frame_hold`` refreshes. A
|
||||
ladder built for any other rate plays back at the wrong speed: built
|
||||
for 60 Hz and shown on a 100 Hz panel, it runs 100/60 too fast.
|
||||
|
||||
So it is the display manager's ``refresh_hz`` (what the panel is
|
||||
driven at), then ``display.hardware.limit_refresh_rate_hz`` from the
|
||||
global config, then scroll_config's default. The global ``target_fps``
|
||||
is deliberately NOT consulted: before frame-locked presentation it was
|
||||
the rate frames were shown at, but now honouring it only turned the
|
||||
General tab's "Scroll Frame Rate" into a scoreboard speed multiplier.
|
||||
"""
|
||||
hardware = scroll_config.refresh_hz_from_config(self.global_config)
|
||||
if hardware and hardware != scroll_config.DEFAULT_REFRESH_HZ:
|
||||
return hardware
|
||||
return self._resolve_target_fps() or hardware or None
|
||||
reported = getattr(self.display_manager, "refresh_hz", None)
|
||||
# A real number only: a stub or a mock answering for anything must not
|
||||
# become the refresh rate.
|
||||
if (isinstance(reported, (int, float)) and not isinstance(reported, bool)
|
||||
and reported > 0):
|
||||
return float(reported)
|
||||
return scroll_config.refresh_hz_from_config(self.global_config)
|
||||
|
||||
def _scroll_frame_hold(self) -> int:
|
||||
"""Refreshes to hold each frame for, from the resolved settings."""
|
||||
@@ -327,11 +332,12 @@ class SportsScrollDisplay:
|
||||
return False
|
||||
|
||||
# Tell the core the panel is scrolling, and for how many
|
||||
# refreshes to hold each frame. Without this the frame hold is
|
||||
# never applied -- so a speed the ladder made crisp still presents
|
||||
# a new frame every refresh and judders -- and, because deferred
|
||||
# updates only run while nothing is scrolling, core would run
|
||||
# blocking work in the middle of this scroll.
|
||||
# refreshes to hold each frame. The helper advances a fixed number
|
||||
# of whole pixels per presented frame, so without the hold a new
|
||||
# frame is presented every refresh and the scroll runs frame_hold
|
||||
# times too fast. And because deferred updates only run while
|
||||
# nothing is scrolling, core would otherwise run blocking work in
|
||||
# the middle of this scroll.
|
||||
if hasattr(self.display_manager, "set_scrolling_state"):
|
||||
self.display_manager.set_scrolling_state(
|
||||
True, frame_hold=self._scroll_frame_hold())
|
||||
|
||||
+71
-44
@@ -306,50 +306,9 @@ class DisplayManager:
|
||||
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)
|
||||
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}")
|
||||
|
||||
@@ -1396,6 +1355,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.
|
||||
@@ -1441,6 +1462,12 @@ class DisplayManager:
|
||||
pixel every second refresh, which is how a scroll runs at half the
|
||||
refresh rate without fractional pixel positions.
|
||||
|
||||
The hold is part of the scroll's speed. A ScrollHelper configured by
|
||||
``scroll_config.configure()`` advances a fixed whole-pixel step per
|
||||
presented frame and reads no clock, so pass the returned
|
||||
``settings.frame_hold`` here: a scroll that leaves it at 1 is
|
||||
presented every refresh and runs ``frame_hold`` times too fast.
|
||||
|
||||
The hold is set here rather than once at plugin construction because
|
||||
it must not outlive the scroll that asked for it: plugins share one
|
||||
display manager, so a hold left set by whoever scrolled last would
|
||||
|
||||
@@ -155,9 +155,14 @@ class VegasModeConfig:
|
||||
target_fps: int = 125 # Target frame rate
|
||||
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
||||
|
||||
# Scroll behavior
|
||||
# Scroll behavior. Neither key steps the scroll or sets a frame rate:
|
||||
# motion is always by elapsed time at scroll_speed px/s. With
|
||||
# frame_based_scrolling the speed is first converted to px per
|
||||
# scroll_delay and clamped to 0.1-5 (ScrollHelper.set_scroll_speed), so
|
||||
# the speed actually applied is clamp(scroll_speed * scroll_delay, 0.1, 5)
|
||||
# / scroll_delay -- at the 0.02 default, speeds under 5 px/s run at 5.
|
||||
frame_based_scrolling: bool = True
|
||||
scroll_delay: float = 0.02 # 50 FPS effective scroll updates
|
||||
scroll_delay: float = 0.02 # only feeds the clamp above; not a frame period
|
||||
|
||||
# Dynamic duration
|
||||
dynamic_duration_enabled: bool = True
|
||||
|
||||
@@ -127,12 +127,14 @@ class RenderPipeline:
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# interprets it differently based on frame_based_scrolling mode:
|
||||
# - Frame-based: pixels per frame step
|
||||
# - Time-based: pixels per second
|
||||
# takes it in different units depending on frame_based_scrolling:
|
||||
# - Frame-based: pixels per scroll_delay seconds (clamped to 0.1-5)
|
||||
# - Time-based: pixels per second (clamped to 1-500)
|
||||
# Both modes then advance by elapsed time; frame-based mode does not
|
||||
# step. So frame-based with scroll_delay only adds the clamp: the
|
||||
# applied speed is clamp(scroll_speed * scroll_delay, 0.1, 5) /
|
||||
# scroll_delay px/s.
|
||||
if self.config.frame_based_scrolling:
|
||||
# Convert pixels/second to pixels/frame
|
||||
# pixels_per_frame = pixels_per_second * seconds_per_frame
|
||||
pixels_per_frame = self.config.scroll_speed * self.config.scroll_delay
|
||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user