RaceLink Concepts — How the wire opcodes work in practice¶
This page explains the three opcodes that operators interact with most
often — OPC_CONTROL, OPC_OFFSET, OPC_SYNC — from a practical
standpoint: what each one does, when to use it, what its quirks are,
and how the pieces compose when you author scenes.
For the bit-level wire format, see
wire-protocol.md. For terminology, see
../glossary.md.
Three opcodes, three jobs.
Opcode What it does (operator's view) OPC_CONTROL"Apply these effect parameters now (or arm them for SYNC)." OPC_OFFSET"Configure how this group should delay its next ARM_ON_SYNC effect." OPC_SYNC"Fire all armed effects across the fleet at once." All three respect a small flags byte (power, arm-on-sync, brightness-meaningful, force-tt0, force-reapply, offset-mode). The flags decide when and how an effect lands, not what it does.
OPC_CONTROL — direct effect control¶
What it is¶
OPC_CONTROL carries effect parameters directly on the wire —
mode, speed, intensity, custom sliders, palette, three colours,
brightness — without requiring a pre-staged WLED preset on the node.
Compare to OPC_PRESET:
| Aspect | OPC_PRESET (WLED preset) |
OPC_CONTROL (direct effect) |
|---|---|---|
| Body size | 4 bytes fixed | 3–21 bytes (fieldMask-driven) |
| Pre-staging | Preset must exist on every node (numeric slot 0–255 in node's WLED preset list) | None — parameters are sent in-band |
| Use case | "Apply Preset 12 to group 3" — preset library is curated and deployed | Ad-hoc parameter combinations, especially during a race |
| Wire efficiency | Smallest packet | Carries only changed fields |
| WLED-ID lookup | Node looks up its local preset slot | Node merges parameters into the active segment |
The "merge semantics" trick¶
This is what makes OPC_CONTROL cheap on the wire even though it
could carry 14 parameters. The fieldMask byte indicates which
fields are present in this packet. Fields that are absent retain
their previous value on the node. Effectively the wire format is a
diff against the current state.
In practice, when you tweak just the speed of a running effect:
- Body grows by 1 byte (the new speed value).
- The fieldMask has only the
speedbit set. - The receiver overwrites
speedand leavesmode,intensity, palette, colours, and the rest untouched.
A typical small change (speed + one custom slider) is 5 bytes of body. A full effect specification (mode + speed + intensity + 3 customs + 3 checks + palette + 3 colours + brightness) is 21 bytes. The packet ranges between 3 bytes (no field changes — just flags) and 21 bytes (everything).
Field set¶
What you can send (and the operator-facing dialog calls each one):
| Field | Range | What it does |
|---|---|---|
brightness |
0–255 | Segment brightness. 0 implicitly turns the segment off (RL_FLAG_POWER_ON is auto-derived from this). |
mode |
0–219 | WLED effect-mode index. 0 = Solid, 2 = Breathe, 35 = Traffic Light, etc. |
speed |
0–255 | Effect speed. Per-effect meaning. |
intensity |
0–255 | Effect intensity. Per-effect meaning. |
custom1, custom2 |
0–255 | Per-effect sliders. Label varies by effect. |
custom3 |
0–31 | Per-effect slider (5 bits). |
check1, check2, check3 |
bool | Per-effect toggles. Label varies by effect. |
palette |
0–~131 | Palette index. 0–71 are built-in WLED palettes; 72+ are user-defined. |
color1, color2, color3 |
RGB triples | Three colour slots. Per-effect role. |
Per-effect labels¶
The same field has different meanings across effects:
check1is "Reverse" on Color Wipe but "Use Color 2" on Plasma.custom2is "Fade time" on Fade but "Density" on Twinkle.
The Host's RL Preset editor reads each effect's label string from
the WLED firmware's effect metadata (FX.cpp _data_FX_MODE_*
strings) and:
- Renames the input fields when you switch the effect.
- Hides fields the active effect doesn't use.
- Shows the generic name (
Speed,Intensity) when the effect uses the WLED default for that slot.
This is why the editor's Speed field might be called "Cycle speed" on a Rainbow effect and "Pulse rate" on a Breath effect — the firmware is telling the host what each slot means for that effect.
For the catalogue of which effects render identically across nodes
when only strip.timebase is synced (a prerequisite for offset
mode), see
deterministic-effects.md.
The flags byte (shared with OPC_PRESET)¶
Six user-intent flag bits ride along with every OPC_CONTROL and
OPC_PRESET:
| Flag | Bit | What it does |
|---|---|---|
RL_FLAG_POWER_ON |
0 | Auto-derived from brightness > 0. Turns the segment on/off. |
RL_FLAG_ARM_ON_SYNC |
1 | Defer apply until the next OPC_SYNC. This is how multi-device choreographies fire on the same wall-clock instant. |
RL_FLAG_HAS_BRI |
2 | Brightness field is meaningful. Auto-set if you supply a brightness. |
RL_FLAG_FORCE_TT0 |
3 | Force transition time 0 — no fade between effects. Useful for sharp colour changes. |
RL_FLAG_FORCE_REAPPLY |
4 | Re-apply even if the parameters haven't changed (useful after a node was reflashed and lost state). |
RL_FLAG_OFFSET_MODE |
5 | Use the device's stored offset configuration. Gates participation in offset mode (see below). |
The flags byte is built host-side via
racelink/domain/flags.py::build_flags_byte — never assemble it by
hand, because RL_FLAG_POWER_ON and RL_FLAG_HAS_BRI are
auto-derived and easy to get wrong.
Operator workflow — the typical OPC_CONTROL sends¶
When you click Apply RL Preset on the Scenes page, the host:
- Looks up the named RL preset from
~/.racelink/rl_presets.json(operator-defined effect snapshot). - Materialises the parameters into a fieldMask + payload.
- Sends one
OPC_CONTROLto the target group (or device).
When you click Apply RL Effect (the inline-parameter scene
action), the host:
- Reads the parameters from the action's
paramsblock. - Builds the same fieldMask + payload.
- Sends one
OPC_CONTROL(no preset lookup involved).
What is not in OPC_CONTROL¶
- No status reply.
OPC_CONTROLis fire-and-acknowledge (ACK only). The node does not echo back its current state. The host trusts its own send-cache. To re-read the device, sendOPC_STATUS. - No segment selector (yet). The current implementation applies to segment 0 (the whole strip). Multi-segment work is a v2 topic.
OPC_OFFSET — configuring per-group time offsets¶
What it is¶
OPC_OFFSET configures a per-device delay that takes effect on
the next ARM_ON_SYNC effect. The wire format is a tagged union:
one byte selects the mode, the body bytes that follow encode the
parameters for that mode.
Five modes ship today:
| Mode | Wire size | Per-device offset formula |
|---|---|---|
none |
2 B (header only) | 0 — clears the stored offset; device leaves offset mode |
explicit |
4 B | Constant offset_ms (operator-supplied) |
linear |
6 B | base_ms + groupId × step_ms |
vshape |
7 B | base_ms + abs(groupId − center) × step_ms |
modulo |
7 B | base_ms + (groupId mod cycle) × step_ms |
base_ms and step_ms are signed int16 (negative steps produce
reverse cascades — group 5 fires first, group 0 last). The
firmware clamps the final per-device value to [0, 65535] ms.
The two wire paths the host can take¶
For the same operator-visible scene, the runner picks between two strategies depending on the participating groups:
Strategy A — broadcast formula. When the operator picks
Broadcast (or selects every currently-known group, which the
save-time canonicaliser collapses to broadcast — see
scene-format.md)
together with a formula mode (linear / vshape / modulo), the host
sends one OPC_OFFSET to the broadcast address
(groupId = 255) with the formula. Every node evaluates the
formula against its own groupId and stores the result. One
packet configures the whole fleet.
Strategy B — per-group explicit. When the operator selects a
sparse list of groups (target.kind == "groups" with a strict
subset of the known set), or chose explicit mode, the host
evaluates the formula host-side and sends N OPC_OFFSET
packets — one per group, in explicit mode with the resolved
offset_ms. The host pays the airtime in exchange for per-group
precision.
Strategy C — broadcast formula + sparse NONE overrides.
When the participants are a majority of the known fleet and
the mode is a formula, the host sends one broadcast formula
packet plus one OPC_OFFSET(NONE) per non-participating group
to deactivate them. The optimizer picks this over Strategy B
when 1 + |non-participants| < |participants|. See
Broadcast Ruleset for the
end-to-end wire rules.
The runner makes this decision automatically. The cost-estimator badge in the scene editor shows what wire path will be taken ("≈ 3 pkts" vs "≈ 12 pkts"). Operators don't pick — they author the intent (Broadcast / Groups / per-group explicit), and the runner picks the shortest wire encoding.
The strict acceptance gate¶
This is the part that confuses operators on first contact, so it's worth a careful explanation.
Every device has two pieces of state:
- Active offset — what the device is currently using.
NONEmeans "no offset, segment plays immediately on receipt". - Pending change — a fresh offset configuration that has not
yet been materialised into the active offset. Materialisation
happens when the next
OPC_PRESET/OPC_CONTROLpacket that setsOFFSET_MODE=1arrives, OR when anOPC_SYNCfires against anARM_ON_SYNCqueued effect.
The device's effective offset is pendingChange if it's valid,
else active. Now the gate logic:
Packet's OFFSET_MODE |
Receiver's effective offset | Gate result |
|---|---|---|
| 1 (use offset) | non-NONE (offset configured) | accept — apply with stored offset |
| 0 (no offset) | NONE (no offset configured) | accept — apply normally |
| 1 | NONE | drop silently — packet asks to use offset, but no offset is configured |
| 0 | non-NONE | drop silently — device is in offset mode; only OPC_OFFSET(NONE) exits it |
The two "drop" rows are features, not bugs:
- Strategy-A scope filter. Strategy A broadcasts a single
OPC_CONTROLwithOFFSET_MODE=1to every device on the fleet. Devices that aren't in the offset set drop the packet. Result: one broadcast, scope-filtered to exactly the devices that should respond. - State-stickiness. Once a device has been transitioned into
offset mode, it stays there until you explicitly send
OPC_OFFSET(NONE)and materialise the change. RandomF=0packets cannot accidentally take the device out of offset mode — that would silently break choreographies.
Leaving offset mode — the only valid sequence¶
You must send OPC_OFFSET(NONE) and then materialise it. Two
materialisation paths:
- Immediate-apply path:
OPC_OFFSET(NONE)followed by anOPC_PRESETwithF=0. The preset is the materialisation trigger. - Deferred-apply path:
OPC_OFFSET(NONE)followed by anOPC_CONTROLwithARM_ON_SYNC=1andF=0, followed byOPC_SYNC. The SYNC handler materialises the pending NONE.
The host's scene runner does both in one operator action: the
offset_group container action with mode=none sends
OPC_OFFSET(NONE) to the participants in Phase 1, then sends each
child action with F=0 in Phase 2. After the scene runs, the
participants are out of offset mode and accept normal packets
again.
Operator pitfall — "I ran a normal scene but nothing happened"¶
If the targeted devices are still in offset mode (a prior
offset_group(linear/...) scene didn't get cleaned up), a normal
scene's children fly with F=0. The strict gate drops every
child silently. The masterbar shows TX activity, but the devices
don't react.
Fix: insert an offset_group(mode=none, children=[…]) scene
before the normal scene. The placeholder children carry F=0 to
materialise the NONE transition.
Pure clear without an effect¶
If you want to clear offset mode without playing any visible
effect, use mode=none with a placeholder child like
rl_effect with mode=0 (Solid) and brightness=0. The
OPC_OFFSET(NONE) packet does the work; the placeholder child
just carries F=0 to trigger materialisation.
OPC_SYNC — fire armed effects + adjust timebase¶
What it is¶
OPC_SYNC is the broadcast packet that does two distinct jobs at
once:
- Adjust
strip.timebase. The packet carries a 24-bit gateway-relative timestamp. Every receiver sets itsstrip.timebaseso thatstrip.nowequals the master time. This synchronises the time base of all WLED segments — required for deterministic effects to render identically across nodes. - Materialise armed effects. Devices with an
ARM_ON_SYNCqueued effect fire it now (with the configured offset, if any).
Two forms — autosync vs deliberate fire¶
OPC_SYNC is variable-length: 4 bytes (legacy / autosync form) or
5 bytes (deliberate-fire form).
| Form | Body | Triggers ARM_ON_SYNC? | Used by |
|---|---|---|---|
| 4 B | ts24 + brightness |
No | The gateway's autosync timer (every 30 s while idle). Device only adjusts strip.timebase; pending arm-on-sync state stays armed. |
| 5 B | ts24 + brightness + flags |
Yes if SYNC_FLAG_TRIGGER_ARMED is set |
The scene runner's _run_sync action. Device adjusts timebase and materialises pending arm-on-sync state. |
SYNC_FLAG_TRIGGER_ARMED (bit 0 of the trailing flags byte, value
0x01) is the only flag bit currently defined. Bit 1 is reserved
for a future "per-group selector" extension.
Why the split?¶
Two needs:
- Continuous timebase synchronisation. Without periodic
timebase nudges, each node's
millis()drifts independently; cyclic effects slowly de-sync. The autosync timer keepsstrip.timebasealigned without disturbing user-armed effects. - Deliberate, atomic multi-device fire. The scene author wants to say "all five groups, fire your queued effect now, on this exact LoRa tick". The 5-byte form does that.
The two roles MUST stay separated. If autosync triggered armed effects, every 30 s the fleet would fire whatever was queued, which is operator-hostile.
Synchronised rollout caveat¶
A node flashed with old firmware has req_len = 4 strict and
rejects the 5-byte deliberate-fire packet. Flash every node
before deploying a new host, or you get a fleet that won't
respond to deliberate sync fires.
The gateway accepts both lengths from the host and passes the flags byte through end-to-end.
What the operator sees¶
When an operator clicks Run on a scene that ends with a Sync
action:
- The scene's
arm_on_syncchildren land on the targeted devices (viaOPC_PRESETorOPC_CONTROLwithARM_ON_SYNC=1). Each device queues the effect, doesn't render it yet. - The
Syncaction sends one 5-byte broadcastOPC_SYNCwithSYNC_FLAG_TRIGGER_ARMED=1. - Every armed device fires its queued effect on the same LoRa
tick — modulo each device's stored offset (see
OPC_OFFSETabove).
Visually: the operator sees the cascade pattern fire across the fleet at once.
"Stop on error" interaction¶
Each scene carries a Stop on error checkbox (default ON). If a
preceding arm_on_sync action fails on some device, the runner
aborts before reaching the Sync action — and any devices that
did arm successfully sit there forever with their armed effect
unfired. Behaviour:
- On the next operator action that materialises arm state (a fresh
Syncor anyOPC_PRESET/OPC_CONTROLwithF=0after a cleanup), the old armed state will fire / clear. - In practice, run a
offset_group(mode=none)scene to flush state before the next race.
If you want a scene to forge ahead despite individual failures,
uncheck Stop on error — armed actions still queue, the Sync at
the end still fires the ones that did arm, and the failed actions
are recorded in the run summary.
How the three opcodes compose — three operator workflows¶
Workflow 1: Plain scene, no offset, no SYNC¶
The simplest case — apply a preset to a group right now.
Wire: 1× OPC_CONTROL (or OPC_PRESET if it's an RL preset that
maps to a numeric WLED slot). Flags F=0. Device renders
immediately.
Workflow 2: Multi-group simultaneous fire¶
Operator wants groups 1, 2, 3 to all start the same effect on the same instant.
[Apply RL Preset, arm_on_sync] → group 1 → preset "Go"
[Apply RL Preset, arm_on_sync] → group 2 → preset "Go"
[Apply RL Preset, arm_on_sync] → group 3 → preset "Go"
[Sync]
Wire: 3× OPC_CONTROL with ARM_ON_SYNC=1 (or 1× broadcast if all
groups want the same effect; the runner can choose), then 1×
5-byte OPC_SYNC. Devices queue, then fire on the SYNC tick.
Workflow 3: Cascade across all groups (offset mode)¶
Operator wants a "wave" — group 0 fires first, group 1 fires 200 ms later, etc.
[Offset Group, "All groups", linear, base=0, step=200 ms]
└─ [Apply RL Preset, arm_on_sync, OFFSET_MODE=1] → preset "Go"
[Sync]
Wire (chosen by runner — Strategy A):
- 1×
OPC_OFFSET(linear, broadcast)— every device evaluatesbase + groupId * stepand stores the result. - 1× broadcast
OPC_CONTROL(arm_on_sync=1, OFFSET_MODE=1)— only devices with offset configured accept (others are filtered by the strict gate). - 1× 5-byte
OPC_SYNC(trigger_armed=1)— every armed device fires after its stored offset.
Result: 3 packets on the wire, fleet-wide cascade.
Workflow 4: Exit offset mode after the cascade¶
After Workflow 3, every device is in offset mode. Run a clean-up scene:
[Offset Group, "All groups", mode=none]
└─ [Apply RL Effect, arm_on_sync, mode=0, brightness=0]
[Sync]
Wire:
- 1×
OPC_OFFSET(NONE, broadcast)— clears stored offset. - 1× broadcast
OPC_CONTROL(arm_on_sync=1, F=0)— placeholder, carriesF=0to materialise the NONE transition. - 1× 5-byte
OPC_SYNC(trigger_armed=1).
After this, the fleet is back to normal mode. Subsequent normal scenes work as expected.
Cyclic-effect phase-lock — the subtle detail¶
This is a firmware detail every operator running offset-mode cascades needs to know about, but you only run into it when you pick the wrong effect.
The catch. Cyclic effects (Breathe, Pacifica, Sinelon — any
effect whose render is f(strip.now)) compute their brightness
curve directly from strip.now. After OPC_SYNC aligns
strip.timebase across the fleet, every node has the same
strip.now. So Breathe on group 0 and Breathe on group 4 both hit
peak brightness at the same wall-clock instant — even though they
"started" 800 ms apart. The visual phase difference collapses to
zero. The cascade you intended turns into a synchronous pulse.
Why state-machine effects aren't affected. Traffic Light, Color
Wipe, Scan — these effects compute their phase relative to their
own start time, stored in SEGENV.step. The offset shifts when
each device starts; their internal phase shifts accordingly.
The fix (in firmware). The WLED usermod maintains a persistent
per-device activePhaseOffsetMs, subtracted from strip.timebase
after every SYNC. Concretely: device 4 with a 800 ms offset has its
strip.timebase set such that its strip.now runs 800 ms
behind master. Breathe on device 4 hits peak brightness 800 ms
later than on device 0. The offset that was originally just a
start delay becomes a phase delay too.
The fix is transparent to the operator — flash recent firmware and cyclic effects work in offset mode like state-machine effects.
Practical guidance. When picking effects for offset cascades, prefer the deterministic effects catalogue (deterministic-effects.md) either way — only those effects render identically across nodes under any synchronisation regime. If you observe phase-lock on a deterministic cyclic effect, your node firmware is too old; flash it.
For firmware contributors. The usermod tracks two variables:
activePhaseOffsetMs— the currently-active per-device phase shift (set when an effect is applied via the offset-mode path, cleared when a non-offset effect is applied).pendingDeferredOffsetMs— captured at deferral time so thatserviceDeferredApply()can updateactivePhaseOffsetMsconsistently.
After every OPC_SYNC the firmware:
- Computes the drift error against the logical (master-aligned)
timebase, i.e.
(int32_t)strip.timebase + activePhaseOffsetMs. Without this term, a non-zeroactivePhaseOffsetMswould always look like anerr == activePhaseOffsetMsand trigger endless hard-resyncs. - Applies the drift correction to
strip.timebase. - Re-asserts the offset by subtracting
activePhaseOffsetMsfromstrip.timebase. The device'sstrip.nowthen runsactivePhaseOffsetMsms behind master, which produces the intended phase shift in any time-based effect.
The applyPhaseOffsetToTimebase() helper centralises the
subtraction and is called after every strip.timebase write
(drift correction, hard resync, inline-apply, deferred-apply). The
drift-correction error is computed against
strip.timebase + activePhaseOffsetMs — i.e. the logical
timebase as if the offset were zero — so the correction tracks
master-relative drift, not the offset itself.
Local-state update timing — when the host mirrors a wire send¶
Every operator-initiated wire send must eventually be reflected in
the host's local device DTO so the device-table and other UI
surfaces show the current state without requiring a manual
OPC_STATUS poll. The when is determined by the opcode's reply
policy:
RESP_ACK/RESP_SPECIFIC— the wire send waits for a device-side acknowledgement. The host updatesdev.*only after the reply lands. Examples:OPC_CONFIG'sdev.specialswrite inapi_specials_configruns inside the post-ACK persistence path;OPC_SET_GROUPwaits for ACK before considering the move applied (bulk_set_group,_spawn_auto_reassign_worker). Rationale: a packet the device never received must not corrupt the host's view of "what the device has".RESP_NONE— fire-and-forget. The host updatesdev.*immediately (optimistic), because no reply is coming and the operator expects to see the change reflected without a manualGet Status. Examples:OPC_PRESET's eager mirror insend_device_preset/_update_group_preset_cache;OPC_CONTROL's mirror insend_control/_update_group_control_cache(flags,effectIdfrommode,brightnesswhen HAS_BRI). Rationale: the operator's last intent is the most accurate reflection of device state we have until the nextOPC_STATUS.
OPC_SYNC and OPC_OFFSET are also RESP_NONE but have no
per-device DTO field to mirror — OPC_SYNC triggers
already-armed effects on the device side; OPC_OFFSET configures
per-group offset state that the host doesn't surface in the
device table.
OPC_CONFIG — device configuration¶
OPC_CONFIG is a different shape of opcode from CONTROL/OFFSET/SYNC:
it does not carry effect parameters and it never participates in
ARM/SYNC dispatch. It carries a single option byte plus four data
bytes and tells the device to change a persistent setting.
For the byte-level format and the full table of option codes, see
../reference/wire-protocol.md §P_Config.
This section explains the semantic model behind the LED-config
override options (0x05–0x0A, 0x0F) added in 2026-05.
Properties vs Methods¶
The option codes split into two semantic kinds. The split is not visible on the wire (every option uses the same 5-byte body) but it drives both the host UX and which options support live read-back.
- Properties are persistent values stored on the device:
FPS (
0x05), segment geometry (0x06/0x07), ABL max mA (0x08), default brightness (0x09), transition duration (0x0A), STARTBLOCK number-of-slots (0x8C) and first slot (0x8D). The host can read them back viaOPC_GET_CONFIG. The Device Options dialog renders them as input rows with Save and a divergence badge that compares the host's stored intent against the live device value. - Methods are one-shot side-effecting commands: Clear master
MAC (
0x02), Clear all overrides / "Reset to RaceLink defaults" (0x0F), Forget master MAC (0x80), Reboot (0x81). There is no meaningful "current value" to read; the device performs the action and ACKs. The dialog renders them as action buttons — destructive ones (0x0F,0x80,0x81) gate behind a confirm prompt. - Hybrid options (
0x01MAC filter on,0x03MAC filter persist,0x04WLAN AP) are persistent like properties but their state ships inSTATUS_REPLY'sconfigByterather than viaOPC_GET_CONFIG. They render as toggle commands.
See the option-code table in
../reference/wire-protocol.md §"Properties vs Methods"
for the full classification.
Why the override layer exists¶
The racelink_wled usermod runs applyRaceLinkDefaults() on every
boot, after WLED has loaded cfg.json. That function compares a
small set of WLED settings (FPS, ABL, Gamma, AP behaviour, …)
against compile-time RACELINK_DEFAULT_* constants and overwrites
the runtime globals if they differ. The mechanism prevents
per-device drift in settings that affect cross-device synchronisation
and visible uniformity — most importantly the V3↔V4 Strobe-drift
case. The enforced set (FPS, ABL, gamma correction, AP open
behaviour) is documented in
../RaceLink_WLED/operator-setup.md §"RaceLink-enforced LED defaults".
The price is that operator UI changes to those settings get reverted on every reboot. That is exactly the desired property for fleet uniformity, but it means the fleet operator has no sanctioned path to deviate from a default for a single device — until OPC_CONFIG.
OPC_CONFIG option codes 0x05+ are the host-authorised path for
sanctioned deviations. The host sends an override; the device stores
it persistently in cfg.json (under RaceLink.overrides) and
remembers it across reboots; applyRaceLinkDefaults() consults the
override and uses it instead of the compile-time default.
Two policies — A and B¶
The semantics of an override depend on whether the underlying
setting is fleet-uniform-required (Policy A) or
operator-tunable (Policy B). Both policies share the same wire
format and persistence path; they differ in what
applyRaceLinkDefaults() does when no override is set.
Policy A — fleet-default-replacing. Used for FPS (0x05) and
ABL max mA (0x08). Behaviour:
- No override: device enforces
RACELINK_DEFAULT_*on every boot. Operator UI changes to the underlying WLED setting are reverted. - Override set: device enforces the override value on every boot. Operator UI changes are still reverted, but the new "default" is the host-authorised value.
- Override cleared (
0x0F): next boot, compile-time default applies again.
Policy B — operator-default-honouring. Used for Segment 0/1
geometry (0x06/0x07), default brightness briS (0x09), and
transition duration (0x0A). Behaviour:
- No override: device leaves the underlying WLED setting untouched.
Operator UI changes are persisted by WLED's normal
cfg.jsonwrite path. - Override set: device enforces the override value on every boot. Operator UI changes to the underlying setting are reverted on next reboot — the host has taken authoritative control.
- Override cleared (
0x0F): next boot, operator-saved values are honoured again.
The split exists because some settings (FPS, ABL) cause visible fleet-wide problems when they drift between devices, and some (brightness, transition feel, segment geometry) are reasonable per-device tuning targets. The host can use Policy B for "push this value to all devices in this group" without imposing a permanent default on the fleet.
Persistence and visibility¶
After a successful OPC_CONFIG that triggered an override change,
the device sets WLED's internal configNeedsWrite flag. WLED's
main loop calls serializeConfigToFS() on its next iteration and
writes cfg.json with both the override (in RaceLink.overrides.*)
and the affected runtime global (in hw.led.fps, light.gc.*, etc.)
in sync.
The host can therefore observe the device's current overrides via
plain GET /json/cfg:
{
"RaceLink": {
"overrides": {
"fps": 60,
"seg0": { "start": 0, "stop": 18 },
"seg1": { "start": 18, "stop": 36 }
}
}
}
Absence of a key means "no override". This HTTP path remains as an
out-of-band debug/diagnostic fallback. The primary read path is
the wire opcode
OPC_GET_CONFIG,
which works without WiFi reach to the node — see Live read and
divergence resolution below.
Live read and divergence resolution¶
The Device Options dialog shows three pieces of information per property row:
- The host-stored intent (the value in
dev.specials[…]) — editable via the input field. - The schema default (italic helper text under the label).
- The live device value, fetched via
OPC_GET_CONFIGwhen the dialog opens.
When the dialog opens, the host fires one OPC_GET_CONFIG per
property in the active capability tab, sequentially (the gateway is
half-duplex, so parallel reads would queue at the transport layer
anyway). Replies populate a per-(addr, option-key) cache; the row
component compares them against the host-stored intent and renders
one of:
- Match —
device: <value> ✓(no warning). - Divergence —
device: <value> ⚠plus two compact buttons: - Push host → re-sends
OPC_CONFIGwith the host's stored value, overwriting the device. - Import device → adopts the device's reported value into the
host's stored intent (writes
dev.specials[…]only — noOPC_CONFIGpacket sent). - Read failed (timeout / no reply) —
device: ?plus a Retry button.
Save behaviour: the device's OPC_CONFIG ACK is the persistence
confirmation. The host therefore does not fire a follow-up
OPC_GET_CONFIG after Save — it just optimistically updates the
live cache to the just-saved value so the divergence badge clears
immediately. If the save's task fails (ACK timeout, device
offline), the operator sees a task-error toast and can close +
reopen the dialog to re-read the actual device state.
Reset to RaceLink defaults (OPC_CONFIG 0x0F)¶
The destructive maintenance method that clears every host-set RaceLink override on a single device and applies the RaceLink baseline values at runtime — no reboot required. Surfaced in the WLED tab of the Device Options dialog as a confirm-gated action button.
Post-reset effects (all applied immediately on the device, then
saved to cfg.json):
- FPS →
RACELINK_DEFAULT_FPS(75 unless build-flag overridden). - ABL max mA →
RACELINK_DEFAULT_ABL_MAX_MA(0, ABL disabled). - Default brightness
briS→RACELINK_DEFAULT_BRIS(128). - Transition duration →
RACELINK_DEFAULT_TRANSITION_MS(700 ms). - Segments → reset to a single
seg[0]spanning the full strip (viastrip.resetSegments()); any extra segments are removed.
The RACELINK_DEFAULT_BRIS and RACELINK_DEFAULT_TRANSITION_MS
constants exist solely as the reset-target values; they are NOT
enforced by applyRaceLinkDefaults() on every boot, because Policy
B preserves operator-cfg semantics in steady state. They only fire
inside the 0x0F handler.
Host-side effects:
dev.specials[wled_*]are reset to the host's schema defaults.- The dialog re-runs the on-open read pass (
refresh: "active-tab"). - All Policy A rows and the briS / transition rows match the defaults — no divergence badge.
- Segment rows show divergence because the host has no reliable strip-length default (different builds ship different LED counts). The operator clicks Import device on each segment row to adopt the device's actual values into the host database. This is a single, deliberate step per affected row.
Boot-time interaction with applyRaceLinkDefaults()¶
Boot
├─ WLED loads cfg.json (incl. RaceLink.overrides.*)
├─ readFromConfig() populates the in-memory `overrides` struct
└─ UsermodRaceLink::setup()
└─ applyRaceLinkDefaults()
├─ Policy A: target = override.fooSet ? override.foo : DEFAULT_FOO
│ if (live != target) write live; mark configNeedsWrite
└─ Policy B: if (override.fooSet && live != override.foo)
write live; mark configNeedsWrite
└─ if (configNeedsWrite) serializeConfigToFS() // re-writes cfg.json
A drifted device thus self-heals on the first boot after correction
(the [RaceLink] enforcing … log lines fire and cfg.json is
re-saved). Subsequent boots are silent.
Host implementation notes¶
- OPC_CONFIG and OPC_GET_CONFIG are both unicast-only. To configure a fleet, the host iterates per node. Group-broadcast support is on the Phase 2.2-Outline list but not yet implemented.
- Expect an
OPC_ACKreply perOPC_CONFIGsend. The ACK is sent before the option is applied (some options take time, e.g. segment append). The ACK is the host's persistence confirmation — no follow-up read is needed after a successful Save. - Apply order between siblings on a node: each OPC_CONFIG is independent; a host that sends 0x05, 0x06, 0x07 in sequence will see all three persisted.
- Coordinate with the device's WLED UI: any setting touched by the override layer becomes "host-managed". The divergence-resolution flow in the Device Options dialog (see Live read and divergence resolution above) is the operator-facing surface for this.
- Host code-path (the full read+write pipeline):
- Schema entries live in
racelink/domain/specials.pyunderRL_SPECIALS["WLED"]["options"](properties) andRL_SPECIALS["WLED"]["functions"](methods). - Wire encode/decode lives in
racelink/services/specials_service.py(pack_option_value,unpack_option_value,write_specials). - Read-back service:
ConfigService.read_config(dev, option)inracelink/services/config_service.py. - Web routes:
POST /api/specials/config(write),POST /api/specials/get(read),POST /api/specials/config/import(operator imports the device's reported value into host db),POST /api/specials/action(methods incl.wled_reset_overrides). - Device-side code reference (single source of truth for
behaviour): the
OPC_CONFIGandOPC_GET_CONFIGdispatchers inusermods/racelink_wled/racelink_wled.cppandapplyRaceLinkDefaults()in the same file.
Putting it together — the airtime budget¶
LoRa airtime at SF7/250 kHz/CR4:5 is roughly 6 ms per 10 bytes of payload. A typical race-start scene:
| Wire op | Body | Estimated airtime |
|---|---|---|
1× OPC_OFFSET(linear, broadcast) |
6 B | 8 ms |
1× broadcast OPC_CONTROL(arm) |
5 B | 8 ms |
1× 5-byte OPC_SYNC |
5 B | 8 ms |
| Total | ≈ 24 ms airtime |
Plus host-side overhead (LBT random backoff, USB framing, runner dispatch) — typically 30–50 ms of wall clock per packet. So the above scene runs in ~150 ms wall clock, with 24 ms of actual airtime.
The cost-estimator badge in the scene editor shows the estimated airtime per action and the scene total. After a successful run, the badge also shows the measured wall-clock duration. The two are not directly comparable — the delta is the overhead — but a sustained 10× ratio means something is wrong (slow USB, retry storm, etc.).
For tuning, see
wire-protocol.md §"USB latency tuning".