Skip to content
led-ticker

Validation rules

led-ticker validate reads your config.toml and reports every rule below. Run it before you deploy:

Terminal window
led-ticker validate config/config.toml

It exits non-zero on errors so you can wire it into CI.

The numeric IDs in headings below match the rule field on --json output (used by the creating-a-config skill and any other programmatic consumer); they’re stable across releases. The human-readable CLI output drops them — every error / warning is self-contained on a single line.

Errors block the engine from starting. Fix all of these before deploying.

Rule 3 — text_align = "scroll" with fit = "stretch" hides the text

Section titled “Rule 3 — text_align = "scroll" with fit = "stretch" hides the text”

Stretch fills every panel pixel with image, leaving no transparent regions for the scrolling text to show through. Use text_align = "scroll_over" (text paints on top of the image — the intended pairing with a fullscreen opaque stretch), switch fit to "pillarbox", "letterbox", or "crop", or change text_align to "left", "right", or "auto".

Rule 7 — text_x_offset is rejected in scroll modes

Section titled “Rule 7 — text_x_offset is rejected in scroll modes”

Static text_x_offset doesn’t compose with the per-tick scroll cursor. Either set text_x_offset = 0 or change text_align to "left" or "right".

Rule 8 — hold_time < 0.05 is almost certainly a unit error

Section titled “Rule 8 — hold_time < 0.05 is almost certainly a unit error”

hold_time is in seconds, not milliseconds. Anything under 50 ms is rejected. If you wrote 3000 thinking ms, you want 3.0.

Rule 12 — animation is only valid on message, gif, and image

Section titled “Rule 12 — animation is only valid on message, gif, and image”

Other widget types have their own draw paths and ignore animation silently — the validator rejects it explicitly to make the no-op visible. For dynamic color on weather.current, rss.feed, the crypto widgets, or two_row, use font_color = "rainbow" or a gradient instead.

Rule 14 — animation = "typewriter" on gif / image is single-row only

Section titled “Rule 14 — animation = "typewriter" on gif / image is single-row only”

Typewriter draws fixed-position glyphs and rejects three combinations: bottom_text set (two-row mode), text_align = "scroll" / "scroll_over" (cursor moves each tick), or empty text. Drop the conflicting setting. Typewriter still composes with font_color = "rainbow" and border = "rainbow".

Rule 22 — Font line-height exceeds the per-row band

Section titled “Rule 22 — Font line-height exceeds the per-row band”

On two_row, or on gif / image with bottom_text set, each row gets a slice of content_height. A font taller than its band would clip into the other row. Pick a smaller font_size, raise content_height, set top_row_height to give the offending row more space, or use a BDF alias (5x8, 6x12).

Rule 25 — start_hold is only valid on scroll modes

Section titled “Rule 25 — start_hold is only valid on scroll modes”

start_hold controls the pre-roll delay before a ticker / one_at_a_time section’s first widget begins moving. It calls into _scroll_and_delay, which slideshow mode doesn’t use. Setting start_hold on a slideshow section would silently do nothing — the validator rejects it so the misconfiguration surfaces immediately. For slideshow mode, the per-widget hold is hold_time. Also rejected: start_hold < 0.

Rule 26 — separator_* is only valid on ticker

Section titled “Rule 26 — separator_* is only valid on ticker”

The separator, separator_font, separator_font_size, and separator_color fields customize the small bullet that ticker mode inserts between widgets in the side-by-side stream. slideshow mode doesn’t intersperse anything; one_at_a_time fully exits each widget before the next, so no separator is needed. Setting any of these fields on a non-ticker section would silently do nothing — the validator rejects it. Either remove the fields, or change mode to "ticker".

Rule 28 — bottom_text_loops requires bottom_text_wrap

Section titled “Rule 28 — bottom_text_loops requires bottom_text_wrap”

bottom_text_loops on a two_row widget controls how many full wrap cycles the bottom row must play before the section ends. A cycle is bottom_text + the separator — both only exist when bottom_text_wrap = true. In non-wrap mode the bottom row scrolls once over its overflow and there’s no cycle to count, so the validator rejects bottom_text_loops > 0 without wrap. Negative values and true / false (bool — bool is a Python int subclass, so without an explicit guard bottom_text_loops = true would silently behave as 1) are also rejected. Either turn on wrap mode, drop bottom_text_loops, or replace the bool with an integer count.

Rule 31 — scroll_step_ms must be positive

Section titled “Rule 31 — scroll_step_ms must be positive”

scroll_step_ms is the engine’s per-tick advance, in milliseconds per logical pixel. The wraps_forever branch in _swap_and_scroll computes n_ticks = int(hold_time / scroll_speed) where scroll_speed = scroll_step_ms / 1000. A value of 0 raises ZeroDivisionError at startup; negative values produce nonsensical tick counts. Set scroll_step_ms to a positive integer (typical range: 25–60 ms per pixel) or omit the field to inherit the engine default.

Rule 29 — text_loops on two_row (did you mean bottom_text_loops?)

Section titled “Rule 29 — text_loops on two_row (did you mean bottom_text_loops?)”

