diff --git a/crates/rsk-rescue/src/phy.rs b/crates/rsk-rescue/src/phy.rs index 026e507..8bfed7f 100644 --- a/crates/rsk-rescue/src/phy.rs +++ b/crates/rsk-rescue/src/phy.rs @@ -29,6 +29,9 @@ const TAG_LED_DRIVER: u8 = 0xC; // 0 = rgb (passthrough), 1 = grb (red/green swapped). PicoForge skips it as // unknown and drops it on a read-modify-write; RS-Key's own tools preserve it. const TAG_LED_ORDER: u8 = 0xD; +// RS-Key vendor tag: number of physically-connected addressable LEDs. +// 0 = unset (use the build's MAX_LEDS default). +const TAG_LED_NUM: u8 = 0xE; /// `led_order` wire value: a standard WS2812B (GRB) part, red↔green swapped. pub const LED_ORDER_GRB: u8 = 1; @@ -62,7 +65,7 @@ pub fn effective_usb_itf(phy: &PhyData) -> u8 { } /// Largest serialized record (every TLV present, 32-byte product). The trailing -/// `(2 + 1)` is the RS-Key `led_order` tag. +/// `(2 + 1) × 2` covers the RS-Key `led_order` and `led_num` tags. pub const PHY_MAX_SIZE: usize = (2 + 4) + (2 + 1) + (2 + 1) @@ -72,7 +75,8 @@ pub const PHY_MAX_SIZE: usize = (2 + 4) + (2 + 4) + (2 + 1) + (2 + 1) - + (2 + 1); + + (2 + 1) + + (2 + 1); // led_num const PRODUCT_CAP: usize = 32; @@ -120,6 +124,9 @@ pub struct PhyData { pub led_driver: Option, /// RS-Key WS2812 wire order (tag `0x0D`): `0` = rgb, `1` = grb. pub led_order: Option, + /// Number of physically connected addressable LEDs (tag `0x0E`); + /// `None` / `0` = use the build's `MAX_LEDS` default. + pub led_num: Option, } impl PhyData { @@ -159,6 +166,7 @@ impl PhyData { (TAG_ENABLED_USB_ITF, 1) => phy.enabled_usb_itf = Some(v[0]), (TAG_LED_DRIVER, 1) => phy.led_driver = Some(v[0]), (TAG_LED_ORDER, 1) => phy.led_order = Some(v[0]), + (TAG_LED_NUM, 1) => phy.led_num = Some(v[0]), _ => {} } p = &p[tlen..]; @@ -207,6 +215,9 @@ impl PhyData { if let Some(o) = self.led_order { w.tlv(TAG_LED_ORDER, &[o])?; } + if let Some(n) = self.led_num { + w.tlv(TAG_LED_NUM, &[n])?; + } Some(w.len) } } @@ -326,6 +337,9 @@ mod proofs { if kani::any() { phy.led_order = Some(kani::any()); } + if kani::any() { + phy.led_num = Some(kani::any()); + } let mut buf = [0u8; PHY_MAX_SIZE]; let n = phy.serialize(&mut buf).unwrap(); @@ -348,6 +362,7 @@ mod proofs { ); assert_eq!(got.led_driver, phy.led_driver); assert_eq!(got.led_order, phy.led_order); + assert_eq!(got.led_num, phy.led_num); } } @@ -368,6 +383,7 @@ mod tests { enabled_usb_itf: Some(USB_ITF_CCID | USB_ITF_HID), led_driver: Some(3), led_order: Some(LED_ORDER_GRB), + led_num: Some(4), }; let mut buf = [0u8; PHY_MAX_SIZE]; let n = phy.serialize(&mut buf).unwrap(); diff --git a/docs/build.md b/docs/build.md index 83b4ae2..80ae17b 100644 --- a/docs/build.md +++ b/docs/build.md @@ -44,9 +44,11 @@ flowchart TD | `LED_PIN` | `16` | `0..=29` | The status-LED GPIO for the `ws2812` and `gpio` backends (RP2350A). Default GPIO16 is the Waveshare RP2350-One. Point it at a free GPIO on boards that use 16 for something else; the indicator simply drives whatever pin you pick. (Unused by `pimoroni`, which has fixed PWM pins, and by `none`.) | | `LED_KIND` | `ws2812` | `ws2812` / `gpio` / `pimoroni` / `none` | The LED driver backend, and the **boot default** — a non-`none` build compiles all three so the driver/pin/order are runtime-switchable via `rsk hw` / PicoForge (see below). `ws2812` = a single addressable RGB on `LED_PIN` (the Waveshare default). `gpio` = a plain on/off LED on `LED_PIN` — hue/brightness collapse to lit/unlit, but the blink *pattern* still distinguishes statuses. `pimoroni` = a 3-pin PWM RGB (Pimoroni Tiny 2350: R=GPIO18, G=GPIO19, B=GPIO20, common-anode). `none` = no indicator (the status engine still runs; nothing renders it, and the phy LED fields are ignored). | | `LED_ORDER` | `rgb` | `rgb` / `grb` | WS2812 wire byte order (`ws2812` backend only). The Waveshare RP2350-One is unusually `rgb` (the default); standard WS2812B parts (e.g. the TenStar RP2350-USB) are `grb`. If a `ws2812` board comes up with red and green swapped (blue fine), flip this to `grb`. | +| `MAX_LEDS` | `8` | `1`–`64` | PIO state-machine and frame-buffer ceiling for addressable LEDs. The **actual** connected count is set at runtime via `rsk hw --led-num` and must be ≤ `MAX_LEDS`. Default 8 covers the common 1–8 range; boards with more (up to 64) override this.| | `FAKE_MKEK` / `FAKE_DEVK` | unset | 64 hex chars | **Test builds only.** Bakes a fake OTP master key / device key into the image instead of reading the OTP fuses, so the whole OTP migration path can be exercised with zero fuse writes. The build prints a loud warning and the key is greppable in the binary. Flashing a FAKE build onto a provisioned device migrates its data under the fake key — going back orphans that data (recovery = per-applet resets). Never flash one on a device you care about. | -The `LED_PIN` / `LED_KIND` / `LED_ORDER` values are **boot defaults** only. A +The `LED_PIN` / `LED_KIND` / `LED_ORDER` / `MAX_LEDS` values are **boot defaults** +only. A non-`none` build compiles all three backends, so the LED pin, driver, and wire order are runtime-configurable — no reflash — with `rsk hw` (or PicoForge), which write them to the device's `phy` record. The build knobs decide the diff --git a/docs/guides/led.md b/docs/guides/led.md index 501cdc7..d23c92c 100644 --- a/docs/guides/led.md +++ b/docs/guides/led.md @@ -3,51 +3,66 @@ The status LED is the device's only display. On the reference board — the **Waveshare RP2350-One** — it's a WS2812 addressable RGB on GPIO16. -Three properties of the indicator are **runtime-configurable**, like the USB -identity: they live in the device's `phy` record and change with `rsk hw` (or -PicoForge) **without reflashing**, because a non-`none` build compiles all -three backends. The `LED_KIND` / `LED_PIN` / `LED_ORDER` build knobs set the -*boot defaults* used when the phy record doesn't override them -([build.md](../build.md), [hardware.md](../hardware.md)): - -- **backend** — `LED_KIND` / `rsk hw --led-driver`: `ws2812` (addressable RGB, - the default), `gpio` (a plain on/off LED — can't show the colours below, but - the blink *pattern* still tells the four states apart), `pimoroni` (a 3-pin - PWM RGB, Pimoroni Tiny 2350), or the build-only `none`. -- **pin** — `LED_PIN` / `rsk hw --led-pin`: the `ws2812`/`gpio` data GPIO - (`0..=29`), for a board that wires its LED off GPIO16. -- **wire order** — `LED_ORDER` / `rsk hw --led-order`: a `ws2812` board whose - **red and green come out swapped** (blue unaffected) has the other byte - order — the Waveshare RP2350-One is `rgb` (the default), most other WS2812B - parts are `grb`. - -A `none` build is the exception: it renders nothing and ignores the phy LED -fields (there is no backend compiled to switch to). - -The LED runs on its own high-priority task, so it keeps animating even while -the firmware blocks waiting for a touch or grinds through a long RSA keygen — -a frozen LED means the firmware itself is wedged, not just busy. - -## What the states mean - -There are four states. Each has a **fixed blink timing** baked into the -firmware (`firmware/src/led.rs`); only the **color and brightness** are -configurable. - -| State | Default color | Blink (on/off) | Means | +## Build-time knobs + +Three hardware properties of the indicator are **compile-time** knobs set by build +flags; a fourth, `MAX_LEDS`, sets the upper bound for the PIO buffer. The actual +number of connected LEDs is configured at **runtime** via `rsk hw --led-num` (or +PicoForge) and must be ≤ `MAX_LEDS`. + +| Knob | Default | When to change it | +|---|---|---| +| `LED_KIND` | `ws2812` | `ws2812` (addressable RGB, default), `gpio` (plain on/off), `pimoroni` (3-pin PWM RGB), or `none` (no indicator). See [build.md](build.md). | +| `LED_PIN` | `16` | A board whose addressable LED is on a different GPIO (`0..=29`). | +| `LED_ORDER` | `rgb` | A WS2812 board with swapped red/green — set `grb` (the WS2812B standard). The Waveshare RP2350-One is `rgb`; most other parts are `grb`. | +| `MAX_LEDS` | `8` | A board with **more than 8** daisy-chained addressable LEDs (max `64`). The actual connected count is set at runtime with `rsk hw --led-num`. | + +```sh +# example: build for a 4-LED board with standard GRB order +env MAX_LEDS=4 LED_ORDER=grb cargo build --release -p firmware +# then set the runtime count (persists across reboots): +rsk hw --led-num 4 +``` + +Once built, a non-`none` build compiles all backends, so the pin, driver, wire +order, and LED count are **runtime-changeable** — no reflash — with `rsk hw` +([build.md](../build.md)). The build knobs set the boot defaults. + +What the LED shows — colour, brightness, and the **visual effect** — is +runtime-configurable separately, covered next. + +## Effects + +Each of the four states can run one of several animated effects. The effect +determines *how* the LED(s) display the state's colour and brightness. All +effects work with any number of LEDs — `vapor` and `sparkle` shine on a single +LED too; `bounce` and `flow` naturally reduce to a static colour or a single +pixel when there is only one LED. + +Effects only render on the **`ws2812`** backend (addressable RGB). The `gpio` and +`pimoroni` backends always use the classic on/off blink, regardless of the +effect setting — they lack per-LED control and pixel-level colour. + +| Effect | ID | What you see | Suits | |---|---|---|---| -| idle | green | 500 / 500 ms — slow, even | ready, nothing in flight | -| processing | green | 50 / 50 ms — fast flicker | handling an APDU / crypto op | -| **waiting for touch** | yellow | 1000 / 100 ms — long on, brief blink | press the button to confirm | -| boot | red | 500 / 500 ms | the brief power-up state | +| `legacy` | 0 | Classic on/off blink (TIMING table) | Original blink behaviour | +| `vapor` | 1 | All LEDs breathe together — smooth triangle-wave brightness | Idle (default) | +| `bounce` | 2 | A wide hump of light glides back and forth with half-step interpolation | Touch (default) | +| `flow` | 3 | Yellow→red gradient flowing left to right with a trailing wake | Processing (default) | +| `sparkle` | 4 | Each LED flashes an independent random colour | Boot (default) | + +### Default mapping (multiple LEDs) -The **touch** state is the one to learn. WebAuthn dialogs, `ssh`, and `gpg` -look hung at exactly the moment the device is waiting for your press — a -near-solid yellow that ticks off once a second is the cue to tap the button. +| State | Default effect | Default colour | Means | +|---|---|---|---| +| idle | `vapor` — gentle breathing | green | ready, nothing in flight | +| processing | `flow` — warm-colour flow | yellow→red gradient | handling an APDU / crypto op | +| **waiting for touch** | `bounce` — smooth bounce | yellow | press the button to confirm | +| boot | `sparkle` — random sparkle | red | the brief power-up state | A few honest details: -- **No dedicated error color.** The firmware does not light a distinct "error" +- **No dedicated error colour.** The firmware does not light a distinct "error" state; a failed operation just drops back to idle. Read the host tool's exit code, not the LED, for success or failure. - **The touch state needs the touch build.** It is only ever shown on the @@ -67,9 +82,10 @@ whatever the ROM does. That mode is for flashing firmware and OTP, covered in ## Customize -Color and per-channel brightness are configurable **per state**; the values -persist in flash (`EF_LED_CONF`) and apply live — no reboot. The host command -is `rsk led`: +### Colour & brightness + +Per-state colour and per-channel brightness are configurable; the values +persist in flash (`EF_LED_CONF`) and apply live — no reboot: ```sh rsk led --get # print the current config @@ -78,19 +94,23 @@ rsk led --status idle --brightness 64 # 0–255; 0 = that state goes da rsk led --status idle --color blue --brightness 64 ``` -Selectors and values: +### Effect & speed -| Flag | Values | -|---|---| -| `--status` | `idle`, `processing`, `touch`, `boot` (default `idle`) | -| `--color` | `off`, `red`, `green`, `blue`, `yellow`, `magenta`, `cyan`, `white` | -| `--brightness` | `0`–`255` per channel (`0` = off) | -| `--steady` | solid color, no blinking — **global**, affects every state | -| `--blink` | the opposite: restore blinking | +Each state's effect and animation speed are configurable the same way: + +```sh +rsk led --status idle --effect vapor # change the effect +rsk led --status touch --effect bounce --speed 15 # custom speed (ticks per step) +rsk led --status processing --effect legacy # revert to classic on/off blink +``` + +`--speed 0` (or omitting `--speed`) uses the effect's built-in default. + +### Steady / blink `--steady` and `--blink` are global, not per-state: the firmware keeps each state's timing internally, but a single flag decides whether *any* of them -blink. So `--steady` makes the whole indicator a solid lamp whose color tracks +blink. So `--steady` makes the whole indicator a solid lamp whose colour tracks the current state, and `--blink` brings the blink patterns back. ```sh @@ -98,77 +118,62 @@ rsk led --status idle --color cyan --steady # solid cyan at idle, no pulse rsk led --blink # back to the blink patterns ``` -`rsk-tui` has a "Cycle idle color" action that steps the idle state through -the palette, plus "Read LED state" — for per-state color, brightness, or the +`rsk-tui` has a "cycle idle color" action that steps the idle state through +the palette, plus "Read LED state" — for per-state colour, brightness, or the steady toggle, use `rsk led`. +### Selectors and values + +| Flag | Values | +|---|---| +| `--status` | `idle`, `processing`, `touch`, `boot` (default `idle`) | +| `--color` | `off`, `red`, `green`, `blue`, `yellow`, `magenta`, `cyan`, `white` | +| `--brightness` | `0`–`255` per channel (`0` = off) | +| `--effect` | `legacy`, `vapor`, `bounce`, `flow`, `sparkle` | +| `--speed` | `0`–`255` (`0` = effect's built-in default) | +| `--steady` | solid colour, no blinking — **global**, affects every state | +| `--blink` | the opposite: restore blinking | + ## Hardware wiring (`rsk hw`) -The **look** (`rsk led`, above) is one layer; the **wiring** — which pin, which -driver, which wire order — is the other. The wiring lives in the `phy` record -(the same device-config blob PicoForge writes) and is applied at **boot**, so a -change needs a reboot — `rsk hw` issues a warm one for you unless `--no-reboot`: +See [hw.md](hw.md) for the full reference. The LED wiring — pin, driver, wire +order — lives in the `phy` record, shared with PicoForge: ```sh -rsk hw # show the current phy LED config rsk hw --led-pin 22 # move the WS2812/gpio data pin to GPIO22 rsk hw --led-driver gpio # switch to a plain on/off LED rsk hw --led-order grb # fix a red/green swap on a GRB part -rsk hw --led-pin 22 --led-order grb # e.g. the TenStar RP2350-USB -rsk hw --led-driver ws2812 --no-reboot # stage a change; reboot later ``` -| Flag | Values | -|---|---| -| `--led-pin` | `0`–`29` (RP2350A GPIOs) — the `ws2812`/`gpio` data pin | -| `--led-driver` | `gpio`, `pimoroni`, `ws2812` | -| `--led-order` | `rgb`, `grb` (the `ws2812` backend only) | -| `--get` | print the current phy LED config and exit | -| `--no-reboot` | write but don't reboot (the change applies on the next boot) | - -A field you never set stays at the firmware build default (`rsk hw` with no -setters, or `--get`, prints which are overridden). `rsk hw` does a -read-modify-write of *only* the LED fields, so a USB identity or other phy -option set elsewhere (PicoForge) is preserved. A `none` build ignores these — -there is no backend compiled to render the LED. - ### Reset to defaults -There's no single "reset LED" verb; set the values back yourself. The factory -defaults are the table above at brightness 16, blinking: - ```sh -rsk led --status idle --color green --brightness 16 -rsk led --status processing --color green --brightness 16 -rsk led --status touch --color yellow --brightness 16 -rsk led --status boot --color red --brightness 16 +rsk led --status idle --color green --brightness 16 --effect vapor +rsk led --status processing --color green --brightness 16 --effect flow +rsk led --status touch --color yellow --brightness 16 --effect bounce +rsk led --status boot --color red --brightness 16 --effect sparkle rsk led --blink ``` ## Under the hood `rsk led` talks to the firmware's vendor applet over CCID -(`tools/rsk/led.py`, `firmware/src/vendor.rs`): **SET LED** (`INS 0x10`) packs -brightness into `P1` and color + the steady bit + the target state into `P2`; -**GET LED** (`INS 0x11`) returns the whole `[steady, (color, brightness) × 4]` -block that `--get` prints. The firmware writes it to `EF_LED_CONF` and reloads -it on every boot, so your colors survive a power cycle but not an OpenPGP/FIDO -factory reset of other applets (those don't touch this file). - -`rsk hw` instead writes the **wiring** to the `phy` record (`EF_PHY`) via the -rescue applet (`tools/rsk/hw.py`, `crates/rsk-rescue/src/phy.rs`): **READ** -(`INS 0x1E`, P1=01) and **WRITE** (`INS 0x1C`, P1=01) the same TLV blob -PicoForge uses — `led_gpio` (tag `0x04`), `led_driver` (`0x0C`), plus the -RS-Key vendor `led_order` tag (`0x0D`, which PicoForge skips as unknown). At -boot `firmware/src/main.rs` reads those fields and selects the pin (a `match` -over GPIO `0..=29`) and the driver; the wire order is a runtime red/green swap -in the render task, so one binary serves both RGB- and GRB-wired parts. -`EF_PHY` survives every applet factory reset. - -One board quirk worth knowing: the Waveshare RP2350-One's WS2812 takes bytes in -**RGB** wire order, not the WS2812B-standard GRB — the `rgb` default matches it, -and a `grb` board just flips `LED_ORDER` (build) or `rsk hw --led-order grb` -(runtime); the swap touches red/green only (blue is unaffected). +(`tools/rsk/led.py`, `firmware/src/vendor.rs`): + +- **SET LED** (`INS 0x10`) packs brightness into `P1` and colour + the steady + bit + the target state into `P2`. When the caller sends 1–2 data bytes, they + set the effect and speed for that state. +- **GET LED** (`INS 0x11`) returns the whole config block: + `[steady:1, (effect:1, color:1, brightness:1, speed:1) × 4]` (17 bytes). + +The firmware writes the block to `EF_LED_CONF` and reloads it on every boot, +so your settings survive a power cycle but not an OpenPGP/FIDO factory reset +(those don't touch this file). The `led.rs` module keeps per-status atomics +that the render task reads live — SET LED updates them immediately, then +persists the full block to flash. + +For the wiring half (`rsk hw`), see [hw.md](hw.md); it writes to `EF_PHY` via +the rescue applet and applies at next boot. ## Troubleshooting @@ -180,6 +185,11 @@ and a `grb` board just flips `LED_ORDER` (build) or `rsk hw --led-order grb` - **Red and green look swapped.** Wrong wire order for your LED part — flip it with `rsk hw --led-order grb` (or build with `LED_ORDER=grb`); see the RGB-vs-GRB note above. +- **Only the first LED lights up; the rest stay dark.** The board has multiple + daisy-chained addressable LEDs, but the runtime LED count was never set. + Run `rsk hw --led-num ` to configure it (persists across reboots; + the change applies after a warm reboot). If you need a higher buffer ceiling, + rebuild with `MAX_LEDS=`. - **`rsk led` can't reach the device.** It needs the CCID interface up (`pcscd` on Linux); if `gpg --card-status` / `rsk status` also fail, fix that first ([linux.md](../linux.md)). diff --git a/docs/hardware.md b/docs/hardware.md index 3fd249a..eafef37 100644 --- a/docs/hardware.md +++ b/docs/hardware.md @@ -29,8 +29,9 @@ cover it: |---|---|---| | `FLASH_SIZE` | `4M` | A board with a different QSPI flash chip (e.g. `8M`). `build.rs` regenerates `memory.x` from it. Must be ≥ ~2 MB and ≤ 16 MB. | | `LED_PIN` | `16` | A board that uses GPIO16 for something else, or wires its addressable LED elsewhere (RP2350A: GPIO `0..=29`). | -| `LED_KIND` | `ws2812` | A board with a different indicator: `gpio` (a plain on/off LED on `LED_PIN`), `pimoroni` (3-pin PWM RGB, Pimoroni Tiny 2350), or `none`. Default `ws2812` is the Waveshare addressable RGB. | +| `LED_KIND` | `ws2812` | `ws2812` (addressable RGB, default), `gpio` (plain on/off), `pimoroni` (3-pin PWM RGB), or `none` (no indicator). See [build.md](build.md). | | `LED_ORDER` | `rgb` | A `ws2812` board whose red and green come out swapped (blue fine): set `grb` (the WS2812B standard). The Waveshare RP2350-One is `rgb`; most other parts are `grb`. | +| `MAX_LEDS` | `8` | A board with **more than 8** daisy-chained addressable LEDs. The buffer ceiling; the actual connected count is set at runtime ([guides/led.md](guides/led.md)). | ```sh # example: an 8 MB board with a plain LED on GPIO25 @@ -40,10 +41,11 @@ env FLASH_SIZE=8M LED_KIND=gpio LED_PIN=25 cargo build --release -p firmware env FLASH_SIZE=16M LED_PIN=22 LED_ORDER=grb cargo build --release -p firmware ``` -The three LED knobs (`LED_PIN` / `LED_KIND` / `LED_ORDER`) set only the *boot -defaults*: a non-`none` build compiles all three backends, so the pin, driver, -and wire order are also changeable at **runtime** — no reflash — with `rsk hw` -or PicoForge, which write them to the device's `phy` record +The four LED knobs (`LED_PIN` / `LED_KIND` / `LED_ORDER` / `MAX_LEDS`) set only the +*boot defaults*: a non-`none` build compiles all three backends, so the pin, +driver, wire order, and buffer ceiling are also changeable at **runtime** — no +reflash — with `rsk hw` or PicoForge, which write them to the device's `phy` +record ([guides/led.md](guides/led.md)). The build knobs still matter for picking a lean `none` build and for the out-of-the-box default. diff --git a/firmware/build.rs b/firmware/build.rs index 30ad2a1..68fb6d6 100644 --- a/firmware/build.rs +++ b/firmware/build.rs @@ -85,6 +85,15 @@ fn main() { println!("cargo:rustc-check-cfg=cfg(led_order, values(\"rgb\", \"grb\"))"); println!("cargo:rerun-if-env-changed=LED_ORDER"); + // Maximum number of addressable LEDs the binary can drive. The PIO state + // machine and frame buffers are sized to this ceiling; the actual number + // of connected LEDs is set at **runtime** via the phy record (`rsk hw + // --led-num`), which must be ≤ MAX_LEDS. Default 8 covers the common + // 1-8 range; boards with more (up to 64) override via `MAX_LEDS=`. + let max_leds = resolve_max_leds(); + println!("cargo:rustc-env=PK_MAX_LEDS={max_leds}"); + println!("cargo:rerun-if-env-changed=MAX_LEDS"); + // Bake fake OTP keys into the image instead of reading the fuses — exercises // the kbase migration + boot path without an irreversible OTP write. // TEST BUILDS ONLY; never set for a shipped image. @@ -195,6 +204,20 @@ fn resolve_led_kind() -> String { } } +/// Resolve `MAX_LEDS` (the PIO/array ceiling for addressable LEDs) to a +/// positive integer; defaults to 8. The runtime count (`rsk hw --led-num`) +/// must be ≤ this value. +fn resolve_max_leds() -> u32 { + let raw = env::var("MAX_LEDS").unwrap_or_else(|_| "8".into()); + let v = raw + .trim() + .parse::() + .unwrap_or_else(|_| panic!("MAX_LEDS={raw:?} must be a positive integer")); + assert!(v >= 1, "MAX_LEDS must be >= 1, got {v}"); + assert!(v <= 64, "MAX_LEDS={v} is unreasonably large; max 64"); + v +} + /// Resolve `LED_ORDER` (the WS2812 wire byte order) to `rgb` or `grb`; defaults /// to `rgb` (the Waveshare RP2350-One). `grb` is the WS2812B standard — pick it /// on boards whose red/green come out swapped (e.g. the TenStar RP2350-USB). diff --git a/firmware/src/led.rs b/firmware/src/led.rs index ecda155..281000b 100644 --- a/firmware/src/led.rs +++ b/firmware/src/led.rs @@ -38,9 +38,29 @@ use embassy_rp::gpio::{Level, Output}; #[cfg(not(led_kind = "none"))] use embassy_rp::pwm::{Config as PwmConfig, Pwm}; -/// A single on-board addressable LED. +/// Maximum number of addressable LEDs the PIO buffer and frame arrays are +/// sized to. Baked at compile time via the `MAX_LEDS` build flag (default 8); +/// the actual connected count is set at runtime via `rsk hw --led-num` and +/// must be ≤ this value. #[cfg(not(led_kind = "none"))] -pub const NUM_LEDS: usize = 1; +pub const MAX_LEDS: usize = max_leds(); + +/// Parse the `PK_MAX_LEDS` env string to `usize` in const context. +/// Panics at compile time if the value exceeds `u8::MAX` (the runtime count +/// is stored as a `u8`, so the ceiling must fit). +#[cfg(not(led_kind = "none"))] +const fn max_leds() -> usize { + let s = env!("PK_MAX_LEDS"); + let b = s.as_bytes(); + let mut acc: usize = 0; + let mut i = 0; + while i < b.len() { + acc = acc * 10 + (b[i] - b'0') as usize; + i += 1; + } + assert!(acc <= 255, "MAX_LEDS must fit in u8"); + acc +} /// Status indices — also the index into [`TIMING`]/[`DEFAULT_COLOR`], the /// per-status atomics, and the `EF_LED_CONF` layout. @@ -73,8 +93,36 @@ const DEFAULT_COLOR: [u8; N_STATUS] = [COLOR_GREEN, COLOR_GREEN, COLOR_YELLOW, C /// Default channel max (a gentle 16/255). const DEFAULT_BRIGHTNESS: u8 = 16; -/// `EF_LED_CONF` byte layout: `[steady, (color, brightness) × N_STATUS]`. -pub const CONF_LEN: usize = 1 + 2 * N_STATUS; +// ------------------------------------------------------------------ +// Effect identifiers and per-status defaults +// ------------------------------------------------------------------ + +/// Built-in effect identifiers — stored in `EF_LED_CONF` as the `effect` byte +/// per status. `EFFECT_LEGACY` reproduces the original Blinker on/off behaviour. +#[allow(dead_code)] +pub const EFFECT_LEGACY: u8 = 0; +pub const EFFECT_VAPOR: u8 = 1; // breathing (all LEDs pulse together) +pub const EFFECT_BOUNCE: u8 = 2; // smooth bounce with half-step interpolation +pub const EFFECT_FLOW: u8 = 3; // unidirectional yellow→red flow +pub const EFFECT_SPARKLE: u8 = 4; // random-colour sparkle per LED + +/// Default effect per status (used when the stored effect is 0 / legacy, +/// and as the initial value before any `rsk led` command). +const DEFAULT_EFFECT: [u8; N_STATUS] = [ + EFFECT_VAPOR, // IDLE + EFFECT_FLOW, // PROCESSING + EFFECT_BOUNCE, // TOUCH + EFFECT_SPARKLE, // BOOT +]; + +/// Speed value meaning "use the effect's built-in default speed". +pub const SPEED_DEFAULT: u8 = 0; + +/// Default speed per status (all use built-in defaults). +const DEFAULT_SPEED: [u8; N_STATUS] = [SPEED_DEFAULT; N_STATUS]; + +/// `EF_LED_CONF` byte layout: `[steady, (effect, color, brightness, speed) × N_STATUS]`. +pub const CONF_LEN: usize = 1 + 4 * N_STATUS; static LED_STATUS: AtomicU8 = AtomicU8::new(STATUS_BOOT); /// When set, the blink task ignores the on/off phases and shows the current @@ -93,6 +141,18 @@ static STATUS_BRIGHTNESS: [AtomicU8; N_STATUS] = [ AtomicU8::new(DEFAULT_BRIGHTNESS), AtomicU8::new(DEFAULT_BRIGHTNESS), ]; +static STATUS_EFFECT: [AtomicU8; N_STATUS] = [ + AtomicU8::new(DEFAULT_EFFECT[STATUS_IDLE as usize]), + AtomicU8::new(DEFAULT_EFFECT[STATUS_PROCESSING as usize]), + AtomicU8::new(DEFAULT_EFFECT[STATUS_TOUCH as usize]), + AtomicU8::new(DEFAULT_EFFECT[STATUS_BOOT as usize]), +]; +static STATUS_SPEED: [AtomicU8; N_STATUS] = [ + AtomicU8::new(DEFAULT_SPEED[STATUS_IDLE as usize]), + AtomicU8::new(DEFAULT_SPEED[STATUS_PROCESSING as usize]), + AtomicU8::new(DEFAULT_SPEED[STATUS_TOUCH as usize]), + AtomicU8::new(DEFAULT_SPEED[STATUS_BOOT as usize]), +]; /// WS2812 wire r/g swap, read live by the addressable render task. Seeds from the /// `LED_ORDER` build flag (`grb` → swap red↔green, `rgb` → passthrough) and is /// overridden at boot by the phy record's order tag via [`set_rg_swap`]. embassy's @@ -100,6 +160,30 @@ static STATUS_BRIGHTNESS: [AtomicU8; N_STATUS] = [ #[cfg(not(led_kind = "none"))] static LED_RG_SWAP: AtomicBool = AtomicBool::new(cfg!(led_order = "grb")); +/// The number of addressable LEDs actually connected; set from the phy record +/// at boot (`rsk hw --led-num`). Must be ≤ [`MAX_LEDS`]. Defaults to `MAX_LEDS` +/// when the phy record carries no count. +#[cfg(not(led_kind = "none"))] +static RUNTIME_LEDS: AtomicU8 = AtomicU8::new(MAX_LEDS as u8); + +/// Return the runtime LED count — how many of the [`MAX_LEDS`] buffer slots +/// are actually connected and should be lit. +#[cfg(not(led_kind = "none"))] +pub fn runtime_leds() -> u8 { + RUNTIME_LEDS.load(Ordering::Relaxed) +} + +/// Set the runtime LED count (called from `main` at boot, or via `rsk hw`). +/// Panics if `n` exceeds [`MAX_LEDS`]. +#[cfg(not(led_kind = "none"))] +pub fn set_runtime_leds(n: u8) { + assert!( + (n as usize) <= MAX_LEDS, + "RUNTIME_LEDS={n} exceeds MAX_LEDS={MAX_LEDS}" + ); + RUNTIME_LEDS.store(n, Ordering::Relaxed); +} + /// Set the active status (the worker on dispatch start/end, `presence` for a /// touch wait). Out-of-range indices are clamped by the render loop. pub fn set_status(idx: u8) { @@ -120,6 +204,13 @@ pub fn set_status_config(idx: u8, color: u8, brightness: u8) { STATUS_BRIGHTNESS[i].store(brightness, Ordering::Relaxed); } +/// Override one status's effect and speed (0 = use effect's built-in default). +pub fn set_status_effect(idx: u8, effect: u8, speed: u8) { + let i = (idx as usize).min(N_STATUS - 1); + STATUS_EFFECT[i].store(effect, Ordering::Relaxed); + STATUS_SPEED[i].store(speed, Ordering::Relaxed); +} + /// Toggle the global no-blink (solid) mode. pub fn set_steady(on: bool) { LED_STEADY.store(on, Ordering::Relaxed); @@ -142,35 +233,61 @@ pub fn set_all_brightness(b: u8) { } } -/// The full config as the persisted/`GET LED` block `[steady, (color, br) × N]`. +/// The full config as the persisted/`GET LED` block `[steady, (effect, color, br, speed) × N]`. pub fn config_block() -> [u8; CONF_LEN] { let mut b = [0u8; CONF_LEN]; b[0] = LED_STEADY.load(Ordering::Relaxed) as u8; for i in 0..N_STATUS { - b[1 + 2 * i] = STATUS_COLOR[i].load(Ordering::Relaxed); - b[2 + 2 * i] = STATUS_BRIGHTNESS[i].load(Ordering::Relaxed); + b[1 + 4 * i] = STATUS_EFFECT[i].load(Ordering::Relaxed); + b[2 + 4 * i] = STATUS_COLOR[i].load(Ordering::Relaxed); + b[3 + 4 * i] = STATUS_BRIGHTNESS[i].load(Ordering::Relaxed); + b[4 + 4 * i] = STATUS_SPEED[i].load(Ordering::Relaxed); } b } -/// Apply a config block (boot from flash / SET LED). A short buffer is treated as -/// a legacy record: `[brightness, idle_color]` or `[brightness, idle_color, -/// steady]`, mapped onto the idle status so an upgrade keeps the old look. +/// Apply a config block (boot from flash / SET LED). Handles four wire formats: +/// +/// | Length | Format | +/// |--------|--------| +/// | 17+ | `[steady, (effect, color, brightness, speed) × N]` — current | +/// | 13–16 | `[steady, (effect, color, brightness) × N]` — pre-speed (atomics keep defaults) | +/// | 7–12 | `[steady, (color, brightness) × N]` — pre-effect (atomics keep defaults) | +/// | 2–3 | `[brightness, idle_color[, steady]]` — very old legacy | pub fn load_block(b: &[u8]) { - if b.len() < CONF_LEN { - if b.len() >= 2 { - STATUS_BRIGHTNESS[STATUS_IDLE as usize].store(b[0], Ordering::Relaxed); - STATUS_COLOR[STATUS_IDLE as usize].store(b[1] & 0x7, Ordering::Relaxed); + if b.len() >= CONF_LEN { + // Current format: [steady, (effect, color, brightness, speed) × N_STATUS] + LED_STEADY.store(b[0] != 0, Ordering::Relaxed); + for i in 0..N_STATUS { + STATUS_EFFECT[i].store(b[1 + 4 * i], Ordering::Relaxed); + STATUS_COLOR[i].store(b[2 + 4 * i] & 0x7, Ordering::Relaxed); + STATUS_BRIGHTNESS[i].store(b[3 + 4 * i], Ordering::Relaxed); + STATUS_SPEED[i].store(b[4 + 4 * i], Ordering::Relaxed); } + } else if b.len() >= 13 { + // Pre-speed format: [steady, (effect, color, brightness) × 4] (3-byte stride) + LED_STEADY.store(b[0] != 0, Ordering::Relaxed); + for i in 0..N_STATUS { + STATUS_EFFECT[i].store(b[1 + 3 * i], Ordering::Relaxed); + STATUS_COLOR[i].store(b[2 + 3 * i] & 0x7, Ordering::Relaxed); + STATUS_BRIGHTNESS[i].store(b[3 + 3 * i], Ordering::Relaxed); + // speed keeps its default (0 = built-in) + } + } else if b.len() >= 7 { + // Pre-effect format: [steady, (color, brightness) × N] — 2-byte stride + LED_STEADY.store(b[0] != 0, Ordering::Relaxed); + let n = (b.len() - 1) / 2; + for i in 0..n.min(N_STATUS) { + STATUS_COLOR[i].store(b[1 + 2 * i] & 0x7, Ordering::Relaxed); + STATUS_BRIGHTNESS[i].store(b[2 + 2 * i], Ordering::Relaxed); + } + } else if b.len() >= 2 { + // Legacy 2/3-byte: [brightness, idle_color[, steady]] + STATUS_BRIGHTNESS[STATUS_IDLE as usize].store(b[0], Ordering::Relaxed); + STATUS_COLOR[STATUS_IDLE as usize].store(b[1] & 0x7, Ordering::Relaxed); if b.len() >= 3 { LED_STEADY.store(b[2] != 0, Ordering::Relaxed); } - return; - } - LED_STEADY.store(b[0] != 0, Ordering::Relaxed); - for i in 0..N_STATUS { - STATUS_COLOR[i].store(b[1 + 2 * i] & 0x7, Ordering::Relaxed); - STATUS_BRIGHTNESS[i].store(b[2 + 2 * i], Ordering::Relaxed); } } @@ -231,23 +348,272 @@ impl Blinker { } } -/// `ws2812` backend: drive the single addressable LED with the blink colour, -/// applying the runtime r/g wire-order swap (see [`LED_RG_SWAP`]) so one binary -/// serves both RGB- and GRB-wired parts. +// Compile-time wire-format invariants (asserts fire during `cargo build`, +// no test harness needed). +const _: () = { + let bytes_per_status = 4; + let expected = 1 + bytes_per_status * N_STATUS; + assert!(CONF_LEN == expected); + assert!(CONF_LEN == 17); +}; + +// ------------------------------------------------------------------ +// Effect functions — each renders a full frame [[`RGB8`]; `MAX_LEDS`] +// from per-status atomics and the global tick counter. +// ------------------------------------------------------------------ + +/// Return the configured tick-interval for `status`, or `default_val` when +/// the stored speed is 0 (meaning "use the built-in default"). +#[cfg(not(led_kind = "none"))] +fn speed_for(status: usize, default_val: u32) -> u32 { + let s = STATUS_SPEED[status].load(Ordering::Relaxed); + if s == 0 { default_val } else { s as u32 } +} + +/// Vapour / breathing: all LEDs pulse together with a triangle-wave +/// brightness envelope (~2 s period). +#[cfg(not(led_kind = "none"))] +fn effect_vapor(status: usize, tick: u32) -> [RGB8; MAX_LEDS] { + let color_idx = STATUS_COLOR[status].load(Ordering::Relaxed); + let peak = STATUS_BRIGHTNESS[status].load(Ordering::Relaxed); + if peak == 0 { + return [RGB8::default(); MAX_LEDS]; + } + + // Speed = period in ticks (0 = default ~2 s = 400 ticks). + let period = speed_for(status, 400); + let half = period / 2; + if half == 0 { + return [RGB8::default(); MAX_LEDS]; + } + let phase = tick % period; + let breathe = if phase < half { + phase * peak as u32 / half + } else { + (period - phase) * peak as u32 / half + }; + + let c = color_rgb(color_idx, breathe as u8); + let n = runtime_leds() as usize; + let mut buf = [RGB8::default(); MAX_LEDS]; + for led in buf[..n].iter_mut() { + *led = c; + } + buf +} + +/// Smooth bounce: a wide hump of light glides back and forth along the +/// strip with half-step interpolation so there is no endpoint stutter. +/// Centre LED at full brightness, neighbours at half. +/// Falls back to a static colour when fewer than 2 runtime LEDs are +/// connected. +#[cfg(not(led_kind = "none"))] +fn effect_bounce(status: usize, tick: u32) -> [RGB8; MAX_LEDS] { + let color_idx = STATUS_COLOR[status].load(Ordering::Relaxed); + let peak = STATUS_BRIGHTNESS[status].load(Ordering::Relaxed); + if peak == 0 { + return [RGB8::default(); MAX_LEDS]; + } + let base = color_rgb(color_idx, peak); + + let n = runtime_leds() as usize; + if n <= 1 { + let mut buf = [RGB8::default(); MAX_LEDS]; + if n == 1 { + buf[0] = base; + } + return buf; + } + + let speed = speed_for(status, 10); // ticks per half-step (0 = default 10 = 50 ms) + let virtual_steps = 4 * (n - 1); + let raw = (tick / speed) as usize % virtual_steps; + + let half_pos = if raw < 2 * (n - 1) { + raw + } else { + virtual_steps - 1 - raw + }; + + let led_a = half_pos / 2; + let frac = (half_pos & 1) != 0; + + let mut buf = [RGB8::default(); MAX_LEDS]; + if !frac { + buf[led_a] = base; + if led_a > 0 { + buf[led_a - 1] = scale_rgb(base, 1, 2); + } + if led_a + 1 < n { + buf[led_a + 1] = scale_rgb(base, 1, 2); + } + } else { + buf[led_a] = scale_rgb(base, 1, 2); + if led_a + 1 < n { + buf[led_a + 1] = scale_rgb(base, 1, 2); + } + } + buf +} + +/// Hot flow: a yellow→orange→red gradient flows unidirectionally left to +/// right with a trailing wake. Trail length adapts to the runtime LED count. +#[cfg(not(led_kind = "none"))] +fn effect_flow(status: usize, tick: u32) -> [RGB8; MAX_LEDS] { + let peak = STATUS_BRIGHTNESS[status].load(Ordering::Relaxed) as u16; + if peak == 0 { + return [RGB8::default(); MAX_LEDS]; + } + + let n = runtime_leds() as usize; + if n == 0 { + return [RGB8::default(); MAX_LEDS]; + } + + let trail = (n - 1).min(4); + let speed = speed_for(status, 4); // ticks per step (0 = default 4 = 20 ms) + let front = ((tick / speed) as usize) % n; + + let mut buf = [RGB8::default(); MAX_LEDS]; + for (i, led) in buf[..n].iter_mut().enumerate() { + let dist = (i + n - front) % n; + let (r, g, b, bright) = match dist { + 0 => (255, 255, 0, peak), + 1 if trail >= 1 => (255, 128, 0, peak * 3 / 5), + 2 if trail >= 2 => (255, 64, 0, peak * 2 / 5), + 3 if trail >= 3 => (128, 16, 0, peak / 5), + 4 if trail >= 4 => (64, 0, 0, peak / 8), + _ => continue, + }; + *led = RGB8 { + r: (r as u16 * bright / 255) as u8, + g: (g as u16 * bright / 255) as u8, + b: (b as u16 * bright / 255) as u8, + }; + } + buf +} + +/// Random sparkle: each LED independently flashes a random colour +/// (~25 % duty cycle, deterministic splitmix32 hash). +#[cfg(not(led_kind = "none"))] +fn effect_sparkle(status: usize, tick: u32) -> [RGB8; MAX_LEDS] { + let peak = STATUS_BRIGHTNESS[status].load(Ordering::Relaxed); + if peak == 0 { + return [RGB8::default(); MAX_LEDS]; + } + + let mut buf = [RGB8::default(); MAX_LEDS]; + let n = runtime_leds() as usize; + for (i, led) in buf[..n].iter_mut().enumerate() { + let h = splitmix32(tick ^ (i as u32 * 0x9e3779b9)); + if (h & 0xFF) < 64 { + let scale = |v: u8| -> u8 { (v as u16 * peak as u16 / 255) as u8 }; + *led = RGB8 { + r: scale((h >> 16) as u8), + g: scale((h >> 8) as u8), + b: scale(h as u8), + }; + } + } + buf +} + +/// Scale an `RGB8` by `num / den`. +#[cfg(not(led_kind = "none"))] +fn scale_rgb(c: RGB8, num: u8, den: u8) -> RGB8 { + RGB8 { + r: (c.r as u16 * num as u16 / den as u16) as u8, + g: (c.g as u16 * num as u16 / den as u16) as u8, + b: (c.b as u16 * num as u16 / den as u16) as u8, + } +} + +/// Minimal splitmix32 pseudo-random hash (deterministic, no std dependency). +#[cfg(not(led_kind = "none"))] +fn splitmix32(mut x: u32) -> u32 { + x = x.wrapping_add(0x9e3779b9); + x ^= x >> 16; + x = x.wrapping_mul(0x85ebca6b); + x ^= x >> 13; + x = x.wrapping_mul(0xc2b2ae35); + x ^= x >> 16; + x +} + +/// `ws2812` backend: addressable LED effect engine. Reads the active status +/// and its configured effect/color/brightness/speed from atomics each tick +/// and dispatches to the appropriate effect function. Only the first +/// [`runtime_leds()`] LEDs are lit; the remaining [`MAX_LEDS`] buffer +/// positions stay dark. #[cfg(not(led_kind = "none"))] #[embassy_executor::task] -pub async fn ws2812_task(mut ws2812: PioWs2812<'static, PIO0, 0, NUM_LEDS, Ws2812Order>) { - let mut blinker = Blinker::new(); +pub async fn ws2812_task(mut ws2812: PioWs2812<'static, PIO0, 0, MAX_LEDS, Ws2812Order>) { + let mut tick: u32 = 0; + // LEGACY blink state (tracked here because it is the only effect that + // needs mutable state across ticks). + let mut on_phase = false; + let mut phase_end = Instant::now(); + loop { - let mut c = blinker.tick(); + tick = tick.wrapping_add(1); + let s = (LED_STATUS.load(Ordering::Relaxed) as usize).min(N_STATUS - 1); + + let buf = dispatch(s, tick, &mut on_phase, &mut phase_end); + + // Per-pixel r/g wire-order swap (GRB-corrected parts). + let mut buf = buf; if LED_RG_SWAP.load(Ordering::Relaxed) { - core::mem::swap(&mut c.r, &mut c.g); + for c in &mut buf { + core::mem::swap(&mut c.r, &mut c.g); + } } - ws2812.write(&[c; NUM_LEDS]).await; + ws2812.write(&buf).await; Timer::after_millis(5).await; } } +/// Choose and run the effect for status `s`. Exposed as a separate function +/// (rather than inlined into the task) so it can be unit-tested. +#[cfg(not(led_kind = "none"))] +fn dispatch(s: usize, tick: u32, on_phase: &mut bool, phase_end: &mut Instant) -> [RGB8; MAX_LEDS] { + let effect_id = STATUS_EFFECT[s].load(Ordering::Relaxed); + match effect_id { + EFFECT_VAPOR => effect_vapor(s, tick), + EFFECT_BOUNCE => effect_bounce(s, tick), + EFFECT_FLOW => effect_flow(s, tick), + EFFECT_SPARKLE => effect_sparkle(s, tick), + // Unknown effect or LEGACY — fall back to on/off blink. + _ => legacy_broadcast(s, on_phase, phase_end), + } +} + +/// Classic on/off blink: all LEDs show the same colour during the on phase +/// and turn off during the off phase, controlled by `TIMING[s]`. +#[cfg(not(led_kind = "none"))] +fn legacy_broadcast(s: usize, on_phase: &mut bool, phase_end: &mut Instant) -> [RGB8; MAX_LEDS] { + let (on_ms, off_ms) = TIMING[s]; + let now = Instant::now(); + if now >= *phase_end { + *on_phase = !*on_phase; + *phase_end = now + Duration::from_millis(if *on_phase { on_ms } else { off_ms }); + } + let c = if *on_phase || LED_STEADY.load(Ordering::Relaxed) { + color_rgb( + STATUS_COLOR[s].load(Ordering::Relaxed), + STATUS_BRIGHTNESS[s].load(Ordering::Relaxed), + ) + } else { + RGB8::default() + }; + let n = runtime_leds() as usize; + let mut buf = [c; MAX_LEDS]; + for led in buf[n..].iter_mut() { + *led = RGB8::default(); + } + buf +} + /// `gpio` backend: a plain on/off LED (active-high). Hue and brightness collapse /// to lit/unlit — only the blink *pattern* distinguishes statuses. #[cfg(not(led_kind = "none"))] diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 2f97e94..5d50892 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -289,6 +289,11 @@ async fn main(_spawner: Spawner) { if let Some(order) = phy.led_order { led::set_rg_swap(order != 0); } + // Runtime LED count from phy; 0 or None means "use the build default" + // (already set as `RUNTIME_LEDS = MAX_LEDS` at init). + if let Some(n) = phy.led_num.filter(|&n| n > 0) { + led::set_runtime_leds(n); + } } vendor::load_led_config(&mut fs); rsk_otp::power_up_bump(&mut fs); @@ -305,7 +310,7 @@ async fn main(_spawner: Spawner) { config.serial_number = Some("rs-key-0001"); config.max_power = 100; config.max_packet_size_0 = 64; - config.device_release = 0x0780; // bcdDevice: our build counter + config.device_release = 0x0781; // bcdDevice: our build counter let mut builder = Builder::new( driver, diff --git a/firmware/src/vendor.rs b/firmware/src/vendor.rs index 2773604..bd2f974 100644 --- a/firmware/src/vendor.rs +++ b/firmware/src/vendor.rs @@ -15,7 +15,7 @@ pub const VENDOR_AID: &[u8] = &[0xF0, 0x00, 0x00, 0x00, 0x01]; /// Dynamic file holding the counter; `Fs::scan` rediscovers it after a reboot. const COUNTER_FID: u16 = 0xCC01; -/// LED config block `[steady, (color, brightness) × status]`; outside both reset +/// LED config block `[steady, (effect, color, brightness) × status]`; outside both reset /// scopes (sticky). A legacy 2/3-byte record (pre-per-status firmware) is mapped /// onto the idle status by [`crate::led::load_block`]. const EF_LED_CONF: u16 = 0x1123; @@ -92,10 +92,17 @@ impl Applet> for VendorApplet { } INS_SET_LED => { // One status (P2 bits 5:4) gets P1 brightness + P2 color; the - // steady bit is global. Apply live, then persist the whole block. + // steady bit is global. Optional data bytes set effect and speed. let status = (apdu.p2 >> 4) & 0x3; crate::led::set_status_config(status, apdu.p2 & 0x7, apdu.p1); crate::led::set_steady(apdu.p2 & P2_STEADY != 0); + if apdu.nc >= 1 { + crate::led::set_status_effect( + status, + apdu.data[0], + apdu.data.get(1).copied().unwrap_or(0), + ); + } if fs.put(EF_LED_CONF, &crate::led::config_block()).is_err() { return Sw::MEMORY_FAILURE; } diff --git a/tools/rsk/hw.py b/tools/rsk/hw.py index 09629ac..866c760 100644 --- a/tools/rsk/hw.py +++ b/tools/rsk/hw.py @@ -18,6 +18,7 @@ issued unless --no-reboot. (Per-status COLOURS are a separate, live setting that persists in a different record — see `rsk led`.) """ + from . import ccid from .status import RESCUE_AID @@ -25,6 +26,7 @@ TAG_LED_GPIO = 0x04 TAG_LED_DRIVER = 0x0C TAG_LED_ORDER = 0x0D # RS-Key vendor tag (PicoForge skips it as unknown) +TAG_LED_NUM = 0x0E # RS-Key vendor tag: addressable LED count # Driver numbering follows pico-fido / PicoForge's LedDriverType. DRIVERS = {"gpio": 1, "pimoroni": 2, "ws2812": 3} @@ -34,16 +36,39 @@ def register(sub): - p = sub.add_parser("hw", help="LED hardware wiring (pin/driver/order) via the phy record") - p.add_argument("--led-pin", type=int, metavar="0-29", - help="WS2812/gpio data GPIO (RP2350A 0..=29)") - p.add_argument("--led-driver", choices=sorted(DRIVERS), - help="LED backend: gpio (on/off), pimoroni (3-pin PWM RGB), ws2812 (addressable)") - p.add_argument("--led-order", choices=sorted(ORDERS), - help="WS2812 wire byte order: grb (standard WS2812B) or rgb (Waveshare RP2350-One)") - p.add_argument("--get", action="store_true", help="read the current phy LED config and exit") - p.add_argument("--no-reboot", action="store_true", - help="don't reboot after writing (the change applies on the next boot)") + p = sub.add_parser( + "hw", help="LED hardware wiring (pin/driver/order) via the phy record" + ) + p.add_argument( + "--led-pin", + type=int, + metavar="0-29", + help="WS2812/gpio data GPIO (RP2350A 0..=29)", + ) + p.add_argument( + "--led-driver", + choices=sorted(DRIVERS), + help="LED backend: gpio (on/off), pimoroni (3-pin PWM RGB), ws2812 (addressable)", + ) + p.add_argument( + "--led-order", + choices=sorted(ORDERS), + help="WS2812 wire byte order: grb (standard WS2812B) or rgb (Waveshare RP2350-One)", + ) + p.add_argument( + "--led-num", + type=int, + metavar="1-255", + help="number of addressable LEDs connected (runtime, overrides MAX_LEDS)", + ) + p.add_argument( + "--get", action="store_true", help="read the current phy LED config and exit" + ) + p.add_argument( + "--no-reboot", + action="store_true", + help="don't reboot after writing (the change applies on the next boot)", + ) p.set_defaults(func=run) @@ -56,7 +81,7 @@ def _parse_tlv(data): i += 2 if i + ln > len(data): break - out.append((tag, data[i:i + ln])) + out.append((tag, data[i : i + ln])) i += ln return out @@ -89,21 +114,33 @@ def _show(tlvs): pin = by.get(TAG_LED_GPIO) drv = by.get(TAG_LED_DRIVER) order = by.get(TAG_LED_ORDER) - print("phy LED config ('(build default)' = field absent, firmware build value used):") + print( + "phy LED config ('(build default)' = field absent, firmware build value used):" + ) print(f" pin {pin[0] if pin else '(build default)'}") print(f" driver {DRIVER_NAMES.get(drv[0], drv[0]) if drv else '(build default)'}") - print(f" order {ORDER_NAMES.get(order[0], order[0]) if order else '(build default)'}") + print( + f" order {ORDER_NAMES.get(order[0], order[0]) if order else '(build default)'}" + ) + led_num = by.get(TAG_LED_NUM) + print(f" num {led_num[0] if led_num else '(build default)'}") def run(args): conn = ccid.connect() _, s1, s2 = ccid.select(conn, RESCUE_AID) if (s1, s2) != (0x90, 0x00): - raise SystemExit(f"SELECT rescue AID failed: {s1:02X}{s2:02X} (firmware too old?)") + raise SystemExit( + f"SELECT rescue AID failed: {s1:02X}{s2:02X} (firmware too old?)" + ) tlvs = _read_phy(conn) - setting = (args.led_pin is not None or args.led_driver is not None - or args.led_order is not None) + setting = ( + args.led_pin is not None + or args.led_driver is not None + or args.led_order is not None + or args.led_num is not None + ) if args.get or not setting: _show(tlvs) return @@ -116,9 +153,15 @@ def run(args): _upsert(tlvs, TAG_LED_DRIVER, DRIVERS[args.led_driver]) if args.led_order is not None: _upsert(tlvs, TAG_LED_ORDER, ORDERS[args.led_order]) + if args.led_num is not None: + if not 1 <= args.led_num <= 255: + raise SystemExit("--led-num must be 1–255") + _upsert(tlvs, TAG_LED_NUM, args.led_num) blob = _serialize_tlv(tlvs) - _, s1, s2 = ccid.transmit(conn, [0x80, 0x1C, 0x01, 0x00, len(blob)] + list(blob) + [0x00]) + _, s1, s2 = ccid.transmit( + conn, [0x80, 0x1C, 0x01, 0x00, len(blob)] + list(blob) + [0x00] + ) if (s1, s2) != (0x90, 0x00): raise SystemExit(f"WRITE phy failed: {s1:02X}{s2:02X}") print("phy LED config written ✓") diff --git a/tools/rsk/led.py b/tools/rsk/led.py index 080e1db..520355d 100644 --- a/tools/rsk/led.py +++ b/tools/rsk/led.py @@ -1,30 +1,63 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (C) 2026 RS-Key contributors -"""rsk led — LED customization over the vendor applet (CCID). +"""rsk led — LED customisation over the vendor applet (CCID). -SET LED (INS 0x10, P1=brightness, P2 = color | steady 0x08 | status<<4) / GET LED -(INS 0x11, returns [steady, (color, brightness) x idle/processing/touch/boot]). -Per-status color + brightness; --steady is a solid color (global). Persists in -flash, applies live. +SET LED (INS 0x10, P1=brightness, P2 = color | steady 0x08 | status<<4, +optional data[0..1] = effect, speed) / GET LED (INS 0x11, returns +[steady, (effect, color, brightness, speed) x 4] — 17 bytes). +Per-status color + brightness + effect + speed; --steady is a solid color +(global). Persists in flash, applies live. """ + from . import ccid -COLORS = {"off": 0, "red": 1, "green": 2, "blue": 3, - "yellow": 4, "magenta": 5, "cyan": 6, "white": 7} +COLORS = { + "off": 0, + "red": 1, + "green": 2, + "blue": 3, + "yellow": 4, + "magenta": 5, + "cyan": 6, + "white": 7, +} COLOR_NAMES = {v: k for k, v in COLORS.items()} +EFFECTS = {"legacy": 0, "vapor": 1, "bounce": 2, "flow": 3, "sparkle": 4} +EFFECT_NAMES = {v: k for k, v in EFFECTS.items()} STATUSES = {"idle": 0, "processing": 1, "touch": 2, "boot": 3} STATUS_NAMES = {v: k for k, v in STATUSES.items()} P2_STEADY = 0x08 +# 17-byte block format: [steady, (effect, color, brightness, speed) x 4] +BLOCK_STRIDE = 4 # bytes per status +BLOCK_OFF_STEADY = 0 +BLOCK_OFF_EFFECT = 1 # first status starts here +BLOCK_OFF_COLOR = 2 +BLOCK_OFF_BRIGHTNESS = 3 +BLOCK_OFF_SPEED = 4 def register(sub): - p = sub.add_parser("led", help="LED color/brightness customization") - p.add_argument("--status", choices=sorted(STATUSES), default="idle", - help="which status to change (default idle)") - p.add_argument("--brightness", type=int, metavar="0-255", - help="per-channel brightness for --status (0 = off)") + p = sub.add_parser("led", help="LED color/brightness/effect customisation") + p.add_argument( + "--status", + choices=sorted(STATUSES), + default="idle", + help="which status to change (default idle)", + ) + p.add_argument( + "--brightness", + type=int, + metavar="0-255", + help="per-channel brightness for --status (0 = off)", + ) p.add_argument("--color", choices=sorted(COLORS), help="color for --status") + p.add_argument( + "--effect", choices=sorted(EFFECTS), help="visual effect for --status" + ) + p.add_argument( + "--speed", type=int, metavar="0-255", help="effect speed (0 = built-in default)" + ) g = p.add_mutually_exclusive_group() g.add_argument("--steady", action="store_true", help="solid color, no blinking") g.add_argument("--blink", action="store_true", help="restore status blinking") @@ -39,30 +72,72 @@ def _get_block(conn): return d +def _status_offset(st): + return BLOCK_OFF_EFFECT + st * BLOCK_STRIDE + + def run(args): conn = ccid.connect() ccid.select(conn, ccid.VENDOR_AID) - changing = args.brightness is not None or args.color is not None or args.steady or args.blink + changing = ( + args.brightness is not None + or args.color is not None + or args.effect is not None + or args.speed is not None + or args.steady + or args.blink + ) if changing: block = _get_block(conn) st = STATUSES[args.status] - b, c, steady = block[2 + 2 * st], block[1 + 2 * st], bool(block[0]) + off = _status_offset(st) + # Read current values from block. + effect = block[off] + color = block[off + 1] + brightness = block[off + 2] + steady = bool(block[BLOCK_OFF_STEADY]) + + # Apply user overrides. if args.brightness is not None: if not 0 <= args.brightness <= 255: raise SystemExit("--brightness must be 0–255") - b = args.brightness + brightness = args.brightness if args.color is not None: - c = COLORS[args.color] + color = COLORS[args.color] + if args.effect is not None: + effect = EFFECTS[args.effect] + if args.speed is not None: + if not 0 <= args.speed <= 255: + raise SystemExit("--speed must be 0–255") steady = True if args.steady else False if args.blink else steady - p2 = (c & 0x7) | (P2_STEADY if steady else 0) | (st << 4) - _, s1, s2 = ccid.transmit(conn, [0x00, 0x10, b & 0xFF, p2]) + + # Build APDU: P1 = brightness, P2 = color | steady | status<<4. + p2 = (color & 0x7) | (P2_STEADY if steady else 0) | (st << 4) + # Include optional data bytes for effect and speed. + data = [] + if args.effect is not None or args.speed is not None: + data.append(effect) + if args.speed is not None: + data.append(args.speed & 0xFF) + _, s1, s2 = ccid.transmit(conn, [0x00, 0x10, brightness & 0xFF, p2] + data) if (s1, s2) != (0x90, 0x00): raise SystemExit(f"SET LED failed: {s1:02X}{s2:02X}") - print(f"set {args.status}: color={COLOR_NAMES.get(c, c)} brightness={b} " - f"(mode={'steady' if steady else 'blink'})") + parts = [ + f"set {args.status}: color={COLOR_NAMES.get(color, color)}", + f"brightness={brightness}", + f"effect={EFFECT_NAMES.get(effect, effect)}", + f"(mode={'steady' if steady else 'blink'})", + ] + print(" ".join(parts)) if args.get or not changing: d = _get_block(conn) - print(f"mode={'steady' if d[0] else 'blink'}") + print(f"mode={'steady' if d[BLOCK_OFF_STEADY] else 'blink'}") for st, name in sorted(STATUS_NAMES.items()): - color = COLOR_NAMES.get(d[1 + 2 * st], d[1 + 2 * st]) - print(f" {name:<10} color={color:<8} brightness={d[2 + 2 * st]}") + off = _status_offset(st) + effect = EFFECT_NAMES.get(d[off], d[off]) + color = COLOR_NAMES.get(d[off + 1], d[off + 1]) + brightness = d[off + 2] + print( + f" {name:<10} color={color:<8} brightness={brightness:<3}" + f" effect={effect}" + ) diff --git a/tools/tui/src/device.rs b/tools/tui/src/device.rs index 3636f0c..129dd3c 100644 --- a/tools/tui/src/device.rs +++ b/tools/tui/src/device.rs @@ -664,13 +664,37 @@ pub fn led_get() -> Result { if (s1, s2) != (0x90, 0x00) || d.len() < 9 { return Err(format!("GET LED {s1:02X}{s2:02X}")); } + let stride = if d.len() >= 17 { + 4 + } else if d.len() >= 13 { + 3 + } else { + 2 + }; let names = ["idle", "processing", "touch", "boot"]; + let effect_names = ["legacy", "vapor", "bounce", "flow", "sparkle"]; let mut out = format!("mode = {}\n", if d[0] != 0 { "steady" } else { "blink" }); for (i, name) in names.iter().enumerate() { + let off = 1 + i * stride; + // stride=2: [color, brightness]; stride>=3: [effect, color, brightness, …] + let (color, brightness) = if stride >= 3 { + (d[off + 1], d[off + 2]) + } else { + (d[off], d[off + 1]) + }; + let effect = if stride >= 3 { + format!( + " effect={}", + effect_names.get(d[off] as usize).copied().unwrap_or("?") + ) + } else { + String::new() + }; out += &format!( - "{name:<11} {} (brightness {})\n", - COLORS.get(d[1 + 2 * i] as usize).copied().unwrap_or("?"), - d[2 + 2 * i] + "{name:<11} {} (brightness {}){}\n", + COLORS.get(color as usize).copied().unwrap_or("?"), + brightness, + effect, ); } Ok(out) @@ -683,8 +707,25 @@ pub fn led_cycle_idle() -> Result { if (s1, s2) != (0x90, 0x00) || d.len() < 9 { return Err(format!("GET LED {s1:02X}{s2:02X}")); } - let next = ((d[1] as usize) % 7) + 1; - let brightness = if d[2] == 0 { 16 } else { d[2] }; + let stride = if d.len() >= 17 { + 4 + } else if d.len() >= 13 { + 3 + } else { + 2 + }; + // idle status: color/brightness offset depends on stride. + let (idle_color, idle_brightness) = if stride >= 3 { + (d[2], d[3]) // [steady, (effect, color, brightness, …), …] + } else { + (d[1], d[2]) // [steady, (color, brightness), …] + }; + let next = ((idle_color as usize) % 7) + 1; + let brightness = if idle_brightness == 0 { + 16 + } else { + idle_brightness + }; let p2 = (next as u8 & 0x7) | if d[0] != 0 { 0x08 } else { 0 }; let (_, s1, s2) = c.apdu(&[0x00, 0x10, brightness, p2])?; if (s1, s2) != (0x90, 0x00) { @@ -1163,7 +1204,7 @@ impl DeviceProvider for MockProvider { Action::LedGet => ActionResult::Report { title: "LED state".into(), body: format!( - "mode = steady\nidle {} (brightness 16)\nprocessing blue (brightness 32)\ntouch green (brightness 64)\nboot white (brightness 8)\n", + "mode = steady\nidle {} (brightness 16) effect=vapor\nprocessing blue (brightness 32) effect=flow\ntouch green (brightness 64) effect=bounce\nboot white (brightness 8) effect=sparkle\n", COLORS[self.idle_color] ), },