# Knob LED Active-color command (cmd 0x27)

CS Pro (W17) / KS Pro (W18) rotary encoders expose a per-knob "active position"
LED color setting via cmd `0x27`. Captured live with PitHouse driving an R5
base + W17 wheel through the linux↔Windows bridge:

- Capture: `sim/logs/bridge-20260510-111708.jsonl`
- Tooling: `tools/bridge-watch-knobs` (live tail of cmd-0x27 traffic)

## Wire format

All cmd-0x27 frames use:

- Wheel device id `0x17`
- Body: `27 [knob_idx] [role] [R] [G] [B]` (3-byte cmd id + 3-byte RGB payload)
- `knob_idx` = 0..4 (knob 1..5; CS Pro uses 0..3, KS Pro uses 0..4)

Group byte selects the operation:

| direction | grp  | role | meaning                                               |
|-----------|------|------|-------------------------------------------------------|
| h2b       | 0x3F | 0    | **WRITE** — set the knob's active-position LED color  |
| b2h       | 0xBF | 0    | echo of the WRITE (`grp = 0x3F \| 0x80`)              |
| h2b       | 0x40 | 0    | READ — request the wheel's stored Active color (RGB=0)|
| h2b       | 0x40 | 1    | READ — request the wheel's *live* active-LED color    |
| b2h       | 0xC0 | 0    | response to ROLE0 read (the persisted Active color)   |
| b2h       | 0xC0 | 1    | response to ROLE1 read (currently lit ring LED color) |

The `role` byte is **not** "background vs primary" as our codebase previously
labelled it. PitHouse never writes `role=1`; doing so produces no visible
change on the wheel (the wheel echoes but ignores). `role=1` is a read-only
introspection of the LED at the knob's *current rotation position*.

### WRITE (h2b 0x3F)

```
7E 06 3F 17 27 [knob] 00 [R] [G] [B] [csum]
```

Sets the active-LED color for the given knob. Visibly changes the single LED
at the knob's currently-active rotation position; the other 11 ring LEDs are
untouched. Wheel acknowledges with the same body on `grp=0xBF dev=0x71` (bit-7
toggled group, nibble-swapped device).

### READ ROLE0 (h2b 0x40, RGB=0)

```
7E 06 40 17 27 [knob] 00 00 00 00 [csum]
```

PitHouse polls each knob's stored Active color at ~1 Hz (one frame per knob
spread across the second). Wheel responds on `grp=0xC0 dev=0x71` with the
persisted RGB.

### READ ROLE1 (h2b 0x40, RGB=0)

```
7E 06 40 17 27 [knob] 01 00 00 00 [csum]
```

Polls the *live* color of the LED at the knob's current rotation position
(rotates with encoder use). PitHouse only polls ROLE1 for the knob currently
focused in its UI; the other knobs only get ROLE0 reads.

## Connect-time read sequence

Within ~3 s of opening the wheel session, PitHouse reads ROLE0 once for each
knob (sweeps knob 1..N, ~50 ms apart), then begins steady-state polling at
~1 Hz mixed with one ROLE1 read for the currently-focused knob.

Example excerpt (relative to bridge start, capture
`bridge-20260510-111708.jsonl`):

```
t=37798.0  READ knob1 ROLE0 → RESP #ff0000   (saved Active = red)
t=37798.0  READ knob4 ROLE0 → RESP #ffffff
t=37798.0  READ knob3 ROLE0 → RESP #ffffff
t=37799.6  READ knob3 ROLE0 → RESP #ffffff
t=37799.7  READ knob3 ROLE1 → RESP #2d80ff   (live active-LED — varies)
```

## PitHouse UI mapping

Per the W17 PitHouse knob page:

- **One "Active" swatch per knob** → emits a single cmd-0x27 ROLE0 WRITE on
  click. Wheel updates the active-position LED immediately.
- **Twelve "Inactive" swatches per knob** → write per-LED ring colors via
  Group 3 (cmd `0x1F 0x03 0x01 [led_idx] [RGB]`, see next section).
- **One "Default background" swatch per knob** → bulk write of the inactive
  swatch color to all 12 ring LEDs (Group 3). Equivalent to clicking each of
  the 12 inactive swatches in turn.

## Group 3 (Rotary) per-LED writes — Inactive swatches

Cmd `0x1F` is the multi-page LED color write. Group 3 is the wheel's rotary
ring layer; each ring LED has its own color slot. The wire body for a single
write is:

```
1F 03 [sub] [led_idx] [R] [G] [B]
```

`led_idx` is contiguous across all knobs (knob 1 = idx 0..11, knob 2 = 12..23,
etc.). On `grp=0x3F dev=0x17` for the write; wheel echoes the same body on
`grp=0xBF dev=0x71`.