text_loops is the marquee-loop field on the gif / image widgets — those widgets are single-row by default and use the unprefixed name. The two_row widget is always two-row and uses bottom_text_loops to match its bottom_text_wrap / bottom_text_separator family. A config that writes text_loops = N on a two_row widget is almost always a copy-paste from a gif/image marquee config — the validator catches it and points at the right name. Rename text_loops to bottom_text_loops and set bottom_text_wrap = true (loops require wrap mode — see rule 28).

Rule 1 — content_height × scale exceeds the panel height

Section titled “Rule 1 — content_height × scale exceeds the panel height”

content_height × scale must be ≤ the real panel height in pixels. When it exceeds the ceiling, the ScaledCanvas wrapper’s vertical offset goes negative and content silently clips at both the top and bottom edges. This is not recoverable at runtime — any section that trips this check will render broken on the panel. Lower content_height to panel_h ÷ scale (e.g. content_height = 16 for a bigsign at scale = 4 and a 64 px tall panel), or lower scale.

Rule 34 — scroll_speed_ms set at section level, or scroll_step_ms set on a gif/image widget

Section titled “Rule 34 — scroll_speed_ms set at section level, or scroll_step_ms set on a gif/image widget”

These two fields look similar but live at different scopes:

  • scroll_step_ms belongs on the section — it controls the engine’s per-tick cursor advance across all widgets in the section.
  • scroll_speed_ms belongs on the widget (gif / image) — it controls the text-marquee cadence inside a single widget’s play() loop.

Writing scroll_speed_ms in a [[playlist.section]] block (section scope) silently does nothing — the section loader doesn’t know the field. Writing scroll_step_ms on a gif or image widget passes it as an unknown kwarg that crashes at startup. The validator catches both and points at the correct scope.

Rule 38 — Unknown field name on a widget

Section titled “Rule 38 — Unknown field name on a widget”

A key in a [[playlist.section.widget]] block doesn’t match any field the widget accepts. This previously passed validation and crashed at startup with a raw TypeError from attrs internals. The validator now catches it at config-load time and includes a did-you-mean suggestion:

✗ ERROR section[0].widget[0]: widget type='message' got unknown field: 'text_color' (did you mean 'font_color'?)
Fix: Remove or rename the field. Run `led-ticker validate --list-fields message` to see valid fields.

Common sources: copy-pasting a field name from the wrong widget type (text_color instead of font_color; alignement misspelling of center; scroll_speed_ms on a non-image widget). Run led-ticker validate --list-fields TYPE to see all accepted fields for a given widget type.

A transition name in [transitions] default, [transitions] between_sections, or a per-section transition / entry_transition / widget_transition field doesn’t exist in the transition registry. A typo here always crashes at startup, so this check runs even without --strict. The error includes a did-you-mean hint when the typo is close to a known name:

✗ ERROR transitions.default: unknown transition 'wipe_alternaiting' (did you mean 'wipe_alternating'?)
Fix: Check the transition name spelling. Run `led-ticker validate --list-fields` or see docs.ledticker.dev/transitions/ for the full catalogue.

The sentinel value "cut" is always accepted. For the full list of available names, see Transitions.

Warnings don’t block the engine, but they flag a likely rendering quirk worth resolving.

Rule 30 — hold_time set alongside bottom_text_loops

Section titled “Rule 30 — hold_time set alongside bottom_text_loops”

When a section has an explicit hold_time AND a two_row widget with bottom_text_loops > 0, the engine uses max(hold_time_ticks, bottom_text_loops × cycle_width) to compute section duration — the larger value dominates. Common confusion: a user sets bottom_text_loops = 3 expecting “exactly 3 loops” but their hold_time (say 8.0 at scroll_step_ms = 35 ≈ 228 ticks) happens to exceed 3 × cycle_width, and the section runs for 5+ loops instead. The fix depends on intent: for an EXACT loop count, drop hold_time from the section so bottom_text_loops is the only floor; for a FIXED duration, drop bottom_text_loops; if you want both as floors and you understand max(), ignore the warning. Rule 30 is scoped to two_row only — gif/image widgets handle their own timing inside play() and hold_time doesn’t reach them, so the same field combination on those widgets is harmless and silent.

Rule 6 — two_row at scale = 4 collapses the canvas to 64 logical pixels wide

Section titled “Rule 6 — two_row at scale = 4 collapses the canvas to 64 logical pixels wide”

Most handles fit at 64 logical px, but anything longer overflows and the bottom row scrolls unintentionally. Use scale = 2 (128 logical px) with content_height ≤ 16.

Rule 21 — transition_duration is in seconds, not milliseconds

Section titled “Rule 21 — transition_duration is in seconds, not milliseconds”

transition_duration takes seconds. A value above 5.0 is almost always a unit error (you wrote 800 thinking ms; you wanted 0.8). The validator flags any duration above 5 s or below 50 ms across [transitions] default, [transitions] between_sections, and per-section overrides — the fix is to divide by 1000.

Rule 23 — Held top_text is wider than the canvas

Section titled “Rule 23 — Held top_text is wider than the canvas”

