Summary
In odds-ticker (v1.3.3), the documented and recommended display_options.scroll_speed / display_options.scroll_delay settings are silently ignored. The plugin always falls back to time-based scrolling at scroll_pixels_per_second, so frame-based mode is unreachable for every user of the current config format.
On a 100 Hz panel this produces visible judder: the default 50 px/s at 100 fps works out to 0.5 px per frame, integer truncation makes every second frame byte-identical, and the display manager's dirty-tracking then skips the panel swap on those frames. Motion happens in irregular 1–2 px hops at ~50 Hz instead of one clean pixel per refresh.
Two defects, both in manager.py
1. The priority-1 branch does not clear scroll_pixels_per_second
# manager.py:246-251 (Priority 1 - current/recommended format)
if display_options and ('scroll_speed' in display_options or 'scroll_delay' in display_options):
self.scroll_speed = display_options.get('scroll_speed', 1.0)
self.scroll_delay = display_options.get('scroll_delay', 0.02)
self.scroll_pixels_per_second = display_options.get('scroll_pixels_per_second') # <-- keeps the value
self.logger.info(f"... (frame-based mode)")
# manager.py:252-257 (Priority 2 - deprecated format) -- this one gets it right
elif display_config and (...):
...
self.scroll_pixels_per_second = None # <-- correctly cleared
config_schema.json declares a default for this key:
properties.display_options.properties.scroll_pixels_per_second.default = 50.0
Because the plugin system deep-merges schema defaults into plugin config, display_options always contains scroll_pixels_per_second, so self.scroll_pixels_per_second is never None on this path. Removing the key from config.json does not help — the schema default is merged straight back in.
2. use_frame_based never looks at display_options
# manager.py:331-333
use_frame_based = (self.scroll_pixels_per_second is None and
display_config and
('scroll_speed' in display_config or 'scroll_delay' in display_config))
This only consults display_config (the deprecated display block). Even with defect 1 fixed, a user on the recommended display_options format still gets use_frame_based == False.
Observed behaviour
The plugin logs its scroll configuration twice on startup — the first line reports frame-based, then 0.36 s later it reconfigures to time-based and that is what actually runs:
17:12:59.140 - INFO - Using display_options.scroll_speed=1.0 px/frame, display_options.scroll_delay=0.01s (frame-based mode)
17:12:59.499 - INFO - Using scroll_pixels_per_second: 50.0 px/s (time-based mode)
Config in use:
"odds-ticker": {
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
}
Resulting frame-time distribution (128x64 x2 chain, limit_refresh_rate_hz: 100), bimodal rather than the expected flat 10 ms:
2.21ms 2.22ms 21.13ms 17.75ms 2.22ms 21.05ms 17.78ms 17.24ms
The ~2.2 ms frames are duplicates where nothing moved and the panel swap was skipped; the ~17–21 ms frames waited two vsyncs. Note that Avg FPS reports a healthy 100.0 throughout, because a 2.2 ms duplicate and a 21 ms double-wait average out to exactly 10 ms — the fault is invisible in the mean and only shows in the distribution.
For comparison, ledmatrix-leaderboard on the same panel holds a flat 10.00 ms and looks correct; it reads scroll_pixels_per_second only as a fallback and sets it to None when display.scroll_speed is present (manager.py:67).
Suggested fix
Clear the value on the priority-1 path so it matches priority 2:
# manager.py:250
self.scroll_pixels_per_second = None
and let use_frame_based accept either config shape:
# manager.py:331
use_frame_based = (
self.scroll_pixels_per_second is None
and (
(display_options and ('scroll_speed' in display_options or 'scroll_delay' in display_options))
or (display_config and ('scroll_speed' in display_config or 'scroll_delay' in display_config))
)
)
Worth considering separately: dropping the default: 50.0 from config_schema.json for a key documented as deprecated. A schema default on a deprecated field means it is always present, which is what makes it override the current format rather than acting as a fallback.
stock-news reads the same key at manager.py:315 and may be worth checking, though its logic differs.
Workaround
Set the deprecated key explicitly to a value that yields a whole number of pixels per frame. At 100 fps that means 100 px/s:
"odds-ticker": {
"display_options": {
"scroll_speed": 1.0,
"scroll_delay": 0.01,
"scroll_pixels_per_second": 100.0
}
}
Environment
- odds-ticker 1.3.3
- Raspberry Pi 4 Model B, Python 3.13.5, aarch64
- Panel: 2x 128x64 chain,
limit_refresh_rate_hz: 100, hardware_mapping: regular
Summary
In
odds-ticker(v1.3.3), the documented and recommendeddisplay_options.scroll_speed/display_options.scroll_delaysettings are silently ignored. The plugin always falls back to time-based scrolling atscroll_pixels_per_second, so frame-based mode is unreachable for every user of the current config format.On a 100 Hz panel this produces visible judder: the default 50 px/s at 100 fps works out to 0.5 px per frame, integer truncation makes every second frame byte-identical, and the display manager's dirty-tracking then skips the panel swap on those frames. Motion happens in irregular 1–2 px hops at ~50 Hz instead of one clean pixel per refresh.
Two defects, both in
manager.py1. The priority-1 branch does not clear
scroll_pixels_per_secondconfig_schema.jsondeclares a default for this key:Because the plugin system deep-merges schema defaults into plugin config,
display_optionsalways containsscroll_pixels_per_second, soself.scroll_pixels_per_secondis neverNoneon this path. Removing the key fromconfig.jsondoes not help — the schema default is merged straight back in.2.
use_frame_basednever looks atdisplay_optionsThis only consults
display_config(the deprecateddisplayblock). Even with defect 1 fixed, a user on the recommendeddisplay_optionsformat still getsuse_frame_based == False.Observed behaviour
The plugin logs its scroll configuration twice on startup — the first line reports frame-based, then 0.36 s later it reconfigures to time-based and that is what actually runs:
Config in use:
Resulting frame-time distribution (128x64 x2 chain,
limit_refresh_rate_hz: 100), bimodal rather than the expected flat 10 ms:The ~2.2 ms frames are duplicates where nothing moved and the panel swap was skipped; the ~17–21 ms frames waited two vsyncs. Note that
Avg FPSreports a healthy 100.0 throughout, because a 2.2 ms duplicate and a 21 ms double-wait average out to exactly 10 ms — the fault is invisible in the mean and only shows in the distribution.For comparison,
ledmatrix-leaderboardon the same panel holds a flat 10.00 ms and looks correct; it readsscroll_pixels_per_secondonly as a fallback and sets it toNonewhendisplay.scroll_speedis present (manager.py:67).Suggested fix
Clear the value on the priority-1 path so it matches priority 2:
and let
use_frame_basedaccept either config shape:Worth considering separately: dropping the
default: 50.0fromconfig_schema.jsonfor a key documented as deprecated. A schema default on a deprecated field means it is always present, which is what makes it override the current format rather than acting as a fallback.stock-newsreads the same key atmanager.py:315and may be worth checking, though its logic differs.Workaround
Set the deprecated key explicitly to a value that yields a whole number of pixels per frame. At 100 fps that means 100 px/s:
Environment
limit_refresh_rate_hz: 100,hardware_mapping: regular