* feat(vegas): let live content keep its place in the ticker Live content used to preempt Vegas outright: while any plugin reported live priority the display controller refused to run the ticker at all and showed a full-screen scoreboard instead. Keeping the marquee meant not seeing live scores; seeing live scores meant losing the marquee. Two changes, both off by default. vegas_scroll.live_in_ticker keeps the ticker running through a live game. Three places assumed the takeover and all three now honour it: the controller's gate, the coordinator's per-frame pause, and the rotation switch that would otherwise move current_mode_index underneath a ticker that never yields. And the rotation is no longer a strict round robin. It was one slot per plugin per cycle, so with a dozen plugins enabled a live score came round once a lap and could be minutes old on screen. A plugin can now hold several slots, placed by Smooth Weighted Round-Robin -- the same scheduler the sports plugins already use to rotate their own games. The property that matters is that repeats are spread through the cycle rather than clumped: three in a row and then silence would be worse than no boost at all. Weight comes from the plugin first, via a new optional get_vegas_priority_weight(), then from the core: live content earns live_weight, everything else 1. So existing plugins gain the behaviour without changes, and the hook exists for the one thing the core cannot work out -- the core can see that a game is live but not whose, so only the plugin can say a favorite is playing. Documented in ADVANCED_FEATURES (worked example, why weights are per plugin not per game, and that frequency is not freshness), CONFIG_REFERENCE, PLUGIN_API_REFERENCE, and the config template. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): carry the new keys through config, and correct two docs Three findings from CodeRabbit, all valid. to_dict() and update() enumerate keys explicitly and had not learned the three new ones, so get_status() never reported them and a live config change never applied -- turning live_in_ticker on in the web UI would have done nothing until a restart. update() clamps the weights exactly as from_config does. The vegas_scroll key count in ADVANCED_FEATURES said 29; the template has 30. My arithmetic, not the reviewer's. The third was a documentation error rather than a code one, and I have fixed it the other way round. The docs claimed a raising get_vegas_priority_weight() is treated as weight 1. The code instead falls through to the core's own live-content check, and that is the better behaviour: the hook is only how a plugin asks for *more* than live_weight, and has_live_priority/has_live_content are separate methods guarded separately, so a plugin with a broken weight calculation should lose the favorite distinction and keep the live boost. Said so in the code, the base-plugin docstring and the API reference. The test fake now fails in each place independently, because the two failures mean different things: a broken hook still earns live_weight, a plugin that cannot say whether it is live has nothing to fall back on and weighs 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ui/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): stop the heaviest plugin doubling across the cycle seam Smooth Weighted Round-Robin spaces repeats well within a pass, but it schedules the heaviest item first and usually last as well. The strip loops, so those two are neighbours: the marquee showed the same plugin twice running at exactly the one join a within-cycle check cannot see. Observed on a live rig at 28 slots -- gaps of 6, 7, 7, 7 and then 1. Rotating the list does not fix it. Rotation preserves the cyclic order exactly, so it moves where the seam is drawn rather than the adjacency itself; the trailing entry has to be swapped with one from the middle. The first version swapped with the first slot that merely fitted, which undid the spacing this exists to protect -- it moved a repeat from a gap of 7 into a gap of 2, more clumped than the seam had ever been. It now picks the candidate furthest from any other appearance, so the repeat lands in the widest gap. Left alone when no candidate exists. A plugin holding most of the slots has to neighbour itself, and scheduling it is better than refusing to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 * fix(vegas): stop the seam repair creating the duplicate it removes Swapping the trailing repeat with a middle slot moves two elements, and the candidate filter only guarded one of them. It checked the neighbours `repeated` would acquire at j, but not what the displaced element would sit beside at the end -- so ['a','b','c','d','x','y','x','a'] came back as [...,'x','x'], the seam duplicate traded for a fresh one. Reported by CodeRabbit with that exact case. Adding the missing condition fixed it and immediately broke something else: schedule[j] is schedule[-2] when j is the second-to-last slot, so that candidate was always excluded, and ['a','b','c','a'] lost the only repair it has. The same class of mistake twice, from reasoning about which neighbours two moved elements end up with. So it no longer reasons. It performs each candidate swap, counts the cyclic duplicates in the result, and keeps the best one that has none -- preferring whichever leaves the boosted plugin most evenly spread. When no such swap exists the schedule is returned untouched, which is the unavoidable case: a plugin holding most of the slots has to neighbour itself. Fuzzed across 6,956 seam schedules: none made worse, none lost an entry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.4 KiB
Configuration Reference
Every key in config/config.json, what it does, its default, and where the
code reads it. The file is created from config/config.template.json on
first run, and ConfigManager._migrate_config() merges any template keys
added by later releases into your existing config (your values are never
overwritten). Secrets live in config/config_secrets.json and are merged
into the config at load time.
Most settings are editable from the web interface; this page documents the
underlying keys for people editing config.json directly or writing
tooling against it.
Top level
| Key | Type / default | Meaning | Read by |
|---|---|---|---|
web_display_autostart |
bool, true |
Whether the web interface service starts with the system | scripts/utils/start_web_conditionally.py |
timezone |
string, "America/New_York" |
IANA timezone for schedules and displays | ConfigManager.get_timezone() |
target_fps |
int, 100 |
Frame-rate ceiling for plugin rendering | src/plugin_system/base_plugin.py, src/common/sports_scroll.py |
location |
object | city / state / country, offered to plugins that need a location (weather, etc.) |
plugins via merged config |
schedule — display on/off hours
| Key | Type / default | Meaning |
|---|---|---|
enabled |
bool, false |
Master switch for scheduled display on/off |
mode |
"global" or "per-day", template uses "per-day" |
Whether one time range applies to all days or each day has its own |
start_time / end_time |
"HH:MM", 07:00–23:00 |
Global-mode on/off times |
days.<weekday>.{enabled,start_time,end_time} |
per-day objects | Per-day-mode overrides |
Read by DisplayController (src/display_controller.py, _check_schedule
around line 603). Managed in the web UI under Schedule.
dim_schedule — scheduled brightness dimming
Same shape as schedule, plus:
| Key | Type / default | Meaning |
|---|---|---|
dim_brightness |
int, 30 |
Brightness percentage applied while the dim window is active |
Read by DisplayController (src/display_controller.py around line 770;
saved via POST /api/v3/config/dim-schedule). The display returns to
display.hardware.brightness outside the window.
display.hardware — matrix panel hardware
All keys map to the corresponding rpi-rgb-led-matrix options and are read
in DisplayManager (src/display_manager.py, ~lines 270–295).
| Key | Type / default |
|---|---|
rows / cols |
int, 32 / 64 |
chain_length |
int, 2 |
parallel |
int, 1 |
brightness |
int, 90 |
hardware_mapping |
string, "adafruit-hat" (code default "adafruit-hat-pwm") |
scan_mode |
int, 0 |
pwm_bits |
int, 9 (code default 10) |
pwm_dither_bits |
int, 1 |
pwm_lsb_nanoseconds |
int, 130 (code default 150) |
disable_hardware_pulsing |
bool, false |
inverse_colors |
bool, false |
show_refresh_rate |
bool, false |
led_rgb_sequence |
string, "RGB" |
limit_refresh_rate_hz |
int, 100 (code default 90) |
pixel_mapper_config |
string, "" — e.g. "U-mapper" / "Rotate:90" |
orientation |
string, "normal" — "180" rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto pixel_mapper_config as a trailing Rotate:180 mapper, so it stays independent of any custom pixel_mapper_config value |
row_address_type |
int, 0 — non-standard panel row addressing |
multiplexing |
int, 0 — panel multiplexing scheme |
panel_type |
string, "" — set to "FM6126A" or "FM6127" for panels needing init |
Where "code default" differs from the template value, the code default only applies if the key is missing entirely from your config.
display.runtime
| Key | Type / default | Meaning |
|---|---|---|
gpio_slowdown |
int, 3 |
GPIO timing slowdown for faster Pis |
rp1_rio |
int, 0 |
RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
display.double_sided
Drives _LogicalMatrix in src/display_manager.py — renders the same
logical image to multiple chained physical panels.
| Key | Type / default | Meaning |
|---|---|---|
enabled |
bool, false |
Mirror output across panel copies |
copies |
int, 2 |
Number of physical copies in the chain |
axis |
"horizontal", default |
Axis along which panels are chained |
display — other keys
| Key | Type / default | Meaning | Read by |
|---|---|---|---|
display_durations |
object, {} |
Per-plugin display duration in seconds, keyed by plugin id (e.g. "clock": 15) |
src/display_controller.py:1030 |
plugin_rotation_order |
array, [] |
Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | src/display_controller.py:2894 |
use_short_date_format |
bool, true |
Compact date rendering in sports scoreboards | src/base_classes/sports/core.py |
dynamic_duration.max_duration_seconds |
int, optional | Cap for plugins that request dynamic display time | src/display_controller.py:405 |
display.vegas_scroll — continuous scroll mode
Read by src/vegas_mode/config.py (VegasScrollConfig.from_config). See
ADVANCED_FEATURES.md for behavior details, including
live content in the ticker.
| Key | Type / default |
|---|---|
enabled |
bool, false |
scroll_speed |
int, 50 (px/s) |
separator_width |
int, 32 |
plugin_order |
array, [] |
excluded_plugins |
array, [] |
target_fps |
int, 125 |
buffer_ahead |
int, 2 |
intra_plugin_gap |
int, 8 |
render_width_pct |
int, 100 |
min_content_separation |
int, 24 |
min_cut_gap |
int, 6 |
continuous_scroll |
bool, true |
smooth_scroll |
bool, true |
extend_threshold_screens |
float, 2.0 |
auto_trim |
bool, true |
trim_threshold |
int, 10 |
content_padding |
int, 8 |
min_plugin_width |
int, 8 |
lead_in_width |
int, 0 |
plugins_per_cycle |
int, 6 |
max_plugin_width_ratio |
float, 0.0 |
overflow_mode |
string, "rotate" |
dynamic_duration_enabled |
bool, true |
min_cycle_duration |
int, 60 |
max_cycle_duration |
int, 240 |
frame_based_scrolling |
bool, true — frame-count-based scroll stepping |
scroll_delay |
float, 0.02 — seconds between scroll updates (~50 FPS) |
live_in_ticker |
bool, false — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
live_weight |
int, 3 (1–10) — slots per cycle for a plugin with live content |
favorite_live_weight |
int, 5 (1–10) — slots per cycle when a plugin reports a favorite team is live |
sync — multi-display synchronization
Read by src/common/sync_manager.py and src/display_controller.py.
| Key | Type / default | Meaning |
|---|---|---|
role |
"standalone" (default), "leader", or "follower" |
This device's role in a synced pair |
port |
int, 5765 |
TCP port used for sync traffic |
follower_position |
"left" (default) or "right" |
Which half of the combined image this follower renders (src/display_controller.py:522) |
plugin_system
Read by the plugin loader/manager (src/plugin_system/).
| Key | Type / default | Meaning |
|---|---|---|
plugins_directory |
string, "plugin-repos" |
Where the Plugin Store installs plugins |
auto_discover |
bool, true |
Scan the plugins directory at startup |
auto_load_enabled |
bool, true |
Load discovered plugins automatically |
development_mode |
bool, false |
Development conveniences in the web UI (editable under General settings) |
Plugin config blocks
Every installed plugin stores its settings under a top-level key equal to
its plugin id (the template ships one for the bundled web-ui-info
plugin). The shape of each block is defined by that plugin's
config_schema.json; common keys are enabled and display_duration.
See PLUGIN_CONFIG_CORE_PROPERTIES.md.
config/config_secrets.json
| Key | Meaning |
|---|---|
github.api_token |
Optional GitHub token the Plugin Store uses to avoid API rate limits (src/plugin_system/store_manager.py:348) |
<plugin-id>.* |
Secrets a plugin declares with "x-secret": true in its config schema; merged into that plugin's config at load time |