The top row on a two_row widget (and the top row on gif / image widgets when bottom_text is set) is held — it doesn’t scroll. If the rendered text is wider than the logical canvas, the right edge clips silently at runtime. The validator measures the text against the resolved font (including inline :slug: emoji) and warns when it would overflow. Shorten the text, drop inline emoji, lower top_font_size, or set the section’s scale lower to widen the logical canvas (each step from scale = 4 to scale = 2 to scale = 1 doubles the available width).

The named font isn’t bundled and didn’t turn up in config/fonts/. This is a warning, not an error: a config drafted on a laptop without the brand font is still useful, and the file may live on the Pi by deploy time. The ticker will raise at startup if the section actually runs without the font present, so resolve the warning before deploy. Drop the font file into config/fonts/ on the deploy target, or pick one of the bundled fonts (BDF: 5x8, 6x10, 6x12, 7x13; hires: Inter-Bold, Inter-Regular).

Rule 35 — default = "..." inside a [[playlist.section]] is silently ignored

Section titled “Rule 35 — default = "..." inside a [[playlist.section]] is silently ignored”

default is a key in the [transitions] block — it sets the global default transition for all sections. Inside a [[playlist.section]] block the equivalent field is transition, not default. Writing default = "wipe_left" inside a section block looks valid to TOML but is silently dropped by the section loader. Rename it to transition = "wipe_left" to get the intended per-section override.

These checks only run when you pass --strict to led-ticker validate. Use --strict in CI so a warning-clean config is enforced before deploy — all existing warnings are also promoted to errors in strict mode.

Rule 40 — Asset path does not exist on disk

Section titled “Rule 40 — Asset path does not exist on disk”

In --strict mode, any path field on a gif or image widget must point to a file that exists relative to the config file’s directory. This check is strict-only because the asset may only live on the deploy target (Pi), not on your laptop:

✗ ERROR section[0].widget[0]: asset path 'assets/missing.gif' does not exist (resolved to /home/pi/config/assets/missing.gif)
Fix: Check the path is correct relative to the config file. In --strict mode all referenced asset files must be present.

Run validate without --strict during local drafting; switch to --strict on the Pi or in CI once all assets are in place.

Rule 42 — bulb_size must be a positive integer

Section titled “Rule 42 — bulb_size must be a positive integer”

The bulb_size field on a lightbulbs border must be a positive integer (or omitted to use the panel-size auto-default).

Fix: Set bulb_size = 3 (typical bigsign value) or omit the field.

Rule 43 — bulb_size exceeds panel-height ceiling

Section titled “Rule 43 — bulb_size exceeds panel-height ceiling”

A lightbulbs border with bulb_size > panel_height // 2 would have its top-edge and bottom-edge bulbs overlap. Catches: bulb_size = 9 on a smallsign-class 16-row panel (max=8).

Fix: Reduce bulb_size to ≤ panel_height // 2, or omit it to use the auto-default (3 on big panels, 1 on small).

Rule 44 — mode must be chase, alternate, or unison

Section titled “Rule 44 — mode must be chase, alternate, or unison”

Typo in the mode field, or a mode that doesn’t exist yet.

Fix: Set mode to one of chase, alternate, or unison.

The direction field on a chase-mode lightbulbs border must be one of those two values.

Fix: Set direction = "cw" (clockwise) or direction = "ccw" (counter-clockwise).

Rule 46 — chase_density must be an integer ≥ 1

Section titled “Rule 46 — chase_density must be an integer ≥ 1”

chase_density = N means “1 in N bulbs is lit”. Values less than 1 are nonsensical.

Fix: Set chase_density to 1 (every bulb lit), 2 (every other), 3 (every third — classic marquee), etc.

Negative gaps would cause bulbs to overlap, producing an undefined visual.

Fix: Set gap = 0 (bulbs touching) or a positive integer.

Rule 48 — chase_density ignored outside chase mode (warning)

Section titled “Rule 48 — chase_density ignored outside chase mode (warning)”

chase_density only affects the chase animation. Setting it on mode = "alternate" or mode = "unison" is silently ignored.

Fix: Either remove chase_density, or change mode to chase.

Rule 49 — direction ignored outside chase mode (warning)

Section titled “Rule 49 — direction ignored outside chase mode (warning)”

direction only affects the chase animation.

Fix: Either remove direction, or change mode to chase.

Rule 51 — hue_wraps must be a positive number

Section titled “Rule 51 — hue_wraps must be a positive number”

hue_wraps controls how many full hue cycles the rainbow sweeps around the perimeter. Zero or negative values (or non-numeric values) would produce an empty or reversed rainbow with undefined behavior.

Fix: Set hue_wraps to a positive number such as 1.0 (one full cycle) or 2 (two cycles). Omit it to use the default of 1.0.

Rule 52 — hue_wraps ignored unless lit_color = "rainbow" (warning)

Section titled “Rule 52 — hue_wraps ignored unless lit_color = "rainbow" (warning)”

hue_wraps only affects the rainbow color sweep. When lit_color is a fixed color (or absent), hue_wraps has no effect and is silently ignored.

Fix: Either set lit_color = "rainbow" to enable the rainbow effect, or remove hue_wraps.