The `sub` byte was previously assumed to always be `0xFF` (matching cmd 0x1F's
"page-style" writes for groups 0/1). PitHouse's Inactive swatches use
**`sub=0x01`** instead. Reads of Group 3 LED state on `grp=0x40` also use
`sub=0x01`. The semantic difference between `0xFF` and `0x01` writes is not
yet pinned down — `0x01` is what PitHouse uses end-to-end.

### Verification (2026-05-10, capture `bridge-20260510-115644.jsonl`)

User changed PitHouse Inactive swatches "spot 1" and "spot 6" on the first
two knobs to white. Captured frames:

| User action       | Wire body                  | led_idx | Wheel echo |
|-------------------|----------------------------|---------|------------|
| Knob 1 spot 1     | `1F 03 01 00 FF FF FF`     | 0x00 (0)  | ✓        |
| Knob 1 spot 6     | `1F 03 01 06 FF FF FF`     | 0x06 (6)  | ✓        |
| Knob 2 spot 1     | `1F 03 01 0C FF FF FF`     | 0x0C (12) | ✓        |
| Knob 2 spot 6     | `1F 03 01 12 FF FF FF`     | 0x12 (18) | ✓        |

Confirmed mapping:
- Knob 1 ring LEDs occupy `led_idx = 0..11`, knob 2 = `12..23`, etc. (CS Pro;
  KS Pro is also contiguous but knob 3's ring is 8 LEDs — its idx range is
  still positioned at the knob's contiguous offset).
- The user's "spot 1 → idx 0, spot 6 → idx 6" matches `led_idx = knob_offset
  + spot_N` where `spot_N` is whatever index PitHouse exposes in its swatch
  grid (the visible labeling vs underlying index hasn't been confirmed end
  to end, but the wire indices follow contiguous knob offsets).

### Bulk "Default background" swatch

Confirmed twice on 2026-05-10 (knob 3 to red `#ff0000`, knob 3 to orange
`#ffa800` paired with an Active write):

- The bulk swatch has **no special opcode** — PitHouse just emits 12 of the
  same `1F 03 01 [idx] [RGB]` frames it uses for individual swatches, one per
  LED in the knob's ring.
- Frames are pipelined in groups of 1–5, ~50 ms between groups; total burst
  ~150–200 ms for all 12.
- Frame ordering is mostly sequential by `led_idx` but small reorderings are
  visible (e.g. `30,29` swapped vs strict ascending order). Order doesn't
  affect the final on-wheel state.
- Wheel echoes every frame on `grp=0xBF dev=0x71` with the same body. PitHouse
  retransmits any frame not echoed within ~250 ms — observed once during the
  orange bulk-set: `idx 32` was sent at t+0 then re-sent at t+255 ms. The
  wheel echoed both.

## Group 3 idle effect — cmd 0x1D

The "Idle effect" dropdown for the knob ring is driven by cmd `0x1D`. Same
opcode as the existing `wheel-telemetry-idle-effect` (group 0) and
`wheel-buttons-idle-effect` (group 1) — different group byte selects which
LED layer the effect applies to.

```
1D [group] [effect_id]      h2b grp=0x3F dev=0x17     write
```

`group` byte values match the LED group enumeration:
- `0x00` = RPM (Single/Group 0)
- `0x01` = Buttons (Group 1)
- `0x03` = Rotary / knob rings (Group 3)

Wheel echoes the same body on `grp=0xBF dev=0x71`. Length byte = `0x03`
(2-byte cmd id + 1-byte payload).

### Effect IDs (Group 3 / knob rings)

Captured from the PitHouse dropdown on 2026-05-10
(`bridge-20260510-115644.jsonl`):

| `effect_id` | Label in PitHouse |
|-------------|-------------------|
| `0x01`      | Constant          |
| `0x06`      | RGB pulse         |

The effect-ID enumeration is shared across all three LED groups (RPM,
Buttons, Rotary). PitHouse exposes the same set of choices in each group's
dropdown — the wheel firmware applies the chosen effect to whichever group
the cmd 0x1D was directed at. Our plugin's existing dropdowns
(`WheelIdleEffectCombo` for RPM, `WheelButtonIdleEffectCombo` for buttons)
are missing the `RGB pulse` entry; it should be added so the user has the
same set of options as PitHouse for every group.

| idx (wire) | Label        |
|------------|--------------|
| `0x00`     | Off          |
| `0x01`     | Constant     |
| `0x02`     | Breathing    |
| `0x03`     | Color Cycle  |
| `0x04`     | Rainbow      |
| `0x05`     | Sand Flow    |
| `0x06`     | RGB pulse    |

Confirmed wire mappings so far on 2026-05-10:
- Group 3 (knob ring): `0x01` Constant, `0x06` RGB pulse — verified live.
- Groups 0 / 1 (RPM, Buttons): `0x00`..`0x05` already shipped in our UI;
  `0x06` RGB pulse is supported by the wheel but not yet added to the
  dropdowns.

### Plugin alignment

`Protocol/MozaCommandDatabase.cs` registers:
```csharp
AddCommand("wheel-knob-idle-effect", "wheel", 64, 63, new byte[] { 29, 3 }, 1, "int");
// plus wheel-{single,ambient}-idle-effect for groups 2 and 4
```

The wheel UI does not yet expose this dropdown for the knob rings — adding
it would mirror the existing `WheelIdleEffectCombo` /
`WheelButtonIdleEffectCombo` plumbing in
`Devices/MozaWheelSettingsControl.xaml(.cs)`.

## Idle-effect speed slider — cmd 0x1E

Each animated effect (Breathing, Color Cycle, Rainbow, RGB Pulse, etc.) has
its own speed slider in PitHouse. The slider value is the animation period
in milliseconds, encoded big-endian u16:

```
1E [group] [effect_id] [ms_msb] [ms_lsb]    h2b grp=0x3F dev=0x17     write
```

Wheel echoes the same body on `grp=0xBF dev=0x71`. Length byte = `0x05`
(2-byte cmd id `[1E, group]` + 3-byte payload `[effect_id, ms_msb, ms_lsb]`).

Verified on 2026-05-10 (`bridge-20260510-115644.jsonl`):

| Time      | Body                | Decode                                         |
|-----------|---------------------|------------------------------------------------|
| 40734.181 | `1E 03 02 03 B6`    | Group 3 (Rotary), Breathing(0x02), 950 ms      |
| 40827.300 | `1E 00 02 03 5E`    | Group 0 (RPM),    Breathing(0x02), 862 ms      |

Cross-group identical format — same as cmd 0x1D, the group byte just
selects which LED layer the slider applies to.

### Existing plugin entries are mis-encoded

`Protocol/MozaCommandDatabase.cs` registers `wheel-telemetry-idle-interval`
and `wheel-buttons-idle-interval` with `PayloadBytes=3`, type `"int"`. The
default `BuildWriteInt` writer encodes the int as a 3-byte big-endian
integer, which lands on the wire as `[high_byte, mid_byte, low_byte]`. That
maps to `[effect_id, ms_msb, ms_lsb]` only when the high byte happens to be
the desired effect; for `WriteSetting(name, 950)` the effect_id ends up as
`0x00` (Off), which the wheel silently absorbs. Result: the existing speed
sliders effectively do nothing for any effect other than Off.

Fix path: change those entries to `"array"` type and have callers build the
payload as `[effect_id, ms_msb, ms_lsb]` explicitly. Updated as part of the
2026-05-10 patch alongside the new `wheel-knob-idle-interval` /
`wheel-group3-idle-interval` registrations.

### Active + Bulk in the same UI commit

When the user sets both an Active color and a Bulk Default in PitHouse and
commits the page, the cmd 0x27 ROLE0 write and the 12 cmd 0x1F 0x03 0x01
writes are emitted **interleaved within the same ~200 ms burst**, not
sequenced (Active then bulk, or bulk then Active). They target independent
wheel state and do not overwrite each other:

- cmd 0x27 ROLE0 → updates the wheel's per-knob "active LED color" override
- cmd 0x1F 0x03 0x01 → updates the per-LED ring base color

The wheel composes them at render time: ring LEDs show their per-LED base
color, except the LED at the knob's current rotation position which shows
the Active override.

## Plugin alignment (status as of 2026-05-10)

## Verified behaviors

Confirmed with multi-knob test (capture `bridge-20260510-111708.jsonl`):

| Knob | UI picked | Wire bytes (body)                | Echoed |
|------|-----------|----------------------------------|--------|
| 1    | red       | `27 00 00 f7 00 00`              | ✓      |
| 2    | green     | `27 01 00 00 ff 00`              | ✓      |
| 3    | blue      | `27 02 00 00 00 ff`              | ✓      |
| 4    | yellow    | `27 03 00 ff f6 85`              | ✓      |

PitHouse normalizes saturation in its picker (e.g. red comes out as `0xF7`
rather than `0xFF`); this is UI-side, not protocol.

## Plugin alignment (status as of 2026-05-10)

`Protocol/MozaCommandDatabase.cs` registers (post-rename):

```csharp
AddCommand($"wheel-knob{k}-active-color", "wheel", 64, 63, new byte[] { 0x27, k-1, 0 }, 3, "array");
AddCommand($"wheel-knob{k}-live-color",   "wheel", 64, 0xFF, new byte[] { 0x27, k-1, 1 }, 3, "array");
```

The earlier `wheel-knob{N}-bg-color` (also role=0, misnamed) and
`wheel-knob{N}-primary-color` (role=1, dead — wheel ignores) are dropped.
The "Active" swatch in `Devices/MozaWheelSettingsControl.xaml.cs` now
routes through `wheel-knob{N}-active-color` (cmd 0x27 ROLE=0), matching
PitHouse byte-for-byte.

`wheel-knob{N}-live-color` is read-only (no WriteGroup); on connect-time
state sync it polls the wheel for the live active-position color via
cmd 0x27 ROLE=1.
