From cdbd91dc8d565514fe6fb9ac2299ef69f38d1bc0 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 22:33:20 +0100 Subject: [PATCH 01/32] Add roadmap plans for full WLED JSON API coverage --- plans/0-update-target-frameworks.md | 104 +++++++++++++++ plans/1-core-value-types-and-enums.md | 100 ++++++++++++++ plans/10-config-api.md | 102 ++++++++++++++ .../11-client-ergonomics-and-cross-cutting.md | 124 ++++++++++++++++++ plans/2-command-values-and-builders.md | 114 ++++++++++++++++ plans/3-complete-state-object.md | 84 ++++++++++++ plans/4-complete-segment-object.md | 84 ++++++++++++ plans/5-complete-info-and-read-endpoints.md | 82 ++++++++++++ plans/6-presets-api.md | 88 +++++++++++++ plans/7-playlists-api.md | 92 +++++++++++++ plans/8-individual-led-control.md | 78 +++++++++++ plans/9-effect-metadata.md | 94 +++++++++++++ plans/README.md | 103 +++++++++++++++ 13 files changed, 1249 insertions(+) create mode 100644 plans/0-update-target-frameworks.md create mode 100644 plans/1-core-value-types-and-enums.md create mode 100644 plans/10-config-api.md create mode 100644 plans/11-client-ergonomics-and-cross-cutting.md create mode 100644 plans/2-command-values-and-builders.md create mode 100644 plans/3-complete-state-object.md create mode 100644 plans/4-complete-segment-object.md create mode 100644 plans/5-complete-info-and-read-endpoints.md create mode 100644 plans/6-presets-api.md create mode 100644 plans/7-playlists-api.md create mode 100644 plans/8-individual-led-control.md create mode 100644 plans/9-effect-metadata.md create mode 100644 plans/README.md diff --git a/plans/0-update-target-frameworks.md b/plans/0-update-target-frameworks.md new file mode 100644 index 0000000..1e9bd68 --- /dev/null +++ b/plans/0-update-target-frameworks.md @@ -0,0 +1,104 @@ +# Plan 0 — Update target frameworks (net8.0 / net9.0 / net10.0) + +**Theme:** Foundation · **Do first:** this precedes Plan 1 and unblocks the modern-only +language/JSON features the rest of the roadmap leans on (source-generated converters, +trimming/AOT friendliness in Plan 11). + +## Why + +The repository is pinned to old frameworks/SDK throughout: + +| Project / file | Current target | Notes | +|----------------|----------------|-------| +| `src/Kevsoft.WLED/Kevsoft.WLED.csproj` | `netstandard2.0` | the shipped package | +| `test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj` | `net6.0` | out of support | +| `samples/BasicConsole/BasicConsole.csproj` | `net6.0` | out of support | +| `Dockerfile` | `mcr.microsoft.com/dotnet/sdk:6.0` | CI builds/tests/packs here | +| `.github/workflows/continuous-integration-workflow.yml` | uses the Docker image | | + +`net6.0` and `net7.0` are out of support; `net8.0` (LTS), `net9.0` (STS) and `net10.0` +(LTS) are the current/next supported set. We want the **library** to multi-target so +modern consumers get the best build while older consumers keep working. + +## Goal + +- **Library (`Kevsoft.WLED`)**: multi-target `netstandard2.0;net8.0;net9.0;net10.0`. + - Keep `netstandard2.0` for maximum reach (this is the whole point of a wrapper lib). + - Add `net8.0`/`net9.0`/`net10.0` so we can use `System.Text.Json` **source + generation**, trimming/AOT annotations, and newer language/runtime APIs under + `#if NET8_0_OR_GREATER` where beneficial. +- **Tests & samples**: target the modern TFMs (multi-target tests across + `net8.0;net9.0;net10.0` so we exercise every runtime the library ships for). +- **SDK/CI**: build on an SDK that can compile `net10.0`. + +## Changes + +### 1. `Directory.Build.props` +Centralise the TFM lists so they're defined once: + +```xml + + netstandard2.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 + +``` + +(Existing `LangVersion=latest`, `Nullable`, `ImplicitUsings` already live here and are +fine.) + +### 2. `src/Kevsoft.WLED/Kevsoft.WLED.csproj` +```xml +$(LibraryTargetFrameworks) +``` +(note the **plural** `TargetFrameworks`). Guard any newer-only API usage with +`#if NET8_0_OR_GREATER`. The `System.Text.Json` / `System.Net.Http.Json` package +references stay for the `netstandard2.0` leg; on the `net8.0+` legs they are part of the +shared framework, so reference them `Condition="'$(TargetFramework)' == 'netstandard2.0'"` +to avoid downgrading the in-box version. + +### 3. Test & sample csproj +- Tests: `$(TestTargetFrameworks)`. +- Sample: `net10.0` (a single modern TFM is fine for a + sample). +- Bump stale test packages that won't restore on net10 (`Microsoft.NET.Test.Sdk` 16.5.0, + `xunit` 2.4.0, `FluentAssertions` 5.10.3, `coverlet` 1.2.0 are all old). Pin to current + versions as part of this plan so the matrix actually builds. + +### 4. `Dockerfile` +- Bump every stage base from `dotnet/sdk:6.0` to `dotnet/sdk:10.0` (the SDK can target + down-level TFMs, so one SDK builds all legs). +- The `dotnet build ./src/**/*.csproj` / `dotnet test` / `dotnet pack` steps are + TFM-agnostic and need no change beyond the SDK bump. + +### 5. CI workflow +- No structural change required (it delegates to Docker), but confirm the runner/image + pulls `sdk:10.0`. Optionally add a non-Docker `actions/setup-dotnet` matrix job that + installs the 8/9/10 SDKs and runs `dotnet test` directly, to get clearer per-TFM + results than the single Docker entrypoint provides. + +## Sequencing & risk + +1. Bump SDK in `Dockerfile` + package versions first (so the existing `netstandard2.0` + library still builds on the new SDK) → green build. +2. Add the three `net*` legs to the library → green build. +3. Multi-target tests → ensure the suite passes on `net8.0`, `net9.0`, `net10.0`. +4. Then proceed with Plan 1 onwards. + +Low functional risk (no behaviour change), but watch for: package-version incompatibilities +on net10, `TreatWarningsAsErrors=true` surfacing new analyzer warnings on the modern legs, +and the conditional package references (don't ship a down-level STJ on `net8.0+`). + +## Tests / verification + +- `dotnet build` and `dotnet test` succeed for **every** TFM in the matrix. +- `dotnet pack` produces a package whose `lib/` contains + `netstandard2.0`, `net8.0`, `net9.0`, `net10.0` folders. +- The existing test suite passes unchanged on all test TFMs (behaviour parity). +- `samples/BasicConsole` builds and runs on `net10.0`. + +## Acceptance + +The library multi-targets `netstandard2.0;net8.0;net9.0;net10.0`, tests run across +`net8.0/net9.0/net10.0`, the Docker/CI toolchain uses the .NET 10 SDK, all packages +restore, and the full suite is green — providing the modern foundation the rest of the +roadmap (esp. Plan 11's source-generated JSON) builds on. diff --git a/plans/1-core-value-types-and-enums.md b/plans/1-core-value-types-and-enums.md new file mode 100644 index 0000000..9cb7ead --- /dev/null +++ b/plans/1-core-value-types-and-enums.md @@ -0,0 +1,100 @@ +# Plan 1 — Core value types & enums + +**Theme:** Foundation · **Unblocks:** every other plan + +## Why + +The current model leaks raw primitives that allow nonsensical values and force callers +to remember magic numbers: + +- Colors are `int[][]` — nothing stops `[[999, -4]]` or a 7-element array. +- "Enums" are bare `byte`/`int`: `NightlightResponse.Mode` (0–3), + `StateResponse.LiveDataOverride` (0–2), `LedsResponse.LightCapabilities` (bitfield). +- Preset / playlist ids are bare `int` with sentinel `-1` meaning "none". + +Both reference libraries fix this with value types and enums +(`frenck/python-wled` `const.py`: `LightCapability`, `LiveDataOverride`, +`NightlightMode`, `SoundSimulationType`, `SyncGroup`; and a hex-parsing `Color`). + +## What we build + +### 1. Color value types + +```csharp +public readonly record struct RgbColor(byte R, byte G, byte B) +{ + public static RgbColor FromHex(string hex); // "FFAA00" / "#FFAA00" + public string ToHex(); +} + +public readonly record struct RgbwColor(byte R, byte G, byte B, byte W); + +/// Primary / secondary (background) / tertiary slots of a segment. +public sealed record SegmentColors( + Color Primary, + Color? Secondary = null, + Color? Tertiary = null); +``` + +- A single `Color` abstraction must represent **either** RGB or RGBW (WLED accepts 3 or + 4 byte arrays, and hex strings). Model as a `readonly record struct Color` with a + `bool HasWhite` discriminator, or two structs behind a small union. The constructor + guarantees component validity (bytes), so an invalid color is unrepresentable. +- A `JsonConverter` reads the `col` array (handles 3-byte, 4-byte, and + hex-string forms — see `frenck` `Color._deserialize`) and writes the canonical byte + arrays. This is the only place that touches `int[][]`. + +> Random colors (`"r"`) are a *command*, not a readable state, so they live in Plan 2 +> (`SegmentUpdate`), not in the response `SegmentColors`. + +### 2. Enums (replace bare numbers) + +```csharp +public enum NightlightMode : byte { Instant = 0, Fade = 1, ColorFade = 2, Sunrise = 3 } + +public enum LiveDataOverride : byte { Off = 0, UntilLiveEnds = 1, UntilReboot = 2 } + +public enum SoundSimulation : byte { BeatSin = 0, WeWillRockYou = 1, Mode10_3 = 2, Mode14_3 = 3 } + +public enum Expand1D : byte { Pixels = 0, Bar = 1, Arc = 2, Corner = 3 } + +[Flags] public enum LightCapability : byte +{ + None = 0, Rgb = 1, WhiteChannel = 2, ColorTemperature = 4, ManualWhite = 8 +} + +[Flags] public enum SyncGroup : byte +{ + None = 0, Group1 = 1, Group2 = 2, Group3 = 4, Group4 = 8, + Group5 = 16, Group6 = 32, Group7 = 64, Group8 = 128 +} +``` + +Each enum gets a tiny `JsonConverter` (numeric on the wire, enum in C#). `[Flags]` +enums serialize as their integer bitfield. + +### 3. Optional ids without sentinels + +WLED uses `-1` for "no preset / no playlist". Expose these as `int?` on the response +(map `-1 → null` in a converter, as `frenck` does in `State.__post_deserialize__`) so +callers never compare against a magic sentinel. + +## Files + +- `src/Kevsoft.WLED/Color.cs`, `RgbColor.cs`, `RgbwColor.cs`, `SegmentColors.cs` +- `src/Kevsoft.WLED/Enums/*.cs` +- `src/Kevsoft.WLED/Json/*Converter.cs` (color + enums + nullable-id) + +## Tests + +- Hex round-trips: `"FFAA00"` ⇄ `(255,170,0)`; with/without `#`; lower/upper case. +- `col` deserialization for 3-byte, 4-byte, and hex-string arrays in one payload. +- Out-of-range hex / wrong-length arrays throw a clear `FormatException`/`JsonException`. +- Each enum maps to/from its documented integer; unknown values fail loudly. +- `[Flags]` round-trip for `LightCapability` value `7 → Rgb|WhiteChannel|ColorTemperature`. + +## Acceptance + +A color can only be constructed from valid components or valid hex; every documented +enumerated field is a real enum; "none" ids are `null`. No public API exposes `int[][]` +or a bare numeric "mode" any more (legacy members `[Obsolete]`, see Plan 11). diff --git a/plans/10-config-api.md b/plans/10-config-api.md new file mode 100644 index 0000000..a4b13f0 --- /dev/null +++ b/plans/10-config-api.md @@ -0,0 +1,102 @@ +# Plan 10 — Configuration API (`/json/cfg`) & node discovery (`/json/nodes`) + +**Theme:** Feature · **Depends on:** Plans 1, 5 + +This plan covers the two remaining read/write endpoints: device configuration and LAN +node discovery. They're grouped because both are self-contained and lower-traffic than the +state/segment work. + +--- + +## Part A — Node discovery (`GET /json/nodes`) + +### Why +Lets an app find other WLED devices on the network (since 0.12.0). Pure read, easy win. + +### Model (cf. `paul-fornage` `nodes.rs`) + +```csharp +public sealed record WledNode( + string Name, + string Ip, // "ip" + string Type, // "type" (board type id) → optional NodeType enum overlay + int BuildId, // "vid" + string? MacAddress); // when present +``` + +### Client method +```csharp +Task> GetNodes(CancellationToken ct = default); +``` +Response is `{ "nodes": [ ... ] }`; map to the list. Empty/`-1` discovery → empty list. + +### Tests +Parse a real `/json/nodes` payload; empty payload → empty list. + +--- + +## Part B — Configuration (`GET`/`POST /json/cfg`) + +### Why +`/json/cfg` exposes the full device configuration (Wi-Fi, hardware/LED bus setup, sync, +time, usermods, security, etc.). It's the largest and most safety-critical surface: a bad +write can knock a device off the network. So ergonomics here means **typed sections, +partial updates, and guard rails**, not a single opaque blob. + +### Strategy (phased, because `cfg` is huge) + +1. **Phase 1 — typed read, safe partial write.** Model the well-documented top-level + sections as nested types, but keep a `JsonExtensionData` catch-all so unknown/firmware- + specific keys round-trip losslessly (critical — never drop config you don't understand). + + ```csharp + public sealed class DeviceConfig + { + [JsonPropertyName("id")] public IdentityConfig? Identity { get; init; } // name, etc. + [JsonPropertyName("nw")] public NetworkConfig? Network { get; init; } + [JsonPropertyName("ap")] public AccessPointConfig? AccessPoint { get; init; } + [JsonPropertyName("hw")] public HardwareConfig? Hardware { get; init; } // LED buses + [JsonPropertyName("if")] public InterfacesConfig? Interfaces { get; init; }// sync/MQTT/etc. + [JsonPropertyName("light")] public LightConfig? Light { get; init; } + [JsonPropertyName("def")] public DefaultsConfig? Defaults { get; init; } + [JsonExtensionData] public Dictionary Unknown { get; init; } + } + ``` + +2. **Phase 2 — deepen high-value sections** (LED bus layout under `hw.led.ins`, sync + under `if.sync`, time under `if.ntp`) into fully typed models as demand warrants. + + Use `paul-fornage` `src/structures/cfg/*` as the authoritative field reference (it's + the most complete public mapping of `cfg`). + +### Client methods + +```csharp +Task GetConfig(CancellationToken ct = default); +Task UpdateConfig(DeviceConfig partial, CancellationToken ct = default); // POST /json/cfg +``` + +### Guard rails (impossible-to-brick ethos) + +- `UpdateConfig` serializes only set sections (nullable sections + `WhenWritingNull`), so a + partial update never blanks untouched config. +- Doc-comment loudly that changing `nw`/`ap` can disconnect the device; consider an opt-in + `AllowNetworkChanges` flag on a config-update options object so a network change can't be + sent by accident. +- Preserve `Unknown` extension data on write so firmware-specific keys survive a + read-modify-write cycle. + +### Files +- `src/Kevsoft.WLED/Config/*.cs` (sections), `DeviceConfig.cs` +- `src/Kevsoft.WLED/WledNode.cs` +- `IWLedClient` + `WLedClient`: `GetNodes`, `GetConfig`, `UpdateConfig` + +### Tests +- Round-trip a real `/json/cfg` payload with `JsonExtensionData` preserving unknown keys. +- Partial `UpdateConfig` emits only the touched section. +- Network-change guard: a `nw` change without the opt-in flag throws. + +## Acceptance +Nodes are discoverable as a typed list; configuration is readable as typed sections with +lossless round-tripping of unknown keys, and partial writes are safe by construction with +explicit guards around network-affecting changes. diff --git a/plans/11-client-ergonomics-and-cross-cutting.md b/plans/11-client-ergonomics-and-cross-cutting.md new file mode 100644 index 0000000..d9e03db --- /dev/null +++ b/plans/11-client-ergonomics-and-cross-cutting.md @@ -0,0 +1,124 @@ +# Plan 11 — Client ergonomics & cross-cutting concerns + +**Theme:** Quality · **Runs alongside:** all other plans + +## Why + +A complete field mapping isn't "easy to use" on its own. This plan covers the +cross-cutting work that makes the library pleasant, safe, and production-ready: high-level +intent methods, cancellation, DI, error handling, versioning, and migration. + +## 1. High-level intent methods (the 80% use cases) + +Most callers want one-liners, not state objects. Add convenience methods on `IWLedClient` +that compose the Plan 2 builders: + +```csharp +Task TurnOn(CancellationToken ct = default); +Task TurnOff(CancellationToken ct = default); +Task Toggle(CancellationToken ct = default); +Task SetBrightness(byte brightness, CancellationToken ct = default); +Task SetColor(RgbColor color, int? segmentId = null, CancellationToken ct = default); +Task SetEffect(Selector effect, int? segmentId = null, CancellationToken ct = default); +Task SetPalette(Selector palette, int? segmentId = null, CancellationToken ct = default); +Task Reboot(CancellationToken ct = default); // {"rb":true} +``` + +The README's first example should become `await client.TurnOn();` / +`await client.SetColor(RgbColor.FromHex("FFAA00"));`. + +## 2. `CancellationToken` on every async method + +Every existing and new client method gains a trailing `CancellationToken ct = default` +and forwards it to `HttpClient`. (Source-compatible additive change.) + +## 3. DI / `IHttpClientFactory` integration + +```csharp +services.AddWledClient("http://wled-desk/"); // typed-client registration +// or +services.AddWledClient(o => o.BaseAddress = new(...)); // options-based +``` + +- Add a `Kevsoft.WLED.DependencyInjection` extension (or guard behind a target/feature) + registering `IWLedClient` via `AddHttpClient`, so `HttpClient` lifetime/pooling is + handled correctly instead of `new HttpClient(...)` per instance. +- Keep the existing constructors for non-DI use. + +## 4. Error handling + +Today every method calls `EnsureSuccessStatusCode()` (raw `HttpRequestException`) and +assumes non-null JSON. Improve to a typed exception hierarchy: + +```csharp +public class WledException : Exception { } +public sealed class WledConnectionException : WledException { } // transport/timeout +public sealed class WledResponseException : WledException // non-2xx +{ public int StatusCode { get; } public string? Body { get; } } +public sealed class WledUnsupportedVersionException : WledException { } +``` + +- Wrap transport failures and non-2xx responses; include status + body snippet. +- Mirror `frenck`'s minimum-version guard (`MIN_REQUIRED_VERSION = 0.14.0`): optionally + validate `info.ver` once and throw `WledUnsupportedVersionException` for too-old + firmware, since many typed features assume ≥ 0.14. + +## 5. Multi-targeting + +Currently `netstandard2.0` with `System.Text.Json`. Add `net8.0` (and keep +`netstandard2.0`) so modern consumers get trimming/AOT-friendly **source-generated** +JSON contexts (`JsonSerializerContext`) for the new converters, and `netstandard2.0` +keeps broad reach. + +## 6. Backwards-compatibility & migration + +Several plans change public shapes (`int[][]` → `SegmentColors`, `byte` → enums, +`int` ids → `int?`). To avoid a hard break: + +- Mark old members `[Obsolete("Use X")]` for one minor release, keeping them working via + shims where feasible; remove in the next major. +- Document the migration in the README and a `CHANGELOG.md`. +- Follow SemVer: shape-changing removals → major bump. + +## 7. Docs & samples (and the standing "keep them current" rule) + +This plan **establishes** the documentation/sample assets; every other plan is then +responsible for keeping them up to date (see the *Definition of Done* in the +[plans README](README.md)). + +- **Root `README.md`**: + - Replace the manual-DTO examples with builder + intent-method examples + (`await client.TurnOn();`, `await client.SetColor(RgbColor.FromHex("FFAA00"));`). + - Add a **"Supported features" capability matrix** (feature → supported? → link to the + relevant plan / sample) so consumers can see coverage at a glance. This matrix is + updated by each feature plan as it lands. + - Document supported WLED firmware versions and the target frameworks (Plan 0). +- **`CHANGELOG.md`**: create it (Keep a Changelog format) and require an entry per change. +- **Samples**: keep `samples/BasicConsole` as a clean, runnable showcase of the + ergonomic API, and add focused samples as features land: color/effect, presets, + playlists, individual LEDs, diagnostics (info/wifi/fs). Each sample must build and run + on the current sample TFM (Plan 0). Wire the samples into CI build so they can't rot. +- Consider enabling XML doc output → a docs site (already `GenerateDocumentationFile`), + and surfacing the README capability matrix there. + +## Files + +- `src/Kevsoft.WLED/WLedClient.cs`, `IWLedClient.cs` (intent methods, `ct`) +- `src/Kevsoft.WLED/Exceptions/*.cs` +- `src/Kevsoft.WLED.DependencyInjection/*` (or guarded folder) +- `Kevsoft.WLED.csproj` (multi-target, STJ source-gen), `README.md`, `CHANGELOG.md` + +## Tests + +- Intent methods emit the expected minimal JSON to the right route. +- `CancellationToken` cancels in-flight requests (cancelled token → `OperationCanceledException`). +- Error mapping: 404/500 → `WledResponseException` with status/body; transport → + `WledConnectionException`. +- DI registration resolves a working `IWLedClient`. +- Obsolete shims still serialize identically to the new types. + +## Acceptance + +The common operations are one-liners with cancellation support; failures surface as typed +exceptions; the client integrates with DI/`IHttpClientFactory`; the package multi-targets; +and shape changes ship with obsolete shims + a documented migration path. diff --git a/plans/2-command-values-and-builders.md b/plans/2-command-values-and-builders.md new file mode 100644 index 0000000..aa87f5c --- /dev/null +++ b/plans/2-command-values-and-builders.md @@ -0,0 +1,114 @@ +# Plan 2 — Command-value model & fluent builders + +**Theme:** Foundation · **Depends on:** Plan 1 · **Unblocks:** Plans 3, 4, 6, 7, 8 + +## Why + +The single biggest "easy to misuse" risk in the JSON API is its **command mini-language**. +Many writable fields accept more than a plain value: + +| Field(s) | Accepts | Example | +|----------|---------|---------| +| `on`, segment `on`, `frz` | `true` / `false` / `"t"` (toggle) | `{"on":"t"}` | +| `bri`, `sx`, `ix` | absolute, `~`/`~-` (inc/dec), `~10`/`~-10`, `w~40` (wrap) | `{"bri":"~40"}` | +| `fx`, `pal` | id, `~`/`~-`, `"r"` (random) | `{"seg":{"fx":"r"}}` | +| `ps` | id, `"1~6~"` (cycle), `"4~10r"` (random in range) | `{"ps":"1~6~"}` | +| `col` slot | RGB(W), hex, `"r"` (random) | `["r",[0,0,0],"r"]` | + +If we expose these as `string`, callers will hand-build invalid commands. If we expose +them only as numbers, we lose toggling/relative/random entirely. The fix is a small set +of closed command types that can *only* be created through valid factory methods. + +## What we build + +### 1. Command value types (closed, factory-only) + +```csharp +// Boolean that may also toggle. +public readonly struct Toggleable +{ + public static Toggleable On { get; } + public static Toggleable Off { get; } + public static Toggleable Toggle { get; } + public static implicit operator Toggleable(bool value); +} + +// 0-255 value that can be set, nudged, or wrapped. +public readonly struct ByteAdjust +{ + public static ByteAdjust Set(byte value); + public static ByteAdjust Increment(byte by = 1); + public static ByteAdjust Decrement(byte by = 1); + public static ByteAdjust IncrementWrap(byte by); // "w~40" + public static implicit operator ByteAdjust(byte value); +} + +// Effect / palette selection. +public readonly struct Selector +{ + public static Selector Id(int id); + public static Selector Next { get; } // "~" + public static Selector Previous { get; } // "~-" + public static Selector Random { get; } // "r" + public static implicit operator Selector(int id); +} + +// Preset selection incl. cycle / random-in-range. +public readonly struct PresetSelector +{ + public static PresetSelector Id(int id); + public static PresetSelector Cycle(int from, int to); // "1~6~" + public static PresetSelector RandomInRange(int from, int to); // "4~10r" +} +``` + +Each type has a `JsonConverter` that emits the correct primitive or string. Because the +only way to create one is a validated factory/implicit conversion, **an invalid command +string is unrepresentable**. + +### 2. Fluent builders (the primary public write surface) + +Instead of newing up `StateRequest { ... }` bags of nullables, callers use builders that +read like intent and compile down to the existing `StateRequest`/`SegmentRequest` DTOs: + +```csharp +await client.UpdateState(s => s + .TurnOn() // or .Toggle() + .Brightness(ByteAdjust.IncrementWrap(40)) + .Transition(TimeSpan.FromMilliseconds(700)) + .Segment(0, seg => seg + .Color(RgbColor.FromHex("FFAA00")) + .Effect(Selector.Random) + .Speed(200))); +``` + +- `StateUpdate` / `SegmentUpdate` expose only settable concepts, each strongly typed. +- `Transition` accepts a `TimeSpan` and converts to WLED's 100 ms units (and supports + `tt` — "this call only" — via `.TransitionOnce(...)`). +- The builder produces a `StateRequest`; `client.UpdateState(Action)` is a + thin wrapper over the existing `Post(StateRequest)`. + +### 3. Keep DTOs, hide them + +The nullable `StateRequest`/`SegmentRequest` remain (serialization layer + power users), +but the documented, discoverable path is the builder. + +## Files + +- `src/Kevsoft.WLED/Commands/{Toggleable,ByteAdjust,Selector,PresetSelector}.cs` +- `src/Kevsoft.WLED/Json/*CommandConverter.cs` +- `src/Kevsoft.WLED/Fluent/{StateUpdate,SegmentUpdate}.cs` +- `IWLedClient.UpdateState(Action)` + `WLedClient` impl + +## Tests + +- Each command type serializes to the exact documented token (`"t"`, `"~"`, `"~-"`, + `"~10"`, `"w~40"`, `"r"`, `"1~6~"`, `"4~10r"`) and absolute values stay numeric. +- Builder composition emits minimal JSON (only touched fields present) — reuse the + existing `PostEmpty*`/`PostFull*` assertion style in `WLedClientPostTests`. +- `TimeSpan` → 100 ms unit conversion (rounding, clamping to `0–65535`). + +## Acceptance + +Toggling, relative adjustments, random and preset-cycle commands are all expressible +**only** through valid types, and the fluent builder is the natural way to write state. diff --git a/plans/3-complete-state-object.md b/plans/3-complete-state-object.md new file mode 100644 index 0000000..b182a4b --- /dev/null +++ b/plans/3-complete-state-object.md @@ -0,0 +1,84 @@ +# Plan 3 — Complete the State object + +**Theme:** Data model · **Depends on:** Plans 1, 2 + +## Why + +`StateResponse`/`StateRequest` only map a subset of the documented state keys, and the +ones they do map use raw primitives. This plan closes the field gap *using the typed +foundations* from Plans 1–2 so the state surface is both complete and safe. + +## Current vs. API + +Mapped today: `on`, `bri`, `transition`, `ps`, `pl`, `nl`, `udpn`, `lor`, `mainseg`, +`seg`, `tb`. + +Missing / mistyped (see [JSON API → State object](https://kno.wled.ge/interfaces/json-api/)): + +| Key | Meaning | Modeled as | +|-----|---------|-----------| +| `tt` | transition for *this call only* | `StateUpdate.TransitionOnce(TimeSpan)` (write-only) | +| `psave` | save current state to preset slot | Plan 6 (write-only command) | +| `sb`,`ib`,`sc` | what to save with `psave` | Plan 6 | +| `pdel` | delete preset id | Plan 6 | +| `nl.rem` | remaining nightlight seconds (read-only) | `Nightlight.Remaining` (`int?`, -1→null) | +| `nl.mode` | already `byte` → use `NightlightMode` enum (Plan 1) | enum | +| `udpn.sgrp`/`rgrp` | send/receive sync groups | `SyncGroup` `[Flags]` (Plan 1) | +| `udpn.nn` | suppress broadcast for this call (write-only) | `UdpSyncUpdate.NoNotify` | +| `lor` | live data override → `LiveDataOverride` enum (Plan 1) | enum | +| `v` | echo full state in POST response | client option (Plan 11) | +| `rb` | reboot now (write-only) | `client.Reboot()` (Plan 11) | +| `live` | enter realtime/blank (write-only) | `StateUpdate.EnterLiveMode(bool)` | +| `time` | set device unix time (write-only) | `StateUpdate.SetTime(DateTimeOffset)` | +| `playlist` | inline playlist | Plan 7 | +| `ledmap` | load ledmap 0–9 (write-only) | `StateUpdate.LoadLedMap(byte)` w/ 0–9 guard | +| `rmcpal` | remove last custom palette (write-only) | `StateUpdate.RemoveLastCustomPalette()` | +| `np` | advance to next preset in playlist (write-only) | `StateUpdate.NextPreset()` | +| `mainseg` | already mapped | keep | + +## What we build + +### Response (read model) — additions to `StateResponse` + +```csharp +public LiveDataOverride LiveDataOverride { get; init; } // was byte +public byte LedMap { get; init; } // ledmap +public int? PresetId { get; init; } // -1 → null +public int? PlaylistId { get; init; } // -1 → null +``` + +`NightlightResponse` gains `Remaining` (`int?`) and `Mode` becomes `NightlightMode`. +`UdpPacketsResponse` gains `SendGroups`/`ReceiveGroups` (`SyncGroup`). + +### Write model — fold the write-only commands into `StateUpdate` (Plan 2) + +```csharp +client.UpdateState(s => s + .EnterLiveMode() + .SetTime(DateTimeOffset.UtcNow) + .LoadLedMap(2) // throws if > 9 + .NextPreset()); +``` + +Write-only keys (`tt`, `rb`, `live`, `time`, `ledmap`, `rmcpal`, `np`, `udpn.nn`) appear +**only** on the builder/`Request`, never on the response — enforcing read/write split. + +## Files + +- Edit `StateResponse.cs`, `StateRequest.cs`, `NightlightResponse/Request.cs`, + `UdpPacketsResponse/Request.cs` +- Extend `Fluent/StateUpdate.cs` (Plan 2) with the write-only commands + +## Tests + +- Round-trip a full real `/json/state` sample (grab one from a device or the docs) and + assert every documented key maps. +- `-1` preset/playlist → `null`; `nl.rem = -1` → `null`. +- Write-only commands serialize correctly and are absent from responses. +- Extend `JsonBuilder.CreateStateJson` to include the new keys. + +## Acceptance + +`StateResponse` represents every readable state key with a correct type, and every +writable/command-only key is reachable through `StateUpdate` with range guards. No bare +`byte` "modes" remain on state. diff --git a/plans/4-complete-segment-object.md b/plans/4-complete-segment-object.md new file mode 100644 index 0000000..359d446 --- /dev/null +++ b/plans/4-complete-segment-object.md @@ -0,0 +1,84 @@ +# Plan 4 — Complete the Segment object + +**Theme:** Data model · **Depends on:** Plans 1, 2 + +## Why + +Segments are where most of WLED's expressive power lives, and the current +`SegmentResponse`/`SegmentRequest` map only ~18 of ~30 documented keys, with colors as +raw `int[][]`. This plan completes the segment surface with the typed foundations. + +## Current vs. API + +Mapped today: `id`, `start`, `stop`, `len`, `grp`, `spc`, `of`, `col`, `fx`, `sx`, `ix`, +`pal`, `sel`, `rev`, `frz`, `on`, `bri`, `mi`. + +Missing (see [JSON API → Segment object](https://kno.wled.ge/interfaces/json-api/)): + +| Key | Meaning | Modeled as | +|-----|---------|-----------| +| `n` | segment name | `string? Name` | +| `startY`,`stopY` | 2D matrix rows | `int? StartY/StopY` (Plan: 2D group) | +| `rY` | flip 2D vertically | `bool ReverseY` | +| `mY` | mirror 2D vertically | `bool MirrorY` | +| `tp` | transpose (swap X/Y) | `bool Transpose` | +| `cct` | color temperature (0–255 *or* 1900–10091 K) | `ColorTemperature` value type | +| `c1`,`c2` | custom sliders (0–255) | `byte CustomSlider1/2` | +| `c3` | custom slider (0–31) | `CustomSlider3` (0–31 guard) | +| `o1`,`o2`,`o3` | effect options | `bool Option1/2/3` | +| `m12` | Expand 1D FX | `Expand1D` enum (Plan 1) | +| `si` | sound simulation type | `SoundSimulation` enum (Plan 1) | +| `set` | group/set id (0–3) | `SegmentSet` (0–3 guard) | +| `fxdef` | load effect defaults | `SegmentUpdate.LoadEffectDefaults()` (write-only) | +| `rpt` | repeat segment to fill strip | `SegmentUpdate.RepeatToFill()` (write-only) | +| `cln` | clone source segment | `int? Clones` (read) | +| `i` | individual LED control | Plan 8 | +| `col` | colors | `SegmentColors` (Plan 1) — replaces `int[][]` | +| `fx`,`pal` | selection w/ `~`/`r` | `Selector` (Plan 2) on write | +| `sx`,`ix`,`bri` | adjustable | `ByteAdjust` (Plan 2) on write | +| `on`,`frz` | toggleable | `Toggleable` (Plan 2) on write | + +### CCT value type (impossible to misuse) + +`cct` is genuinely dual-range. Model it so the caller states *intent*: + +```csharp +public readonly struct ColorTemperature +{ + public static ColorTemperature Relative(byte value); // 0-255 + public static ColorTemperature Kelvin(int kelvin); // 1900-10091, guarded + public bool IsKelvin { get; } +} +``` + +The converter writes the relative byte or the Kelvin int per the docs, and reads back +into whichever range WLED reported (per the docs' forward-compat guidance). + +## What we build + +- Add the missing read fields to `SegmentResponse` (typed). +- Replace `int[][] Colors` with `SegmentColors Colors` (+ `[Obsolete]` shim, Plan 11). +- Add a **2D matrix sub-group** comment/region so matrix-only fields (`startY`,`stopY`, + `rY`,`mY`,`tp`) are clearly grouped and documented as no-ops on 1D strips. +- Extend `SegmentUpdate` (Plan 2) with all writable fields, using `Toggleable`, + `ByteAdjust`, `Selector`, `ColorTemperature`, and the guarded slider types. + +## Files + +- Edit `SegmentResponse.cs`, `SegmentRequest.cs` +- `src/Kevsoft.WLED/ColorTemperature.cs`, `CustomSlider3.cs`, `SegmentSet.cs` +- Extend `Fluent/SegmentUpdate.cs` + +## Tests + +- Full real segment payload round-trips (including a 2D segment with `startY/stopY`). +- `c3 > 31`, `set > 3`, Kelvin out of `1900–10091` all throw at construction. +- `cct` round-trips in both relative and Kelvin form. +- `col` via `SegmentColors` (RGB, RGBW, hex) round-trips; random color is a write command. +- Extend `JsonBuilder` segment emission with the new keys. + +## Acceptance + +Every documented segment key is represented with a correct, range-safe type; colors flow +through `SegmentColors`; 2D fields are clearly delineated; writable fields use the Plan 2 +command types so relative/toggle/random work without magic strings. diff --git a/plans/5-complete-info-and-read-endpoints.md b/plans/5-complete-info-and-read-endpoints.md new file mode 100644 index 0000000..5aea774 --- /dev/null +++ b/plans/5-complete-info-and-read-endpoints.md @@ -0,0 +1,82 @@ +# Plan 5 — Complete Info & new read endpoints (si / net / nodes / live) + +**Theme:** Read APIs · **Depends on:** Plan 1 + +## Why + +`InformationResponse`/`LedsResponse` omit many diagnostic fields, and several read-only +endpoints aren't exposed at all. These are pure reads, so they're low-risk and high-value +for monitoring/diagnostics integrations. + +## Part A — Complete `InformationResponse` / `LedsResponse` + +Missing info keys (see [JSON API → Info object](https://kno.wled.ge/interfaces/json-api/) +and `frenck` `Info`/`Leds`/`Wifi`/`Filesystem`): + +| Key | Modeled as | +|-----|-----------| +| `leds.rgbw` | `bool Rgbw` | +| `leds.wv` | `bool WhiteValueSlider` | +| `leds.cct` | `bool SupportsColorTemperature` | +| `leds.seglc` | `LightCapability[] SegmentLightCapabilities` (Plan 1 flags) | +| `leds.lc` | retype to `LightCapability` (Plan 1) | +| `lm` | `string LiveMode` | +| `lip` | `string LiveIp` | +| `ws` | `int? WebSocketClients` (-1 → null, unsupported) | +| `wifi` | `WifiResponse { Bssid, Signal, Channel, Rssi }` | +| `fs` | `FilesystemResponse { Used, Total, LastModified }` + computed `Free`/`FreePercentage` | +| `ndc` | `int DiscoveredDevices` | +| `cpalcount`/`umpalcount`/`umpalnames` | custom/usermod palette counts + names | + +`fs.pmt` is a unix timestamp → expose as `DateTimeOffset?` (0/absent → null). +`FilesystemResponse` gets computed `Free`, `FreePercentage`, `UsedPercentage` properties +(mirrors `frenck.Filesystem`), so callers never divide by hand. + +## Part B — New read endpoints + +| Endpoint | Returns | New client method | +|----------|---------|-------------------| +| `GET /json/si` | `{state, info}` only (lighter than `/json`) | `Task GetStateInfo(CancellationToken)` | +| `GET /json/net` | nearby Wi-Fi networks (`networks[]`) | `Task GetNetworks(...)` | +| `GET /json/nodes` | discovered WLED nodes on the LAN | Plan 10 *(covered there)* | +| `GET /json/live` | live LED color stream (if `WLED_ENABLE_JSONLIVE`) | `Task GetLiveColors(...)` | + +### `/json/si` + +```csharp +public sealed class StateInfoResponse +{ + [JsonPropertyName("state")] public StateResponse State { get; init; } + [JsonPropertyName("info")] public InformationResponse Info { get; init; } +} +``` + +### `/json/net` + +Array of `{ ssid, rssi, bssid, channel, enc }`. Map `enc` to an `WifiEncryption` enum +where the WLED values are known; otherwise keep an `int` plus enum overlay. + +### `/json/live` + +`{ leds: ["rrggbb", ...], n: , ... }`. Expose `LiveResponse.Leds` as +`RgbColor[]` (reuse Plan 1 hex parsing). Return `null` when the build lacks JSON-live +(endpoint 404/empty) rather than throwing, so feature-detection is easy. + +## Files + +- Edit `InformationResponse.cs`, `LedsResponse.cs` +- `src/Kevsoft.WLED/{WifiResponse,FilesystemResponse,StateInfoResponse,NetworkResponse,LiveResponse}.cs` +- `IWLedClient` + `WLedClient`: `GetStateInfo`, `GetNetworks`, `GetLiveColors` + +## Tests + +- Full real `/json/info` sample round-trips incl. `wifi`, `fs`, `seglc`, `ws=-1 → null`. +- `FilesystemResponse` computed properties (`Free`, percentages) with sample numbers. +- `/json/si`, `/json/net`, `/json/live` happy-path + `/json/live` 404 → `null`. +- Extend `JsonBuilder` / add new builders; reuse `MockHttpMessageHandler` per-route. + +## Acceptance + +`InformationResponse` exposes the full documented info surface with typed Wi-Fi/FS/LED +capabilities, and `/json/si`, `/json/net`, `/json/live` are first-class read methods with +graceful feature-detection. diff --git a/plans/6-presets-api.md b/plans/6-presets-api.md new file mode 100644 index 0000000..a603163 --- /dev/null +++ b/plans/6-presets-api.md @@ -0,0 +1,88 @@ +# Plan 6 — Presets API + +**Theme:** Feature · **Depends on:** Plans 1–4 + +## Why + +Presets are one of WLED's headline features and are completely absent today. The JSON API +exposes presets through a mix of state-object command keys (`psave`, `ps`, `pdel`, `sb`, +`ib`, `sc`) and the `presets.json` file (readable via `/presets.json`). We want a clean, +intention-revealing API that hides this awkwardness. + +## API surface (WLED) + +- **Apply** a preset: `{"ps": }` (also `"1~6~"` cycle / `"4~10r"` random — Plan 2 + `PresetSelector`). +- **Save** current state to a slot: `{"psave": , "n":"name", "ql":"label", + "sb":bool, "ib":bool, "sc":bool}` (segment-bounds / include-brightness / + selected-segments flags). +- **Delete**: `{"pdel": }`. +- **Read** all presets: `GET /presets.json` → object keyed by id, where each value is a + preset *or* a playlist (a preset whose `playlist.ps` is non-empty). `frenck` splits + these in `Device.__pre_deserialize__` — we do the same. + +## What we build + +### Read model + +```csharp +public sealed record Preset( + int Id, + string Name, + string? QuickLabel, + bool On, + int MainSegmentId, + IReadOnlyList Segments); +``` + +`GET /presets.json` is parsed into `IReadOnlyDictionary` (playlists filtered +out into Plan 7's model). Preset `0` is dropped (it's the scratch slot). + +### Client methods (intention-revealing, no magic keys) + +```csharp +Task> GetPresets(CancellationToken ct = default); +Task ApplyPreset(PresetSelector preset, CancellationToken ct = default); +Task SavePreset(int id, SavePresetOptions? options = null, CancellationToken ct = default); +Task DeletePreset(int id, CancellationToken ct = default); +``` + +```csharp +public sealed class SavePresetOptions +{ + public string? Name { get; init; } + public string? QuickLabel { get; init; } + public bool SaveSegmentBounds { get; init; } = true; // sb + public bool IncludeBrightness { get; init; } = true; // ib + public bool SaveSelectedSegments { get; init; } = true;// sc +} +``` + +Internally these compose `StateRequest` (`Ps`, `Psave`, `Pdel`, plus the new `n`/`ql`/ +`sb`/`ib`/`sc` request fields) — so presets ride the existing serialization layer. +`ApplyPreset` takes a `PresetSelector`, making cycle/random first-class and impossible to +mistype. + +> Note: saving a preset persists the device's *current* live state. Document this clearly +> (a frequent source of confusion) and offer `SavePresetFrom(StateUpdate, ...)` later if +> WLED's "save arbitrary state" path is confirmed. + +## Files + +- `src/Kevsoft.WLED/Preset.cs`, `SavePresetOptions.cs` +- Add `Psave`/`Pdel`/`n`/`ql`/`sb`/`ib`/`sc` to `StateRequest.cs` +- `IWLedClient` + `WLedClient`: the four methods above +- `src/Kevsoft.WLED/Json/PresetsConverter.cs` (split presets vs playlists) + +## Tests + +- Parse a real `presets.json` containing both presets and a playlist; assert playlists + are excluded and slot `0` dropped. +- `ApplyPreset(PresetSelector.Cycle(1,6))` → `{"ps":"1~6~"}`. +- `SavePreset(3, new(){Name="X"})` → `{"psave":3,"n":"X","sb":true,...}`. +- `DeletePreset(3)` → `{"pdel":3}`. + +## Acceptance + +Callers can list, apply (incl. cycle/random), save (with documented flags), and delete +presets through dedicated methods, never touching `psave`/`pdel`/`ps` directly. diff --git a/plans/7-playlists-api.md b/plans/7-playlists-api.md new file mode 100644 index 0000000..0ace04d --- /dev/null +++ b/plans/7-playlists-api.md @@ -0,0 +1,92 @@ +# Plan 7 — Playlists API + +**Theme:** Feature · **Depends on:** Plans 2, 6 + +## Why + +Playlists (available since 0.11.0) cycle presets with per-step durations and transitions. +The wire format is awkward — three *parallel arrays* (`ps`, `dur`, `transition`) plus +`repeat`/`end` — which is exactly the kind of thing we should never make a caller +assemble by hand. + +## API surface (WLED) + +```jsonc +{ "playlist": { + "ps": [26, 20, 18, 20], // preset ids, in order + "dur": [30, 20, 10, 50], // tenths of a second (scalar broadcasts to all) + "transition": 0, // tenths of a second (scalar or array) + "repeat": 10, // 0 = forever + "end": 21 // preset to apply when finished +} } +``` + +Playlists are also stored in `presets.json` (a preset whose `playlist.ps` is non-empty). + +## What we build — make the parallel arrays unrepresentable + +Model a playlist as a list of **entries**, each a self-contained step. The library zips +them into/out of the parallel-array wire format (mirrors `frenck` `Playlist`/ +`PlaylistEntry`). + +```csharp +public sealed record PlaylistEntry( + int PresetId, + TimeSpan Duration, // serialized to tenths-of-second + TimeSpan? Transition = null); + +public sealed class PlaylistDefinition +{ + public IReadOnlyList Entries { get; init; } = []; + public int Repeat { get; init; } = 0; // 0 = indefinite + public int? EndPresetId { get; init; } + public bool Shuffle { get; init; } // "r" +} + +public sealed record Playlist(int Id, string Name, PlaylistDefinition Definition); +``` + +Because each `PlaylistEntry` couples its own preset/duration/transition, the +"arrays of different lengths" bug class is gone. + +### Client methods + +```csharp +Task> GetPlaylists(CancellationToken ct = default); +Task StartPlaylist(PlaylistDefinition playlist, CancellationToken ct = default); +Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? o = null, CancellationToken ct = default); +``` + +- `StartPlaylist` builds `{"playlist": {...}}` by **unzipping** entries into `ps`/`dur`/ + `transition` arrays. A `PlaylistRequest` DTO + converter owns this transformation. +- `SavePlaylist` combines the playlist with a `psave` (reuses Plan 6 plumbing). +- `GetPlaylists` reuses the `presets.json` parse from Plan 6, taking the playlist branch. + +### Fluent helper (optional, nice) + +```csharp +await client.StartPlaylist(p => p + .Add(preset: 26, TimeSpan.FromSeconds(3)) + .Add(preset: 20, TimeSpan.FromSeconds(2), transition: TimeSpan.FromMilliseconds(700)) + .Repeat(10) + .EndOn(21)); +``` + +## Files + +- `src/Kevsoft.WLED/{PlaylistEntry,PlaylistDefinition,Playlist}.cs` +- `src/Kevsoft.WLED/PlaylistRequest.cs` + `Json/PlaylistConverter.cs` +- Add `Playlist` to `StateRequest.cs` (the `playlist` key) +- `IWLedClient` + `WLedClient`: `GetPlaylists`, `StartPlaylist`, `SavePlaylist` + +## Tests + +- `PlaylistDefinition` with 4 entries → exact `ps`/`dur`/`transition` arrays (tenths). +- Scalar `dur`/`transition` from device → broadcast to all entries on read. +- Round-trip a real `presets.json` playlist entry into `Playlist` and back. +- `Repeat(0)` (indefinite) and `EndOn` behaviour. + +## Acceptance + +Playlists are created and read as ordered lists of typed entries; the library fully owns +the parallel-array ⇄ entries transformation, so callers cannot produce mismatched arrays. diff --git a/plans/8-individual-led-control.md b/plans/8-individual-led-control.md new file mode 100644 index 0000000..cf392c0 --- /dev/null +++ b/plans/8-individual-led-control.md @@ -0,0 +1,78 @@ +# Plan 8 — Per-segment individual LED control + +**Theme:** Feature · **Depends on:** Plans 1, 2 + +## Why + +The segment `i` property lets you address individual LEDs — great for clocks, meters, +notifications. It has three overlapping wire encodings packed into one heterogeneous JSON +array, which is hostile to hand-author. We hide all of that behind explicit, typed ops. + +## API surface (WLED `seg.i`) + +1. **Sequential from 0:** `{"i":["FF0000","00FF00","0000FF"]}` (or RGB arrays). +2. **Indexed:** `{"i":[0,"FF0000",2,"00FF00",4,"0000FF"]}` (index, color, ...). +3. **Ranges:** `{"i":[0,8,"FF0000",10,18,"0000FF"]}` (start, stop, color, ...). + +Rules from the docs we must encode/enforce: +- Indices are **segment-relative**, not strip-global. +- Hex is preferred over arrays for large payloads (efficiency). +- Must send ≤ ~256 colors per request; **split** larger sets into sequential calls + (never parallel — the device serializes poorly). +- Setting LEDs **freezes** the segment; brightness must be set *beforehand* (turning on + and setting LEDs in the same request does **not** work). + +## What we build — one typed builder, three intents + +```csharp +public sealed class IndividualLedBuilder +{ + IndividualLedBuilder Set(params Color[] sequentialFromStart); // form 1 + IndividualLedBuilder Set(int index, Color color); // form 2 + IndividualLedBuilder SetRange(int start, int stopExclusive, Color color); // form 3 +} +``` + +Client method: + +```csharp +Task SetIndividualLeds(int segmentId, Action build, + CancellationToken ct = default); +``` + +### Safety / correctness baked in + +- The builder accumulates ops, then the converter emits the **most compact** legal `i` + array (prefers hex, collapses consecutive single sets into a range where shorter). +- **Auto-chunking:** if the resulting color count exceeds the safe limit (configurable, + default 256), the client transparently issues **sequential** requests (awaiting each) + using the `[startIndex, ...]` form — implementing the docs' guidance for the caller. +- A guard rejects negative indices and `stop <= start` at build time. +- Doc-comment + analyzer-style guidance: brightness/power-on must precede LED setting; + optionally expose `SetIndividualLeds(..., ensureBrightness: byte?)` that pre-sends a + brightness state in a prior request when requested. + +### Reading + +`i` is *not* part of the state response (write-only per docs), so there is no read model; +current colors come from `/json/live` (Plan 5) instead. Document this explicitly. + +## Files + +- `src/Kevsoft.WLED/IndividualLedBuilder.cs` +- `src/Kevsoft.WLED/Json/IndividualLedConverter.cs` +- `IWLedClient` + `WLedClient`: `SetIndividualLeds` + +## Tests + +- Sequential / indexed / range forms each emit the exact documented array shape. +- Hex chosen over RGB arrays for compactness; RGBW falls back to arrays. +- Auto-chunking: a 600-LED set produces 3 sequential requests with correct start offsets, + issued in order (assert call sequence on `MockHttpMessageHandler`). +- Build-time guards: negative index, `stop <= start` throw. + +## Acceptance + +Individual-LED addressing is expressed through one builder with three clear intents; the +library owns encoding choice and the sequential-chunking rule, so a caller can light +thousands of LEDs correctly without knowing the wire format or the 256-color limit. diff --git a/plans/9-effect-metadata.md b/plans/9-effect-metadata.md new file mode 100644 index 0000000..20b8077 --- /dev/null +++ b/plans/9-effect-metadata.md @@ -0,0 +1,94 @@ +# Plan 9 — Effect metadata (`/json/fxdata`) + +**Theme:** Feature · **Depends on:** Plans 1, 5 + +## Why + +Since 0.14, `GET /json/fxdata` returns, per effect, a compact metadata string describing +which controls that effect actually uses (sliders, color slots, palette, flags, defaults). +This is what lets a UI hide irrelevant controls and what lets *us* validate that a caller +isn't setting, say, `c3` on an effect that ignores it. Today this is unexposed and the raw +string format is genuinely fiddly — perfect to parse once, correctly, behind a type. + +## API surface (WLED) + +`/json/fxdata` → array of strings aligned by index with the effects list. Format: + +``` +;;;; +``` + +Example (Aurora): `!,!;;!;1;sx=24,pal=50` + +- **parameters:** up to 5 sliders (`sx,ix,c1,c2,c3`) + 3 checkboxes (`o1,o2,o3`), + comma-separated labels; `!` = default label; empty = control hidden. +- **colors:** up to 3 slots (`Fx`,`Bg`,`Cs`); `!` = default label; empty = hidden. +- **palette:** `!` = palette selection enabled; empty = no palette. +- **flags:** single chars — `1`=1D, `2`=2D matrix, `3`=3D, `v`=volume-reactive, + `f`=frequency-reactive, `0`=single-LED. +- **defaults:** `name=value` pairs, e.g. `sx=24,pal=50`. + +Fallbacks for missing sections are documented (2 sliders; 3 colors; palette on; flag `1`). + +## What we build — parse the format into a rich, queryable type + +```csharp +public sealed record EffectMetadata( + int EffectId, + string Name, + IReadOnlyList Sliders, // sx, ix, c1, c2, c3 (with label, range) + IReadOnlyList Options, // o1, o2, o3 checkboxes + IReadOnlyList Colors, // Fx / Bg / Cs (visible ones) + bool UsesPalette, + EffectDimensionality Dimensionality, // OneD / TwoD / ThreeD / SingleLed + bool ReactsToVolume, + bool ReactsToFrequency, + IReadOnlyDictionary Defaults); + +[Flags] public enum EffectCapabilities { ... } // optional convenience overlay +``` + +`EffectControl` records its parameter key (`c3`), label, and documented value range +(so `c3` knows it's 0–31). The parser applies all documented fallbacks. + +### Client methods + +```csharp +Task> GetEffectMetadata(CancellationToken ct = default); +``` + +Pair it with the effect names (`GetEffects`) to populate `Name`, and **filter out +`RSVD`/`-` reserved effects** (docs recommend hiding them; `frenck` drops `RSVD`). + +### The ergonomic payoff (optional, powerful) + +Expose validation helpers that make misuse visible: + +```csharp +metadata.Supports(SegmentControl.Custom3); // false → setting c3 is a no-op +segmentUpdate.ValidateAgainst(metadata); // warn/throw if setting hidden controls +``` + +This turns effect metadata from documentation into an *enforced contract*. + +## Files + +- `src/Kevsoft.WLED/{EffectMetadata,EffectControl,EffectColorSlot}.cs` +- `src/Kevsoft.WLED/EffectDimensionality.cs` +- `src/Kevsoft.WLED/Json/EffectMetadataParser.cs` +- `IWLedClient` + `WLedClient`: `GetEffectMetadata` + +## Tests + +- Parse documented examples: `!,!;;!;1;sx=24,pal=50` (Aurora), `2v` flags, empty + sections, `,Saturation,,,,Invert`, `,,,,,Random colors`. +- All fallbacks (missing parameters → 2 sliders; missing colors → 3; missing palette → + on; missing flags → 1D). +- Reserved effects filtered; indices stay aligned with the effects list. +- `Defaults` parsed to `{ sx:24, pal:50 }`. + +## Acceptance + +Effect metadata is available as a fully-parsed, queryable model (controls, ranges, flags, +defaults), reserved effects are filtered, and optional validation lets callers avoid +setting controls an effect ignores. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..43e22f5 --- /dev/null +++ b/plans/README.md @@ -0,0 +1,103 @@ +# WLED.NET Roadmap — Full, Ergonomic JSON API Coverage + +This folder contains a set of feature plans that evolve **WLED.NET** from a partial, +field-for-field wrapper into a *complete* and *hard-to-misuse* .NET client for the +[WLED JSON API](https://kno.wled.ge/interfaces/json-api/). + +## Guiding principle: make wrong states unrepresentable + +> "Model things well so they're easy to use. Not just a mapping of random fields — +> make it impossible to send the wrong information by how we model the library." + +Every plan below is held to that bar. Concretely, that means: + +- **Types over primitives.** No raw `int[][]` colors, no `byte` enums-by-convention, + no magic strings. We introduce value types (`RgbColor`, `RgbwColor`, `SegmentColors`), + enums (`NightlightMode`, `LiveDataOverride`, `LightCapability` `[Flags]`, + `SyncGroup` `[Flags]`, `SoundSimulation`, `Expand1D`), and command types that can + only hold valid values. +- **Command-value safety.** Fields that accept the WLED "toggle/increment/decrement/ + random" mini-language (`"t"`, `~`, `~-`, `~10`, `"r"`, `"1~6~"`) get dedicated types + (e.g. `Toggleable`, `Adjust`, `EffectSelector`) so a caller *cannot* hand-write an + invalid command string. +- **Builders, not nullable bags.** High-level fluent builders (`StateUpdate`, + `SegmentUpdate`) replace manually newing up DTOs full of nullable properties. + The raw `Request`/`Response` DTOs remain as the serialization layer underneath. +- **Validation at the boundary of construction.** Where the API has documented ranges + (e.g. `c3` is 0–31, `bri` is 0–255, `cct` is 0–255 or 1900–10091), the constructing + type guards the range so an out-of-range value throws *before* it ever hits the wire. +- **Read model ≠ write model.** Responses are immutable and total; requests/builders + expose only what is actually settable. + +## Where the library is today + +Supported: `GET /json`, `/json/state`, `/json/info`, `/json/eff`, `/json/pal`; +`POST /json`, `/json/state`. The mapped objects cover only a subset of fields, expose +raw primitives (`int[][]` colors, `byte` "enums", `int` preset/playlist ids), and offer +no presets, playlists, per-LED control, effect metadata, discovery, or config support. + +See each plan for the precise gap it closes. + +## Reference implementations + +These plans cross-reference two mature community libraries (linked by the WLED docs): + +- **[paul-fornage/wled-json-api-library](https://github.com/paul-fornage/wled-json-api-library)** + (Rust) — the most complete, documented JSON structure, including `/json/cfg`. +- **[frenck/python-wled](https://github.com/frenck/python-wled)** — excellent + ergonomics: enums, a hex-parsing `Color` type, segments keyed by id, presets and + playlists split apart, and a single `Device` aggregate. + +## Plans (suggested execution order) + +| # | Plan | Theme | +|---|------|-------| +| 0 | [Update target frameworks (net8/9/10)](0-update-target-frameworks.md) | Foundation | +| 1 | [Core value types & enums](1-core-value-types-and-enums.md) | Foundation | +| 2 | [Command-value model & fluent builders](2-command-values-and-builders.md) | Foundation | +| 3 | [Complete the State object](3-complete-state-object.md) | Data model | +| 4 | [Complete the Segment object](4-complete-segment-object.md) | Data model | +| 5 | [Complete Info & new read endpoints (si/net/nodes/live)](5-complete-info-and-read-endpoints.md) | Read APIs | +| 6 | [Presets API](6-presets-api.md) | Feature | +| 7 | [Playlists API](7-playlists-api.md) | Feature | +| 8 | [Per-segment individual LED control](8-individual-led-control.md) | Feature | +| 9 | [Effect metadata (`/json/fxdata`)](9-effect-metadata.md) | Feature | +| 10 | [Configuration API (`/json/cfg`)](10-config-api.md) | Feature | +| 11 | [Client ergonomics & cross-cutting concerns](11-client-ergonomics-and-cross-cutting.md) | Quality | + +Plan 0 modernises the toolchain (multi-targeting `netstandard2.0;net8.0;net9.0;net10.0`) +and should land first. Plans 1–2 are the ergonomic foundation and unblock everything else. Plans 3–10 add +discrete capabilities. Plan 11 (high-level helpers, `CancellationToken`, DI, errors, +multi-targeting, versioning) runs alongside all of them. + +## Shared conventions for every plan + +- Keep the existing dual-DTO serialization layer: immutable `XResponse` (total, + non-null) + mutable `XRequest` (nullable, `[JsonIgnore(WhenWritingNull)]`) with + `Request.From(Response)` + implicit operator. Builders and value types sit *on top* + of this layer; the wire format never leaks into the public happy path. +- `[JsonPropertyName]` always carries the raw WLED key; the C# member uses the + descriptive .NET name. +- Every new endpoint adds a method to `IWLedClient` + `WLedClient`, with GET/POST tests + in `Kevsoft.WLED.Tests` (extend `JsonBuilder`, `MockHttpMessageHandler`). +- Custom `JsonConverter`s carry the "impossible to misuse" types across the wire; they + are unit-tested in both directions against real WLED sample payloads. +- Backwards compatibility: where a public member changes shape (e.g. `int[][]` → + `SegmentColors`), provide an `[Obsolete]` shim for one minor version (see Plan 11). + +## Definition of Done — applies to **every** plan + +A feature isn't finished when the code compiles and tests pass. Each plan must also: + +1. **Update the root [`README.md`](../README.md)** — keep a "Supported features" / + capability matrix current, and add or refresh a short usage snippet for the new + capability. The README is the project's shop window; a feature that isn't documented + there is effectively invisible to consumers. +2. **Keep [`samples/BasicConsole`](../samples/BasicConsole) exemplary** — extend the + sample (or add a focused sample) so the new capability has a copy-pasteable, runnable + example that demonstrates the *ergonomic* path (builders / intent methods), not the + raw DTOs. The sample should always build and run against the latest API. +3. **Update `CHANGELOG.md`** (introduced in Plan 11) with the user-facing change. + +Treat items 1 and 2 as acceptance criteria for the plan — reviewers should reject a +change that adds a feature without updating the README and the sample. From bd51e94560b7707692adb9a70e72e78d1417b525 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 22:34:23 +0100 Subject: [PATCH 02/32] Plan 0: multi-target net8/9/10 and modernise toolchain --- Directory.Build.props | 3 +++ Dockerfile | 2 +- samples/BasicConsole/BasicConsole.csproj | 2 +- src/Kevsoft.WLED/Kevsoft.WLED.csproj | 4 ++-- src/Kevsoft.WLED/StringContentWithoutCharset.cs | 2 +- test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj | 14 +++++++------- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e189be4..f21f371 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,6 +4,9 @@ Copyright 2020 Kevsoft Kevin Smith latest + netstandard2.0;net8.0;net9.0;net10.0 + net8.0;net9.0;net10.0 + net10.0 enable enable true diff --git a/Dockerfile b/Dockerfile index 036be08..835cdd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ ARG VERSION=0.0.0 -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS restore +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS restore WORKDIR / COPY ./nuget.config . diff --git a/samples/BasicConsole/BasicConsole.csproj b/samples/BasicConsole/BasicConsole.csproj index 7a0fe29..3c24c9f 100644 --- a/samples/BasicConsole/BasicConsole.csproj +++ b/samples/BasicConsole/BasicConsole.csproj @@ -6,7 +6,7 @@ Exe - net6.0 + $(SampleTargetFramework) enable enable false diff --git a/src/Kevsoft.WLED/Kevsoft.WLED.csproj b/src/Kevsoft.WLED/Kevsoft.WLED.csproj index e8c171a..d653112 100644 --- a/src/Kevsoft.WLED/Kevsoft.WLED.csproj +++ b/src/Kevsoft.WLED/Kevsoft.WLED.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + $(LibraryTargetFrameworks) @@ -22,7 +22,7 @@ - + diff --git a/src/Kevsoft.WLED/StringContentWithoutCharset.cs b/src/Kevsoft.WLED/StringContentWithoutCharset.cs index 33f9142..03cd6bb 100644 --- a/src/Kevsoft.WLED/StringContentWithoutCharset.cs +++ b/src/Kevsoft.WLED/StringContentWithoutCharset.cs @@ -9,6 +9,6 @@ internal sealed class StringContentWithoutCharset : StringContent /// public StringContentWithoutCharset(string content, string mediaType) : base(content, Encoding.UTF8, mediaType) { - Headers.ContentType.CharSet = ""; + Headers.ContentType!.CharSet = ""; } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj index 2916911..fa3487f 100644 --- a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj +++ b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj @@ -1,18 +1,18 @@ - net6.0 + $(TestTargetFrameworks) false - - - - - - + + + + + + From 60d5fe61d4b6cb79c1440685c443b9b93bb60ab4 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 22:38:41 +0100 Subject: [PATCH 03/32] Plan 1: core value types and enums --- src/Kevsoft.WLED/Color.cs | 77 +++++++++++++++ src/Kevsoft.WLED/ColorJsonConverter.cs | 54 +++++++++++ src/Kevsoft.WLED/Expand1D.cs | 19 ++++ src/Kevsoft.WLED/HexColor.cs | 43 +++++++++ src/Kevsoft.WLED/IsExternalInit.cs | 13 +++ src/Kevsoft.WLED/LightCapability.cs | 23 +++++ src/Kevsoft.WLED/LiveDataOverride.cs | 16 ++++ src/Kevsoft.WLED/NightlightMode.cs | 19 ++++ src/Kevsoft.WLED/RgbColor.cs | 28 ++++++ src/Kevsoft.WLED/RgbwColor.cs | 28 ++++++ src/Kevsoft.WLED/SegmentColors.cs | 41 ++++++++ .../SegmentColorsJsonConverter.cs | 36 +++++++ src/Kevsoft.WLED/SoundSimulation.cs | 19 ++++ src/Kevsoft.WLED/SyncGroup.cs | 19 ++++ test/Kevsoft.WLED.Tests/ColorJsonTests.cs | 75 +++++++++++++++ test/Kevsoft.WLED.Tests/ColorTests.cs | 94 +++++++++++++++++++ .../EnumSerializationTests.cs | 44 +++++++++ 17 files changed, 648 insertions(+) create mode 100644 src/Kevsoft.WLED/Color.cs create mode 100644 src/Kevsoft.WLED/ColorJsonConverter.cs create mode 100644 src/Kevsoft.WLED/Expand1D.cs create mode 100644 src/Kevsoft.WLED/HexColor.cs create mode 100644 src/Kevsoft.WLED/IsExternalInit.cs create mode 100644 src/Kevsoft.WLED/LightCapability.cs create mode 100644 src/Kevsoft.WLED/LiveDataOverride.cs create mode 100644 src/Kevsoft.WLED/NightlightMode.cs create mode 100644 src/Kevsoft.WLED/RgbColor.cs create mode 100644 src/Kevsoft.WLED/RgbwColor.cs create mode 100644 src/Kevsoft.WLED/SegmentColors.cs create mode 100644 src/Kevsoft.WLED/SegmentColorsJsonConverter.cs create mode 100644 src/Kevsoft.WLED/SoundSimulation.cs create mode 100644 src/Kevsoft.WLED/SyncGroup.cs create mode 100644 test/Kevsoft.WLED.Tests/ColorJsonTests.cs create mode 100644 test/Kevsoft.WLED.Tests/ColorTests.cs create mode 100644 test/Kevsoft.WLED.Tests/EnumSerializationTests.cs diff --git a/src/Kevsoft.WLED/Color.cs b/src/Kevsoft.WLED/Color.cs new file mode 100644 index 0000000..d651fb2 --- /dev/null +++ b/src/Kevsoft.WLED/Color.cs @@ -0,0 +1,77 @@ +namespace Kevsoft.WLED; + +/// +/// A single WLED color slot, which is either an RGB or an RGBW color. +/// +/// +/// WLED represents a color as an array of 3 (RGB) or 4 (RGBW) bytes, or as a hex string. +/// This type unifies those representations so an invalid color cannot be constructed. +/// +[JsonConverter(typeof(ColorJsonConverter))] +public readonly struct Color : IEquatable +{ + private Color(byte r, byte g, byte b, byte? w) + { + R = r; + G = g; + B = b; + W = w; + } + + /// Red channel. + public byte R { get; } + + /// Green channel. + public byte G { get; } + + /// Blue channel. + public byte B { get; } + + /// White channel, or null for an RGB color. + public byte? W { get; } + + /// true if this color has a dedicated white channel. + public bool IsRgbw => W.HasValue; + + /// Creates an RGB color. + public static Color Rgb(byte r, byte g, byte b) => new(r, g, b, null); + + /// Creates an RGBW color. + public static Color Rgbw(byte r, byte g, byte b, byte w) => new(r, g, b, w); + + /// Parses a 6 (RGB) or 8 (RGBW) digit hex color, with an optional leading #. + public static Color FromHex(string hex) + { + var (r, g, b, w) = HexColor.Parse(hex); + return new Color(r, g, b, w); + } + + /// Returns the color as an upper-case hex string (6 or 8 digits, no leading #). + public string ToHex() => HexColor.Format(R, G, B, W); + + /// The color components as a 3 or 4 element byte array, matching the WLED wire format. + public byte[] ToBytes() => IsRgbw ? new[] { R, G, B, W!.Value } : new[] { R, G, B }; + + public bool Equals(Color other) => R == other.R && G == other.G && B == other.B && W == other.W; + + public override bool Equals(object? obj) => obj is Color other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + R; + hash = (hash * 31) + G; + hash = (hash * 31) + B; + hash = (hash * 31) + (W ?? -1); + return hash; + } + } + + public override string ToString() => ToHex(); + + public static bool operator ==(Color left, Color right) => left.Equals(right); + + public static bool operator !=(Color left, Color right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/ColorJsonConverter.cs b/src/Kevsoft.WLED/ColorJsonConverter.cs new file mode 100644 index 0000000..faa5a8e --- /dev/null +++ b/src/Kevsoft.WLED/ColorJsonConverter.cs @@ -0,0 +1,54 @@ +namespace Kevsoft.WLED; + +/// +/// Reads a WLED color, which may be a 3/4 element numeric array ([255,170,0]) or a +/// hex string ("FFAA00"), and always writes it as a numeric array. +/// +public sealed class ColorJsonConverter : JsonConverter +{ + public override Color Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + return Color.FromHex(reader.GetString()!); + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a color."); + } + + Span components = stackalloc byte[4]; + var count = 0; + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + if (count >= 4) + { + throw new JsonException("A color may contain at most 4 components."); + } + + components[count++] = reader.GetByte(); + } + + return count switch + { + 3 => Color.Rgb(components[0], components[1], components[2]), + 4 => Color.Rgbw(components[0], components[1], components[2], components[3]), + _ => throw new JsonException($"A color must contain 3 (RGB) or 4 (RGBW) components but had {count}."), + }; + } + + public override void Write(Utf8JsonWriter writer, Color value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + writer.WriteNumberValue(value.R); + writer.WriteNumberValue(value.G); + writer.WriteNumberValue(value.B); + if (value.IsRgbw) + { + writer.WriteNumberValue(value.W!.Value); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Kevsoft.WLED/Expand1D.cs b/src/Kevsoft.WLED/Expand1D.cs new file mode 100644 index 0000000..7183739 --- /dev/null +++ b/src/Kevsoft.WLED/Expand1D.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// How a 1D effect is expanded onto a 2D matrix. +/// +public enum Expand1D : byte +{ + /// Map pixel-for-pixel. + Pixels = 0, + + /// Expand as a bar. + Bar = 1, + + /// Expand as an arc. + Arc = 2, + + /// Expand from a corner. + Corner = 3, +} diff --git a/src/Kevsoft.WLED/HexColor.cs b/src/Kevsoft.WLED/HexColor.cs new file mode 100644 index 0000000..ea48b00 --- /dev/null +++ b/src/Kevsoft.WLED/HexColor.cs @@ -0,0 +1,43 @@ +namespace Kevsoft.WLED; + +internal static class HexColor +{ + public static (byte R, byte G, byte B, byte? W) Parse(string hex) + { + if (hex is null) + { + throw new ArgumentNullException(nameof(hex)); + } + + var value = hex.StartsWith("#", StringComparison.Ordinal) ? hex.Substring(1) : hex; + + if (value.Length != 6 && value.Length != 8) + { + throw new FormatException($"'{hex}' is not a valid hex color; expected 6 (RGB) or 8 (RGBW) hex digits."); + } + + var r = ParseByte(hex, value, 0); + var g = ParseByte(hex, value, 2); + var b = ParseByte(hex, value, 4); + byte? w = value.Length == 8 ? ParseByte(hex, value, 6) : null; + + return (r, g, b, w); + } + + public static string Format(byte r, byte g, byte b, byte? w) + { + var rgb = $"{r:X2}{g:X2}{b:X2}"; + return w.HasValue ? rgb + w.Value.ToString("X2") : rgb; + } + + private static byte ParseByte(string original, string value, int index) + { + var pair = value.Substring(index, 2); + if (!byte.TryParse(pair, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var result)) + { + throw new FormatException($"'{original}' contains invalid hex digits ('{pair}')."); + } + + return result; + } +} diff --git a/src/Kevsoft.WLED/IsExternalInit.cs b/src/Kevsoft.WLED/IsExternalInit.cs new file mode 100644 index 0000000..0dcdfc6 --- /dev/null +++ b/src/Kevsoft.WLED/IsExternalInit.cs @@ -0,0 +1,13 @@ +#if NETSTANDARD2_0 +namespace System.Runtime.CompilerServices; + +using System.ComponentModel; + +/// +/// Polyfill that enables init-only setters and records on netstandard2.0. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit +{ +} +#endif diff --git a/src/Kevsoft.WLED/LightCapability.cs b/src/Kevsoft.WLED/LightCapability.cs new file mode 100644 index 0000000..2608bde --- /dev/null +++ b/src/Kevsoft.WLED/LightCapability.cs @@ -0,0 +1,23 @@ +namespace Kevsoft.WLED; + +/// +/// The colour capabilities a light/segment supports, as a bitfield. +/// +[Flags] +public enum LightCapability : byte +{ + /// No special capabilities. + None = 0, + + /// Supports RGB colour. + Rgb = 1, + + /// Has a dedicated white channel. + WhiteChannel = 2, + + /// Supports colour temperature (CCT). + ColorTemperature = 4, + + /// White channel can be controlled manually. + ManualWhite = 8, +} diff --git a/src/Kevsoft.WLED/LiveDataOverride.cs b/src/Kevsoft.WLED/LiveDataOverride.cs new file mode 100644 index 0000000..2dc9380 --- /dev/null +++ b/src/Kevsoft.WLED/LiveDataOverride.cs @@ -0,0 +1,16 @@ +namespace Kevsoft.WLED; + +/// +/// Controls how incoming realtime / live data overrides the normal effect output. +/// +public enum LiveDataOverride : byte +{ + /// Live data is shown while it is being received. + Off = 0, + + /// Ignore live data until the live source stops sending. + UntilLiveEnds = 1, + + /// Ignore live data until the device reboots. + UntilReboot = 2, +} diff --git a/src/Kevsoft.WLED/NightlightMode.cs b/src/Kevsoft.WLED/NightlightMode.cs new file mode 100644 index 0000000..5462188 --- /dev/null +++ b/src/Kevsoft.WLED/NightlightMode.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// How the nightlight fades the light over its duration. +/// +public enum NightlightMode : byte +{ + /// Instantly switch to the target brightness at the end. + Instant = 0, + + /// Linearly fade to the target brightness. + Fade = 1, + + /// Fade following a colour-temperature curve. + ColorFade = 2, + + /// Sunrise/sunset simulation. + Sunrise = 3, +} diff --git a/src/Kevsoft.WLED/RgbColor.cs b/src/Kevsoft.WLED/RgbColor.cs new file mode 100644 index 0000000..344535e --- /dev/null +++ b/src/Kevsoft.WLED/RgbColor.cs @@ -0,0 +1,28 @@ +namespace Kevsoft.WLED; + +/// +/// A 24-bit RGB color. +/// +public readonly record struct RgbColor(byte R, byte G, byte B) +{ + /// + /// Parses a hex color such as "FFAA00" or "#FFAA00". + /// + public static RgbColor FromHex(string hex) + { + var (r, g, b, w) = HexColor.Parse(hex); + if (w.HasValue) + { + throw new FormatException($"'{hex}' is an RGBW hex value; use {nameof(RgbwColor)}.{nameof(RgbwColor.FromHex)} instead."); + } + + return new RgbColor(r, g, b); + } + + /// + /// Returns the color as an upper-case 6 digit hex string (no leading #). + /// + public string ToHex() => HexColor.Format(R, G, B, null); + + public static implicit operator Color(RgbColor color) => Color.Rgb(color.R, color.G, color.B); +} diff --git a/src/Kevsoft.WLED/RgbwColor.cs b/src/Kevsoft.WLED/RgbwColor.cs new file mode 100644 index 0000000..ed5caac --- /dev/null +++ b/src/Kevsoft.WLED/RgbwColor.cs @@ -0,0 +1,28 @@ +namespace Kevsoft.WLED; + +/// +/// A 32-bit RGBW color (RGB plus a dedicated white channel). +/// +public readonly record struct RgbwColor(byte R, byte G, byte B, byte W) +{ + /// + /// Parses an 8 digit hex color such as "FFAA0040" or "#FFAA0040". + /// + public static RgbwColor FromHex(string hex) + { + var (r, g, b, w) = HexColor.Parse(hex); + if (!w.HasValue) + { + throw new FormatException($"'{hex}' is an RGB hex value; use {nameof(RgbColor)}.{nameof(RgbColor.FromHex)} instead."); + } + + return new RgbwColor(r, g, b, w.Value); + } + + /// + /// Returns the color as an upper-case 8 digit hex string (no leading #). + /// + public string ToHex() => HexColor.Format(R, G, B, W); + + public static implicit operator Color(RgbwColor color) => Color.Rgbw(color.R, color.G, color.B, color.W); +} diff --git a/src/Kevsoft.WLED/SegmentColors.cs b/src/Kevsoft.WLED/SegmentColors.cs new file mode 100644 index 0000000..9f7a65d --- /dev/null +++ b/src/Kevsoft.WLED/SegmentColors.cs @@ -0,0 +1,41 @@ +namespace Kevsoft.WLED; + +/// +/// The primary, secondary (background) and tertiary color slots of a segment. +/// +[JsonConverter(typeof(SegmentColorsJsonConverter))] +public sealed record SegmentColors(Color Primary, Color? Secondary = null, Color? Tertiary = null) +{ + /// The color slots in wire order, omitting trailing unset slots. + public IReadOnlyList Slots + { + get + { + var slots = new List { Primary }; + if (Secondary.HasValue) + { + slots.Add(Secondary.Value); + if (Tertiary.HasValue) + { + slots.Add(Tertiary.Value); + } + } + + return slots; + } + } + + /// Builds a from up to three color slots. + public static SegmentColors FromSlots(IReadOnlyList slots) + { + if (slots is null || slots.Count == 0) + { + throw new ArgumentException("At least a primary color is required.", nameof(slots)); + } + + return new SegmentColors( + slots[0], + slots.Count > 1 ? slots[1] : null, + slots.Count > 2 ? slots[2] : null); + } +} diff --git a/src/Kevsoft.WLED/SegmentColorsJsonConverter.cs b/src/Kevsoft.WLED/SegmentColorsJsonConverter.cs new file mode 100644 index 0000000..715b88b --- /dev/null +++ b/src/Kevsoft.WLED/SegmentColorsJsonConverter.cs @@ -0,0 +1,36 @@ +namespace Kevsoft.WLED; + +/// +/// Reads and writes a segment's col array, which holds up to three color slots. +/// +public sealed class SegmentColorsJsonConverter : JsonConverter +{ + private static readonly ColorJsonConverter ColorConverter = new(); + + public override SegmentColors Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading segment colors."); + } + + var slots = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + slots.Add(ColorConverter.Read(ref reader, typeof(Color), options)); + } + + return SegmentColors.FromSlots(slots); + } + + public override void Write(Utf8JsonWriter writer, SegmentColors value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var color in value.Slots) + { + ColorConverter.Write(writer, color, options); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Kevsoft.WLED/SoundSimulation.cs b/src/Kevsoft.WLED/SoundSimulation.cs new file mode 100644 index 0000000..abc9edc --- /dev/null +++ b/src/Kevsoft.WLED/SoundSimulation.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// The built-in audio simulation used when no real sound input is available. +/// +public enum SoundSimulation : byte +{ + /// Beat-synced sine wave. + BeatSin = 0, + + /// "We Will Rock You" rhythm. + WeWillRockYou = 1, + + /// Simulation mode 10_3. + Mode10_3 = 2, + + /// Simulation mode 14_3. + Mode14_3 = 3, +} diff --git a/src/Kevsoft.WLED/SyncGroup.cs b/src/Kevsoft.WLED/SyncGroup.cs new file mode 100644 index 0000000..48bfa68 --- /dev/null +++ b/src/Kevsoft.WLED/SyncGroup.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// The sync groups a segment belongs to, as a bitfield (groups 1–8). +/// +[Flags] +public enum SyncGroup : byte +{ + /// No groups. + None = 0, + Group1 = 1, + Group2 = 2, + Group3 = 4, + Group4 = 8, + Group5 = 16, + Group6 = 32, + Group7 = 64, + Group8 = 128, +} diff --git a/test/Kevsoft.WLED.Tests/ColorJsonTests.cs b/test/Kevsoft.WLED.Tests/ColorJsonTests.cs new file mode 100644 index 0000000..338b721 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ColorJsonTests.cs @@ -0,0 +1,75 @@ +namespace Kevsoft.WLED.Tests; + +public class ColorJsonTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Fact] + public void ReadsThreeByteArray() + { + var color = JsonSerializer.Deserialize("[255,170,0]", Options); + + color.Should().Be(Color.Rgb(255, 170, 0)); + } + + [Fact] + public void ReadsFourByteArray() + { + var color = JsonSerializer.Deserialize("[255,170,0,64]", Options); + + color.Should().Be(Color.Rgbw(255, 170, 0, 64)); + } + + [Fact] + public void ReadsHexString() + { + var color = JsonSerializer.Deserialize("\"FFAA00\"", Options); + + color.Should().Be(Color.Rgb(255, 170, 0)); + } + + [Fact] + public void WritesRgbAsArray() + { + var json = JsonSerializer.Serialize(Color.Rgb(255, 170, 0), Options); + + json.Should().Be("[255,170,0]"); + } + + [Fact] + public void WritesRgbwAsArray() + { + var json = JsonSerializer.Serialize(Color.Rgbw(255, 170, 0, 64), Options); + + json.Should().Be("[255,170,0,64]"); + } + + [Fact] + public void SegmentColorsReadsMixedSlots() + { + var colors = JsonSerializer.Deserialize("[[255,170,0],\"00FF00\",[0,0,0,128]]", Options); + + colors.Should().Be(new SegmentColors( + Color.Rgb(255, 170, 0), + Color.Rgb(0, 255, 0), + Color.Rgbw(0, 0, 0, 128))); + } + + [Fact] + public void SegmentColorsRoundTrips() + { + var colors = new SegmentColors(Color.Rgb(1, 2, 3), Color.Rgb(4, 5, 6)); + + var json = JsonSerializer.Serialize(colors, Options); + + json.Should().Be("[[1,2,3],[4,5,6]]"); + } + + [Fact] + public void SegmentColorsPrimaryOnlyOmitsTrailingSlots() + { + var colors = new SegmentColors(Color.Rgb(1, 2, 3)); + + JsonSerializer.Serialize(colors, Options).Should().Be("[[1,2,3]]"); + } +} diff --git a/test/Kevsoft.WLED.Tests/ColorTests.cs b/test/Kevsoft.WLED.Tests/ColorTests.cs new file mode 100644 index 0000000..663bc73 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ColorTests.cs @@ -0,0 +1,94 @@ +namespace Kevsoft.WLED.Tests; + +public class ColorTests +{ + [Theory] + [InlineData("FFAA00", 255, 170, 0)] + [InlineData("#FFAA00", 255, 170, 0)] + [InlineData("ffaa00", 255, 170, 0)] + [InlineData("000000", 0, 0, 0)] + public void RgbColorParsesHex(string hex, byte r, byte g, byte b) + { + var color = RgbColor.FromHex(hex); + + color.Should().Be(new RgbColor(r, g, b)); + } + + [Fact] + public void RgbColorRoundTripsToHex() + { + new RgbColor(255, 170, 0).ToHex().Should().Be("FFAA00"); + } + + [Fact] + public void RgbwColorParsesAndRoundTrips() + { + var color = RgbwColor.FromHex("#FFAA0040"); + + color.Should().Be(new RgbwColor(255, 170, 0, 64)); + color.ToHex().Should().Be("FFAA0040"); + } + + [Fact] + public void RgbColorRejectsRgbwHex() + { + var act = () => RgbColor.FromHex("FFAA0040"); + + act.Should().Throw(); + } + + [Fact] + public void RgbwColorRejectsRgbHex() + { + var act = () => RgbwColor.FromHex("FFAA00"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("FFAA")] + [InlineData("GGAA00")] + [InlineData("")] + public void FromHexRejectsInvalidValues(string hex) + { + var act = () => Color.FromHex(hex); + + act.Should().Throw(); + } + + [Fact] + public void ColorRgbHasNoWhite() + { + var color = Color.Rgb(1, 2, 3); + + color.IsRgbw.Should().BeFalse(); + color.W.Should().BeNull(); + color.ToBytes().Should().Equal((byte)1, (byte)2, (byte)3); + } + + [Fact] + public void ColorRgbwHasWhite() + { + var color = Color.Rgbw(1, 2, 3, 4); + + color.IsRgbw.Should().BeTrue(); + color.W.Should().Be(4); + color.ToBytes().Should().Equal((byte)1, (byte)2, (byte)3, (byte)4); + } + + [Fact] + public void ImplicitConversionFromRgbColor() + { + Color color = new RgbColor(10, 20, 30); + + color.Should().Be(Color.Rgb(10, 20, 30)); + } + + [Fact] + public void ImplicitConversionFromRgbwColor() + { + Color color = new RgbwColor(10, 20, 30, 40); + + color.Should().Be(Color.Rgbw(10, 20, 30, 40)); + } +} diff --git a/test/Kevsoft.WLED.Tests/EnumSerializationTests.cs b/test/Kevsoft.WLED.Tests/EnumSerializationTests.cs new file mode 100644 index 0000000..a707d6c --- /dev/null +++ b/test/Kevsoft.WLED.Tests/EnumSerializationTests.cs @@ -0,0 +1,44 @@ +namespace Kevsoft.WLED.Tests; + +public class EnumSerializationTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Theory] + [InlineData(NightlightMode.Instant, 0)] + [InlineData(NightlightMode.Sunrise, 3)] + public void NightlightModeSerializesAsNumber(NightlightMode mode, int expected) + { + JsonSerializer.Serialize(mode, Options).Should().Be(expected.ToString()); + JsonSerializer.Deserialize(expected.ToString(), Options).Should().Be(mode); + } + + [Theory] + [InlineData(LiveDataOverride.Off, 0)] + [InlineData(LiveDataOverride.UntilLiveEnds, 1)] + [InlineData(LiveDataOverride.UntilReboot, 2)] + public void LiveDataOverrideSerializesAsNumber(LiveDataOverride value, int expected) + { + JsonSerializer.Serialize(value, Options).Should().Be(expected.ToString()); + JsonSerializer.Deserialize(expected.ToString(), Options).Should().Be(value); + } + + [Fact] + public void LightCapabilityFlagsRoundTrip() + { + var value = (LightCapability)7; + + value.Should().Be(LightCapability.Rgb | LightCapability.WhiteChannel | LightCapability.ColorTemperature); + JsonSerializer.Serialize(value, Options).Should().Be("7"); + JsonSerializer.Deserialize("7", Options).Should().Be(value); + } + + [Fact] + public void SyncGroupFlagsRoundTrip() + { + var value = SyncGroup.Group1 | SyncGroup.Group8; + + JsonSerializer.Serialize(value, Options).Should().Be("129"); + JsonSerializer.Deserialize("129", Options).Should().Be(value); + } +} From 30d605c884ef189b81d23355e202322b00749f70 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 22:56:26 +0100 Subject: [PATCH 04/32] Plan 2: command-value types and fluent state/segment builders --- src/Kevsoft.WLED/Commands/ByteAdjust.cs | 63 +++++++++++ .../Commands/ByteAdjustJsonConverter.cs | 77 +++++++++++++ src/Kevsoft.WLED/Commands/PresetSelector.cs | 85 +++++++++++++++ .../Commands/PresetSelectorJsonConverter.cs | 54 ++++++++++ src/Kevsoft.WLED/Commands/Selector.cs | 60 +++++++++++ .../Commands/SelectorJsonConverter.cs | 38 +++++++ src/Kevsoft.WLED/Commands/Toggleable.cs | 47 ++++++++ .../Commands/ToggleableJsonConverter.cs | 38 +++++++ src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 54 ++++++++++ src/Kevsoft.WLED/Fluent/StateUpdate.cs | 101 ++++++++++++++++++ src/Kevsoft.WLED/IWLedClient.cs | 5 + src/Kevsoft.WLED/SegmentRequest.cs | 8 +- src/Kevsoft.WLED/StateRequest.cs | 16 ++- src/Kevsoft.WLED/WLedClient.cs | 12 +++ test/Kevsoft.WLED.Tests/CommandValueTests.cs | 75 +++++++++++++ .../StateUpdateBuilderTests.cs | 55 ++++++++++ 16 files changed, 780 insertions(+), 8 deletions(-) create mode 100644 src/Kevsoft.WLED/Commands/ByteAdjust.cs create mode 100644 src/Kevsoft.WLED/Commands/ByteAdjustJsonConverter.cs create mode 100644 src/Kevsoft.WLED/Commands/PresetSelector.cs create mode 100644 src/Kevsoft.WLED/Commands/PresetSelectorJsonConverter.cs create mode 100644 src/Kevsoft.WLED/Commands/Selector.cs create mode 100644 src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs create mode 100644 src/Kevsoft.WLED/Commands/Toggleable.cs create mode 100644 src/Kevsoft.WLED/Commands/ToggleableJsonConverter.cs create mode 100644 src/Kevsoft.WLED/Fluent/SegmentUpdate.cs create mode 100644 src/Kevsoft.WLED/Fluent/StateUpdate.cs create mode 100644 test/Kevsoft.WLED.Tests/CommandValueTests.cs create mode 100644 test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs diff --git a/src/Kevsoft.WLED/Commands/ByteAdjust.cs b/src/Kevsoft.WLED/Commands/ByteAdjust.cs new file mode 100644 index 0000000..96e8048 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/ByteAdjust.cs @@ -0,0 +1,63 @@ +namespace Kevsoft.WLED; + +/// +/// A 0–255 value that can be set absolutely, nudged up/down, or incremented with wrap, +/// matching WLED's acceptance of a number, "~"/"~-", "~N"/"~-N" +/// and "wN" tokens for brightness-style fields. +/// +[JsonConverter(typeof(ByteAdjustJsonConverter))] +public readonly struct ByteAdjust : IEquatable +{ + internal enum Operation : byte + { + Set, + Increment, + Decrement, + IncrementWrap, + } + + private ByteAdjust(Operation operation, byte value) + { + Op = operation; + Amount = value; + } + + internal Operation Op { get; } + + internal byte Amount { get; } + + /// Set the value absolutely. + public static ByteAdjust Set(byte value) => new(Operation.Set, value); + + /// Increase the value by (default 1). + public static ByteAdjust Increment(byte by = 1) => new(Operation.Increment, by); + + /// Decrease the value by (default 1). + public static ByteAdjust Decrement(byte by = 1) => new(Operation.Decrement, by); + + /// Increase the value by , wrapping around at the limit. + public static ByteAdjust IncrementWrap(byte by) => new(Operation.IncrementWrap, by); + + public static implicit operator ByteAdjust(byte value) => Set(value); + + internal string ToToken() => Op switch + { + Operation.Set => Amount.ToString(System.Globalization.CultureInfo.InvariantCulture), + Operation.Increment => Amount == 1 ? "~" : $"~{Amount}", + Operation.Decrement => Amount == 1 ? "~-" : $"~-{Amount}", + Operation.IncrementWrap => $"w~{Amount}", + _ => throw new InvalidOperationException(), + }; + + public bool Equals(ByteAdjust other) => Op == other.Op && Amount == other.Amount; + + public override bool Equals(object? obj) => obj is ByteAdjust other && Equals(other); + + public override int GetHashCode() => ((int)Op * 397) ^ Amount; + + public override string ToString() => ToToken(); + + public static bool operator ==(ByteAdjust left, ByteAdjust right) => left.Equals(right); + + public static bool operator !=(ByteAdjust left, ByteAdjust right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/Commands/ByteAdjustJsonConverter.cs b/src/Kevsoft.WLED/Commands/ByteAdjustJsonConverter.cs new file mode 100644 index 0000000..86088ed --- /dev/null +++ b/src/Kevsoft.WLED/Commands/ByteAdjustJsonConverter.cs @@ -0,0 +1,77 @@ +namespace Kevsoft.WLED; + +/// Serializes as a number or an adjustment token. +public sealed class ByteAdjustJsonConverter : JsonConverter +{ + public override ByteAdjust Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return ByteAdjust.Set(reader.GetByte()); + } + + if (reader.TokenType == JsonTokenType.String) + { + return ParseToken(reader.GetString()!); + } + + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a byte adjustment."); + } + + public override void Write(Utf8JsonWriter writer, ByteAdjust value, JsonSerializerOptions options) + { + if (value.Op == ByteAdjust.Operation.Set) + { + writer.WriteNumberValue(value.Amount); + } + else + { + writer.WriteStringValue(value.ToToken()); + } + } + + private static ByteAdjust ParseToken(string token) + { + if (token.StartsWith("w~", StringComparison.Ordinal)) + { + return ByteAdjust.IncrementWrap(ParseAmount(token, token.Substring(2), 0)); + } + + if (token == "~") + { + return ByteAdjust.Increment(); + } + + if (token == "~-") + { + return ByteAdjust.Decrement(); + } + + if (token.StartsWith("~-", StringComparison.Ordinal)) + { + return ByteAdjust.Decrement(ParseAmount(token, token.Substring(2), 1)); + } + + if (token.StartsWith("~", StringComparison.Ordinal)) + { + return ByteAdjust.Increment(ParseAmount(token, token.Substring(1), 1)); + } + + throw new JsonException($"'{token}' is not a valid byte adjustment token."); + } + + private static byte ParseAmount(string token, string text, byte fallback) + { + if (text.Length == 0) + { + return fallback; + } + + if (!byte.TryParse(text, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var amount)) + { + throw new JsonException($"'{token}' contains an invalid amount."); + } + + return amount; + } +} diff --git a/src/Kevsoft.WLED/Commands/PresetSelector.cs b/src/Kevsoft.WLED/Commands/PresetSelector.cs new file mode 100644 index 0000000..bbbddd5 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/PresetSelector.cs @@ -0,0 +1,85 @@ +namespace Kevsoft.WLED; + +/// +/// Selects a preset by id, by cycling through a range ("from~to~") or by choosing a +/// random preset within a range ("from~tor"). +/// +[JsonConverter(typeof(PresetSelectorJsonConverter))] +public readonly struct PresetSelector : IEquatable +{ + internal enum Kind : byte + { + Id, + Cycle, + RandomInRange, + } + + private PresetSelector(Kind kind, int from, int to) + { + Type = kind; + From = from; + To = to; + } + + internal Kind Type { get; } + + internal int From { get; } + + internal int To { get; } + + /// Select a specific preset id. + public static PresetSelector Id(int id) => new(Kind.Id, id, id); + + /// Cycle through presets from to . + public static PresetSelector Cycle(int from, int to) + { + EnsureRange(from, to); + return new(Kind.Cycle, from, to); + } + + /// Select a random preset between and . + public static PresetSelector RandomInRange(int from, int to) + { + EnsureRange(from, to); + return new(Kind.RandomInRange, from, to); + } + + public static implicit operator PresetSelector(int id) => Id(id); + + internal string ToToken() => Type switch + { + Kind.Id => From.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind.Cycle => $"{From}~{To}~", + Kind.RandomInRange => $"{From}~{To}r", + _ => throw new InvalidOperationException(), + }; + + private static void EnsureRange(int from, int to) + { + if (to < from) + { + throw new ArgumentException($"'to' ({to}) must be greater than or equal to 'from' ({from}).", nameof(to)); + } + } + + public bool Equals(PresetSelector other) => Type == other.Type && From == other.From && To == other.To; + + public override bool Equals(object? obj) => obj is PresetSelector other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = (int)Type; + hash = (hash * 397) ^ From; + hash = (hash * 397) ^ To; + return hash; + } + } + + public override string ToString() => ToToken(); + + public static bool operator ==(PresetSelector left, PresetSelector right) => left.Equals(right); + + public static bool operator !=(PresetSelector left, PresetSelector right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/Commands/PresetSelectorJsonConverter.cs b/src/Kevsoft.WLED/Commands/PresetSelectorJsonConverter.cs new file mode 100644 index 0000000..ceb3eb7 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/PresetSelectorJsonConverter.cs @@ -0,0 +1,54 @@ +namespace Kevsoft.WLED; + +/// Serializes as a number or a cycle/random token. +public sealed class PresetSelectorJsonConverter : JsonConverter +{ + public override PresetSelector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return PresetSelector.Id(reader.GetInt32()); + } + + if (reader.TokenType == JsonTokenType.String) + { + return ParseToken(reader.GetString()!); + } + + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a preset selector."); + } + + public override void Write(Utf8JsonWriter writer, PresetSelector value, JsonSerializerOptions options) + { + if (value.Type == PresetSelector.Kind.Id) + { + writer.WriteNumberValue(value.From); + } + else + { + writer.WriteStringValue(value.ToToken()); + } + } + + private static PresetSelector ParseToken(string token) + { + if (token.EndsWith("~", StringComparison.Ordinal)) + { + var parts = token.TrimEnd('~').Split('~'); + if (parts.Length == 2 && int.TryParse(parts[0], out var from) && int.TryParse(parts[1], out var to)) + { + return PresetSelector.Cycle(from, to); + } + } + else if (token.EndsWith("r", StringComparison.Ordinal)) + { + var parts = token.Substring(0, token.Length - 1).Split('~'); + if (parts.Length == 2 && int.TryParse(parts[0], out var from) && int.TryParse(parts[1], out var to)) + { + return PresetSelector.RandomInRange(from, to); + } + } + + throw new JsonException($"'{token}' is not a valid preset selector token."); + } +} diff --git a/src/Kevsoft.WLED/Commands/Selector.cs b/src/Kevsoft.WLED/Commands/Selector.cs new file mode 100644 index 0000000..1a9b81d --- /dev/null +++ b/src/Kevsoft.WLED/Commands/Selector.cs @@ -0,0 +1,60 @@ +namespace Kevsoft.WLED; + +/// +/// Selects an effect or palette by id, by relative movement ("~"/"~-") or +/// at random ("r"). +/// +[JsonConverter(typeof(SelectorJsonConverter))] +public readonly struct Selector : IEquatable +{ + internal enum Kind : byte + { + Id, + Next, + Previous, + Random, + } + + private Selector(Kind kind, int id) + { + Type = kind; + IdValue = id; + } + + internal Kind Type { get; } + + internal int IdValue { get; } + + /// Select a specific id. + public static Selector Id(int id) => new(Kind.Id, id); + + /// Select the next entry. + public static Selector Next => new(Kind.Next, 0); + + /// Select the previous entry. + public static Selector Previous => new(Kind.Previous, 0); + + /// Select a random entry. + public static Selector Random => new(Kind.Random, 0); + + public static implicit operator Selector(int id) => Id(id); + + public bool Equals(Selector other) => Type == other.Type && IdValue == other.IdValue; + + public override bool Equals(object? obj) => obj is Selector other && Equals(other); + + public override int GetHashCode() => ((int)Type * 397) ^ IdValue; + + public override string ToString() => Type switch + { + Kind.Id => IdValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind.Next => "~", + Kind.Previous => "~-", + Kind.Random => "r", + _ => throw new InvalidOperationException(), + }; + + public static bool operator ==(Selector left, Selector right) => left.Equals(right); + + public static bool operator !=(Selector left, Selector right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs b/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs new file mode 100644 index 0000000..f3b95d6 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs @@ -0,0 +1,38 @@ +namespace Kevsoft.WLED; + +/// Serializes as a number, "~", "~-" or "r". +public sealed class SelectorJsonConverter : JsonConverter +{ + public override Selector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number) + { + return Selector.Id(reader.GetInt32()); + } + + if (reader.TokenType == JsonTokenType.String) + { + return reader.GetString() switch + { + "~" => Selector.Next, + "~-" => Selector.Previous, + "r" => Selector.Random, + var value => throw new JsonException($"'{value}' is not a valid selector token."), + }; + } + + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a selector."); + } + + public override void Write(Utf8JsonWriter writer, Selector value, JsonSerializerOptions options) + { + if (value.Type == Selector.Kind.Id) + { + writer.WriteNumberValue(value.IdValue); + } + else + { + writer.WriteStringValue(value.ToString()); + } + } +} diff --git a/src/Kevsoft.WLED/Commands/Toggleable.cs b/src/Kevsoft.WLED/Commands/Toggleable.cs new file mode 100644 index 0000000..3c25bb2 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/Toggleable.cs @@ -0,0 +1,47 @@ +namespace Kevsoft.WLED; + +/// +/// A boolean command that can also represent a "toggle" instruction, matching WLED's +/// acceptance of true, false or "t" for on/off style fields. +/// +[JsonConverter(typeof(ToggleableJsonConverter))] +public readonly struct Toggleable : IEquatable +{ + private readonly byte _kind; + + private Toggleable(byte kind) => _kind = kind; + + /// Turn off. + public static Toggleable Off => new(0); + + /// Turn on. + public static Toggleable On => new(1); + + /// Toggle the current state. + public static Toggleable Toggle => new(2); + + /// true if this is the toggle instruction. + public bool IsToggle => _kind == 2; + + /// The boolean value, or null when this is a toggle instruction. + public bool? Value => _kind switch + { + 0 => false, + 1 => true, + _ => null, + }; + + public static implicit operator Toggleable(bool value) => value ? On : Off; + + public bool Equals(Toggleable other) => _kind == other._kind; + + public override bool Equals(object? obj) => obj is Toggleable other && Equals(other); + + public override int GetHashCode() => _kind; + + public override string ToString() => IsToggle ? "Toggle" : Value!.Value ? "On" : "Off"; + + public static bool operator ==(Toggleable left, Toggleable right) => left.Equals(right); + + public static bool operator !=(Toggleable left, Toggleable right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/Commands/ToggleableJsonConverter.cs b/src/Kevsoft.WLED/Commands/ToggleableJsonConverter.cs new file mode 100644 index 0000000..b7006d6 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/ToggleableJsonConverter.cs @@ -0,0 +1,38 @@ +namespace Kevsoft.WLED; + +/// Serializes as true, false or "t". +public sealed class ToggleableJsonConverter : JsonConverter +{ + public override Toggleable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return Toggleable.On; + case JsonTokenType.False: + return Toggleable.Off; + case JsonTokenType.String: + var value = reader.GetString(); + if (string.Equals(value, "t", StringComparison.OrdinalIgnoreCase)) + { + return Toggleable.Toggle; + } + + throw new JsonException($"'{value}' is not a valid toggle value."); + default: + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a toggle value."); + } + } + + public override void Write(Utf8JsonWriter writer, Toggleable value, JsonSerializerOptions options) + { + if (value.IsToggle) + { + writer.WriteStringValue("t"); + } + else + { + writer.WriteBooleanValue(value.Value!.Value); + } + } +} diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs new file mode 100644 index 0000000..ad95877 --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -0,0 +1,54 @@ +namespace Kevsoft.WLED; + +/// +/// Fluent builder for a sparse segment update. The segment id is always sent so the +/// device patches the matching segment rather than replacing all segments. +/// +public sealed class SegmentUpdate +{ + private readonly SegmentRequest _request; + + internal SegmentUpdate(int id) => _request = new SegmentRequest { Id = id }; + + /// Turn the segment on. + public SegmentUpdate TurnOn() => On(Toggleable.On); + + /// Turn the segment off. + public SegmentUpdate TurnOff() => On(Toggleable.Off); + + /// Toggle the segment on/off. + public SegmentUpdate Toggle() => On(Toggleable.Toggle); + + /// Set the segment on/off state explicitly. + public SegmentUpdate On(Toggleable value) + { + _request.SegmentState = value; + return this; + } + + /// Freeze the segment's effect. + public SegmentUpdate Freeze() => Freeze(Toggleable.On); + + /// Set the segment's freeze state explicitly. + public SegmentUpdate Freeze(Toggleable value) + { + _request.Freeze = value; + return this; + } + + /// Select the effect by id, relative movement or at random. + public SegmentUpdate Effect(Selector effect) + { + _request.EffectId = effect; + return this; + } + + /// Select the color palette by id, relative movement or at random. + public SegmentUpdate Palette(Selector palette) + { + _request.ColorPaletteId = palette; + return this; + } + + internal SegmentRequest Build() => _request; +} diff --git a/src/Kevsoft.WLED/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs new file mode 100644 index 0000000..0e60a69 --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -0,0 +1,101 @@ +namespace Kevsoft.WLED; + +/// +/// Fluent builder for a sparse state update. Only the properties you set are sent to the +/// device, so an update never accidentally overwrites unrelated state. +/// +public sealed class StateUpdate +{ + private readonly StateRequest _request = new(); + private readonly List _segments = new(); + + /// Turn the light on. + public StateUpdate TurnOn() => On(Toggleable.On); + + /// Turn the light off. + public StateUpdate TurnOff() => On(Toggleable.Off); + + /// Toggle the light on/off. + public StateUpdate Toggle() => On(Toggleable.Toggle); + + /// Set the on/off state explicitly. + public StateUpdate On(Toggleable value) + { + _request.On = value; + return this; + } + + /// Set, nudge or wrap the master brightness (0–255). + public StateUpdate Brightness(ByteAdjust brightness) + { + _request.Brightness = brightness; + return this; + } + + /// Set the crossfade transition duration (rounded to 100ms units, max 25.5s). + public StateUpdate Transition(TimeSpan duration) + { + _request.Transition = ToTransitionUnits(duration); + return this; + } + + /// Set the transition duration for this call only. + public StateUpdate TransitionOnce(TimeSpan duration) + { + _request.TransientTransition = ToTransitionUnits(duration); + return this; + } + + /// Apply a preset. + public StateUpdate Preset(PresetSelector preset) + { + _request.PresetId = preset; + return this; + } + + /// Set the main segment id. + public StateUpdate MainSegment(int id) + { + _request.MainSegment = id; + return this; + } + + /// Configure the segment with the given id, patching only the properties you set. + public StateUpdate Segment(int id, Action configure) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var segment = new SegmentUpdate(id); + configure(segment); + _segments.Add(segment.Build()); + return this; + } + + internal StateRequest Build() + { + if (_segments.Count > 0) + { + _request.Segments = _segments.ToArray(); + } + + return _request; + } + + private static byte ToTransitionUnits(TimeSpan duration) + { + var units = Math.Round(duration.TotalMilliseconds / 100.0, MidpointRounding.AwayFromZero); + if (units < 0) + { + units = 0; + } + else if (units > byte.MaxValue) + { + units = byte.MaxValue; + } + + return (byte)units; + } +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 6c27758..a8778ee 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -15,4 +15,9 @@ public interface IWLedClient Task Post(WLedRootRequest request); Task Post(StateRequest request); + + /// + /// Builds and posts a sparse state update using a fluent builder. + /// + Task UpdateState(Action configure); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/SegmentRequest.cs b/src/Kevsoft.WLED/SegmentRequest.cs index d27c186..849a80f 100644 --- a/src/Kevsoft.WLED/SegmentRequest.cs +++ b/src/Kevsoft.WLED/SegmentRequest.cs @@ -45,7 +45,7 @@ public sealed class SegmentRequest /// [JsonPropertyName("fx")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? EffectId { get; set; } + public Selector? EffectId { get; set; } /// [JsonPropertyName("sx")] @@ -60,7 +60,7 @@ public sealed class SegmentRequest /// [JsonPropertyName("pal")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? ColorPaletteId { get; set; } + public Selector? ColorPaletteId { get; set; } /// [JsonPropertyName("sel")] @@ -75,12 +75,12 @@ public sealed class SegmentRequest /// [JsonPropertyName("frz")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? Freeze { get; set; } + public Toggleable? Freeze { get; set; } /// [JsonPropertyName("on")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? SegmentState { get; set; } + public Toggleable? SegmentState { get; set; } /// [JsonPropertyName("bri")] diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index f07d5c7..36657f2 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -5,22 +5,30 @@ public sealed class StateRequest /// [JsonPropertyName("on")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public bool? On { get; set; } + public Toggleable? On { get; set; } /// [JsonPropertyName("bri")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public byte? Brightness { get; set; } + public ByteAdjust? Brightness { get; set; } /// [JsonPropertyName("transition")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public byte? Transition { get; set; } + /// + /// Sets the transition time for the current API call only (the tt field). + /// One unit is 100ms. + /// + [JsonPropertyName("tt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? TransientTransition { get; set; } + /// [JsonPropertyName("ps")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? PresetId { get; set; } + public PresetSelector? PresetId { get; set; } /// [JsonPropertyName("pl")] @@ -66,7 +74,7 @@ public static StateRequest From(StateResponse stateResponse) On = stateResponse.On, Brightness = stateResponse.Brightness, Transition = stateResponse.Transition, - PresetId = stateResponse.PresetId, + PresetId = PresetSelector.Id(stateResponse.PresetId), PlaylistId = stateResponse.PlaylistId, Nightlight = stateResponse.Nightlight, UdpPackets = stateResponse.UdpPackets, diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 45ed2ec..abf2ed9 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -82,4 +82,16 @@ public async Task Post(StateRequest request) var result = await _client.PostAsync("/json/state", content); result.EnsureSuccessStatusCode(); } + + public Task UpdateState(Action configure) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var update = new StateUpdate(); + configure(update); + return Post(update.Build()); + } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/CommandValueTests.cs b/test/Kevsoft.WLED.Tests/CommandValueTests.cs new file mode 100644 index 0000000..285ce4f --- /dev/null +++ b/test/Kevsoft.WLED.Tests/CommandValueTests.cs @@ -0,0 +1,75 @@ +namespace Kevsoft.WLED.Tests; + +public class CommandValueTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Fact] + public void ToggleableTokens() + { + JsonSerializer.Serialize(Toggleable.On, Options).Should().Be("true"); + JsonSerializer.Serialize(Toggleable.Off, Options).Should().Be("false"); + JsonSerializer.Serialize(Toggleable.Toggle, Options).Should().Be("\"t\""); + JsonSerializer.Serialize((Toggleable)true, Options).Should().Be("true"); + } + + [Fact] + public void ToggleableReads() + { + JsonSerializer.Deserialize("true", Options).Should().Be(Toggleable.On); + JsonSerializer.Deserialize("false", Options).Should().Be(Toggleable.Off); + JsonSerializer.Deserialize("\"t\"", Options).Should().Be(Toggleable.Toggle); + } + + [Fact] + public void ByteAdjustTokens() + { + JsonSerializer.Serialize(ByteAdjust.Set(128), Options).Should().Be("128"); + JsonSerializer.Serialize((ByteAdjust)200, Options).Should().Be("200"); + JsonSerializer.Serialize(ByteAdjust.Increment(), Options).Should().Be("\"~\""); + JsonSerializer.Serialize(ByteAdjust.Increment(10), Options).Should().Be("\"~10\""); + JsonSerializer.Serialize(ByteAdjust.Decrement(), Options).Should().Be("\"~-\""); + JsonSerializer.Serialize(ByteAdjust.Decrement(10), Options).Should().Be("\"~-10\""); + JsonSerializer.Serialize(ByteAdjust.IncrementWrap(40), Options).Should().Be("\"w~40\""); + } + + [Theory] + [InlineData("128")] + [InlineData("\"~\"")] + [InlineData("\"~10\"")] + [InlineData("\"~-\"")] + [InlineData("\"~-10\"")] + [InlineData("\"w~40\"")] + public void ByteAdjustRoundTrips(string json) + { + var value = JsonSerializer.Deserialize(json, Options); + + JsonSerializer.Serialize(value, Options).Should().Be(json); + } + + [Fact] + public void SelectorTokens() + { + JsonSerializer.Serialize(Selector.Id(5), Options).Should().Be("5"); + JsonSerializer.Serialize((Selector)7, Options).Should().Be("7"); + JsonSerializer.Serialize(Selector.Next, Options).Should().Be("\"~\""); + JsonSerializer.Serialize(Selector.Previous, Options).Should().Be("\"~-\""); + JsonSerializer.Serialize(Selector.Random, Options).Should().Be("\"r\""); + } + + [Fact] + public void PresetSelectorTokens() + { + JsonSerializer.Serialize(PresetSelector.Id(3), Options).Should().Be("3"); + JsonSerializer.Serialize(PresetSelector.Cycle(1, 6), Options).Should().Be("\"1~6~\""); + JsonSerializer.Serialize(PresetSelector.RandomInRange(4, 10), Options).Should().Be("\"4~10r\""); + } + + [Fact] + public void PresetSelectorRejectsInvalidRange() + { + var act = () => PresetSelector.Cycle(6, 1); + + act.Should().Throw(); + } +} diff --git a/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs new file mode 100644 index 0000000..c12b82c --- /dev/null +++ b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs @@ -0,0 +1,55 @@ +namespace Kevsoft.WLED.Tests; + +public class StateUpdateBuilderTests +{ + [Fact] + public async Task UpdateStateEmitsOnlyTouchedFields() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s + .TurnOn() + .Brightness(ByteAdjust.IncrementWrap(40)) + .Transition(TimeSpan.FromMilliseconds(700)) + .Segment(0, seg => seg + .Effect(Selector.Random) + .Palette(Selector.Next))); + + var (uri, body) = mockHttpMessageHandler.CapturedRequests.Single(); + uri.Should().Be($"{baseUri}/json/state"); + + var root = JsonDocument.Parse(body!).RootElement; + root.EnumerateObject().Select(p => p.Name).Should() + .BeEquivalentTo("on", "bri", "transition", "seg"); + root.GetProperty("on").GetBoolean().Should().BeTrue(); + root.GetProperty("bri").GetString().Should().Be("w~40"); + root.GetProperty("transition").GetInt32().Should().Be(7); + + var segment = root.GetProperty("seg")[0]; + segment.EnumerateObject().Select(p => p.Name).Should().BeEquivalentTo("id", "fx", "pal"); + segment.GetProperty("id").GetInt32().Should().Be(0); + segment.GetProperty("fx").GetString().Should().Be("r"); + segment.GetProperty("pal").GetString().Should().Be("~"); + } + + [Fact] + public async Task UpdateStateToggleAndTransitionOnce() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s + .Toggle() + .TransitionOnce(TimeSpan.FromSeconds(1))); + + var (_, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("on").GetString().Should().Be("t"); + root.GetProperty("tt").GetInt32().Should().Be(10); + } +} From 19138722a4f3362bf0d64dcfba46680c1c98e9dd Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:01:53 +0100 Subject: [PATCH 05/32] Plan 3: complete the state object with typed fields and write-only commands --- src/Kevsoft.WLED/Fluent/StateUpdate.cs | 47 +++++++++++ src/Kevsoft.WLED/NightlightRequest.cs | 4 +- src/Kevsoft.WLED/NightlightResponse.cs | 11 ++- .../NullableSentinelInt32JsonConverter.cs | 24 ++++++ src/Kevsoft.WLED/StateRequest.cs | 39 +++++++++- src/Kevsoft.WLED/StateResponse.cs | 14 ++-- src/Kevsoft.WLED/UdpPacketsRequest.cs | 21 ++++- src/Kevsoft.WLED/UdpPacketsResponse.cs | 12 +++ test/Kevsoft.WLED.Tests/JsonBuilder.cs | 15 ++-- test/Kevsoft.WLED.Tests/StateObjectTests.cs | 78 +++++++++++++++++++ .../Kevsoft.WLED.Tests/WLedClientPostTests.cs | 6 +- 11 files changed, 250 insertions(+), 21 deletions(-) create mode 100644 src/Kevsoft.WLED/NullableSentinelInt32JsonConverter.cs create mode 100644 test/Kevsoft.WLED.Tests/StateObjectTests.cs diff --git a/src/Kevsoft.WLED/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs index 0e60a69..cb54dc7 100644 --- a/src/Kevsoft.WLED/Fluent/StateUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -60,6 +60,53 @@ public StateUpdate MainSegment(int id) return this; } + /// Set how live/realtime data overrides the normal output. + public StateUpdate LiveDataOverride(LiveDataOverride mode) + { + _request.LiveDataOverride = mode; + return this; + } + + /// Enter (or leave) realtime/blank live mode for this call only. + public StateUpdate EnterLiveMode(bool enabled = true) + { + _request.Live = enabled; + return this; + } + + /// Set the device clock. + public StateUpdate SetTime(DateTimeOffset time) + { + _request.Time = time.ToUnixTimeSeconds(); + return this; + } + + /// Load the ledmap with the given id (0–9). + public StateUpdate LoadLedMap(byte id) + { + if (id > 9) + { + throw new ArgumentOutOfRangeException(nameof(id), id, "Ledmap id must be between 0 and 9."); + } + + _request.LedMap = id; + return this; + } + + /// Remove the last custom palette. + public StateUpdate RemoveLastCustomPalette() + { + _request.RemoveLastCustomPalette = true; + return this; + } + + /// Advance to the next preset in the active playlist. + public StateUpdate NextPreset() + { + _request.NextPreset = true; + return this; + } + /// Configure the segment with the given id, patching only the properties you set. public StateUpdate Segment(int id, Action configure) { diff --git a/src/Kevsoft.WLED/NightlightRequest.cs b/src/Kevsoft.WLED/NightlightRequest.cs index 96f285c..8087a93 100644 --- a/src/Kevsoft.WLED/NightlightRequest.cs +++ b/src/Kevsoft.WLED/NightlightRequest.cs @@ -12,10 +12,10 @@ public sealed class NightlightRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Duration { get; set; } - /// + /// [JsonPropertyName("mode")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public byte? Mode { get; set; } + public NightlightMode? Mode { get; set; } /// [JsonPropertyName("tbri")] diff --git a/src/Kevsoft.WLED/NightlightResponse.cs b/src/Kevsoft.WLED/NightlightResponse.cs index 5ca152a..d4e2f69 100644 --- a/src/Kevsoft.WLED/NightlightResponse.cs +++ b/src/Kevsoft.WLED/NightlightResponse.cs @@ -18,14 +18,21 @@ public sealed class NightlightResponse public int Duration { get; set; } /// - /// Nightlight mode (0: instant, 1: fade, 2: color fade, 3: sunrise) (available since 0.10.2). + /// Nightlight mode (instant, fade, color fade, sunrise) (available since 0.10.2). /// [JsonPropertyName("mode")] - public byte Mode { get; set; } + public NightlightMode Mode { get; set; } /// /// Target brightness. /// [JsonPropertyName("tbri")] public int TargetBrightness { get; set; } + + /// + /// Remaining nightlight duration in seconds, or null when nightlight is inactive. + /// + [JsonPropertyName("rem")] + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? Remaining { get; set; } } \ No newline at end of file diff --git a/src/Kevsoft.WLED/NullableSentinelInt32JsonConverter.cs b/src/Kevsoft.WLED/NullableSentinelInt32JsonConverter.cs new file mode 100644 index 0000000..f7c08f7 --- /dev/null +++ b/src/Kevsoft.WLED/NullableSentinelInt32JsonConverter.cs @@ -0,0 +1,24 @@ +namespace Kevsoft.WLED; + +/// +/// Maps WLED's -1 "none" sentinel to null when reading, and writes +/// null back as -1. +/// +public sealed class NullableSentinelInt32JsonConverter : JsonConverter +{ + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + var value = reader.GetInt32(); + return value < 0 ? null : value; + } + + public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value ?? -1); + } +} diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index 36657f2..17a9a80 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -48,7 +48,7 @@ public sealed class StateRequest /// [JsonPropertyName("lor")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public byte? LiveDataOverride { get; set; } + public LiveDataOverride? LiveDataOverride { get; set; } /// [JsonPropertyName("mainseg")] @@ -67,6 +67,41 @@ public sealed class StateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? Timebase { get; set; } + /// + /// Enter realtime/blank live mode for this call only (the live field, write-only). + /// + [JsonPropertyName("live")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Live { get; set; } + + /// + /// Set the device clock to this Unix time in seconds (the time field, write-only). + /// + [JsonPropertyName("time")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Time { get; set; } + + /// + /// Load the ledmap with this id (0–9) (the ledmap field, write-only). + /// + [JsonPropertyName("ledmap")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? LedMap { get; set; } + + /// + /// Remove the last custom palette (the rmcpal field, write-only). + /// + [JsonPropertyName("rmcpal")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RemoveLastCustomPalette { get; set; } + + /// + /// Advance to the next preset in the active playlist (the np field, write-only). + /// + [JsonPropertyName("np")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? NextPreset { get; set; } + public static StateRequest From(StateResponse stateResponse) { return new StateRequest() @@ -74,7 +109,7 @@ public static StateRequest From(StateResponse stateResponse) On = stateResponse.On, Brightness = stateResponse.Brightness, Transition = stateResponse.Transition, - PresetId = PresetSelector.Id(stateResponse.PresetId), + PresetId = stateResponse.PresetId is { } presetId ? PresetSelector.Id(presetId) : null, PlaylistId = stateResponse.PlaylistId, Nightlight = stateResponse.Nightlight, UdpPackets = stateResponse.UdpPackets, diff --git a/src/Kevsoft.WLED/StateResponse.cs b/src/Kevsoft.WLED/StateResponse.cs index f4b48ac..b576b3b 100644 --- a/src/Kevsoft.WLED/StateResponse.cs +++ b/src/Kevsoft.WLED/StateResponse.cs @@ -21,16 +21,18 @@ public sealed class StateResponse public byte Transition { get; set; } /// - /// ID of currently set preset. + /// ID of currently set preset, or null when none is active. /// [JsonPropertyName("ps")] - public int PresetId { get; set; } + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? PresetId { get; set; } /// - /// ID of currently set playlist. For now, this sets the preset cycle feature, -1 is off and 0 is on. + /// ID of currently set playlist, or null when none is active. /// [JsonPropertyName("pl")] - public int PlaylistId { get; set; } + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? PlaylistId { get; set; } /// /// Nightlight @@ -45,10 +47,10 @@ public sealed class StateResponse public UdpPacketsResponse UdpPackets { get; set; } = null!; /// - /// Live data override. 0 is off, 1 is override until live data ends, 2 is override until ESP reboot (available since 0.10.0) + /// Live data override. Off shows live data, or override until it ends / until reboot (available since 0.10.0). /// [JsonPropertyName("lor")] - public byte LiveDataOverride { get; set; } + public LiveDataOverride LiveDataOverride { get; set; } /// /// Main Segment diff --git a/src/Kevsoft.WLED/UdpPacketsRequest.cs b/src/Kevsoft.WLED/UdpPacketsRequest.cs index 98985ee..0ebe31c 100644 --- a/src/Kevsoft.WLED/UdpPacketsRequest.cs +++ b/src/Kevsoft.WLED/UdpPacketsRequest.cs @@ -12,12 +12,31 @@ public sealed class UdpPacketsRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Receive { get; set; } + /// + [JsonPropertyName("sgrp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SyncGroup? SendGroups { get; set; } + + /// + [JsonPropertyName("rgrp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SyncGroup? ReceiveGroups { get; set; } + + /// + /// Suppress sending a broadcast packet for this call only (the nn field). + /// + [JsonPropertyName("nn")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? NoNotify { get; set; } + public static UdpPacketsRequest From(UdpPacketsResponse udpPacketsResponse) { return new UdpPacketsRequest { Send = udpPacketsResponse.Send, - Receive = udpPacketsResponse.Receive + Receive = udpPacketsResponse.Receive, + SendGroups = udpPacketsResponse.SendGroups, + ReceiveGroups = udpPacketsResponse.ReceiveGroups }; } diff --git a/src/Kevsoft.WLED/UdpPacketsResponse.cs b/src/Kevsoft.WLED/UdpPacketsResponse.cs index e3f7bcd..fa7320a 100644 --- a/src/Kevsoft.WLED/UdpPacketsResponse.cs +++ b/src/Kevsoft.WLED/UdpPacketsResponse.cs @@ -13,4 +13,16 @@ public sealed class UdpPacketsResponse /// [JsonPropertyName("recv")] public bool Receive { get; set; } + + /// + /// Sync groups this device sends to. + /// + [JsonPropertyName("sgrp")] + public SyncGroup SendGroups { get; set; } + + /// + /// Sync groups this device receives from. + /// + [JsonPropertyName("rgrp")] + public SyncGroup ReceiveGroups { get; set; } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/JsonBuilder.cs b/test/Kevsoft.WLED.Tests/JsonBuilder.cs index 79eeb0c..3f9bc62 100644 --- a/test/Kevsoft.WLED.Tests/JsonBuilder.cs +++ b/test/Kevsoft.WLED.Tests/JsonBuilder.cs @@ -8,19 +8,22 @@ public static string CreateStateJson(StateResponse state) ""on"": {state.On.ToString().ToLower()}, ""bri"": {state.Brightness}, ""transition"": {state.Transition}, - ""ps"": {state.PresetId}, - ""pl"": {state.PlaylistId}, + ""ps"": {state.PresetId ?? -1}, + ""pl"": {state.PlaylistId ?? -1}, ""nl"": {{ ""on"": {state.Nightlight.On.ToString().ToLower()}, ""dur"": {state.Nightlight.Duration}, - ""mode"": {state.Nightlight.Mode}, - ""tbri"": {state.Nightlight.TargetBrightness} + ""mode"": {(byte)state.Nightlight.Mode}, + ""tbri"": {state.Nightlight.TargetBrightness}, + ""rem"": {state.Nightlight.Remaining ?? -1} }}, ""udpn"": {{ ""send"": {state.UdpPackets.Send.ToString().ToLower()}, - ""recv"": {state.UdpPackets.Receive.ToString().ToLower()} + ""recv"": {state.UdpPackets.Receive.ToString().ToLower()}, + ""sgrp"": {(byte)state.UdpPackets.SendGroups}, + ""rgrp"": {(byte)state.UdpPackets.ReceiveGroups} }}, - ""lor"": {state.LiveDataOverride}, + ""lor"": {(byte)state.LiveDataOverride}, ""mainseg"": {state.MainSegment}, ""seg"": [{String.Join(", ", state.Segments.Select(seg => { diff --git a/test/Kevsoft.WLED.Tests/StateObjectTests.cs b/test/Kevsoft.WLED.Tests/StateObjectTests.cs new file mode 100644 index 0000000..6ccd21a --- /dev/null +++ b/test/Kevsoft.WLED.Tests/StateObjectTests.cs @@ -0,0 +1,78 @@ +namespace Kevsoft.WLED.Tests; + +public class StateObjectTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Theory] + [InlineData("-1", null)] + [InlineData("5", 5)] + public void PresetIdSentinelMapsToNull(string raw, int? expected) + { + var json = $@"{{""ps"":{raw},""pl"":-1,""nl"":{{""rem"":-1}}}}"; + + var state = JsonSerializer.Deserialize(json, Options)!; + + state.PresetId.Should().Be(expected); + state.PlaylistId.Should().BeNull(); + state.Nightlight.Remaining.Should().BeNull(); + } + + [Fact] + public void LiveDataOverrideReadsAsEnum() + { + var state = JsonSerializer.Deserialize(@"{""lor"":2}", Options)!; + + state.LiveDataOverride.Should().Be(LiveDataOverride.UntilReboot); + } + + [Fact] + public void NightlightModeReadsAsEnum() + { + var state = JsonSerializer.Deserialize(@"{""nl"":{""mode"":3}}", Options)!; + + state.Nightlight.Mode.Should().Be(NightlightMode.Sunrise); + } + + [Fact] + public void SyncGroupsReadAsFlags() + { + var state = JsonSerializer.Deserialize(@"{""udpn"":{""sgrp"":3,""rgrp"":4}}", Options)!; + + state.UdpPackets.SendGroups.Should().Be(SyncGroup.Group1 | SyncGroup.Group2); + state.UdpPackets.ReceiveGroups.Should().Be(SyncGroup.Group3); + } + + [Fact] + public async Task WriteOnlyCommandsSerialize() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s + .EnterLiveMode() + .SetTime(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000)) + .LoadLedMap(2) + .RemoveLastCustomPalette() + .NextPreset()); + + var (_, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + + root.GetProperty("live").GetBoolean().Should().BeTrue(); + root.GetProperty("time").GetInt64().Should().Be(1_700_000_000); + root.GetProperty("ledmap").GetByte().Should().Be(2); + root.GetProperty("rmcpal").GetBoolean().Should().BeTrue(); + root.GetProperty("np").GetBoolean().Should().BeTrue(); + } + + [Fact] + public void LoadLedMapRejectsOutOfRange() + { + var act = () => new StateUpdate().LoadLedMap(10); + + act.Should().Throw(); + } +} diff --git a/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs b/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs index 274bab5..9a2b158 100644 --- a/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs +++ b/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs @@ -54,7 +54,7 @@ public async Task PostFullWLedRootResponse() var json = JsonDocument.Parse(body!); var expected = JsonDocument.Parse(JsonBuilder.CreateRootResponse(response)); - AssertBeEquivalentTo(json.RootElement.GetProperty("state"), expected.RootElement.GetProperty("state")); + AssertBeEquivalentTo(expected.RootElement.GetProperty("state"), json.RootElement.GetProperty("state")); } [Fact] @@ -73,9 +73,11 @@ public async Task PostFullStateResponse() var json = JsonDocument.Parse(body!); var expected = JsonDocument.Parse(JsonBuilder.CreateStateJson(response)); - AssertBeEquivalentTo(json.RootElement, expected.RootElement); + AssertBeEquivalentTo(expected.RootElement, json.RootElement); } + // Iterates the properties present on the posted request and asserts each one matches the + // source response JSON. Read-only response keys (absent from the request) are ignored. private static void AssertBeEquivalentTo(JsonElement actualJsonElement, JsonElement expectedJsonElement) { if (expectedJsonElement.ValueKind == JsonValueKind.Object) From f4f8b6bd83fd39ffdbd9421f53e35eadc24eed51 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:23:39 +0100 Subject: [PATCH 06/32] Plan 4: complete the segment object with colors, effect params and 2D fields --- src/Kevsoft.WLED/ColorTemperature.cs | 53 +++++ .../ColorTemperatureJsonConverter.cs | 20 ++ src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 211 ++++++++++++++++++ src/Kevsoft.WLED/SegmentRequest.cs | 116 +++++++++- src/Kevsoft.WLED/SegmentResponse.cs | 112 +++++++++- test/Kevsoft.WLED.Tests/JsonBuilder.cs | 21 +- test/Kevsoft.WLED.Tests/SegmentObjectTests.cs | 144 ++++++++++++ test/Kevsoft.WLED.Tests/WLedClientGetTests.cs | 2 +- .../Kevsoft.WLED.Tests/WLedClientPostTests.cs | 2 +- .../WledFixtureCustomization.cs | 20 ++ 10 files changed, 685 insertions(+), 16 deletions(-) create mode 100644 src/Kevsoft.WLED/ColorTemperature.cs create mode 100644 src/Kevsoft.WLED/ColorTemperatureJsonConverter.cs create mode 100644 test/Kevsoft.WLED.Tests/SegmentObjectTests.cs create mode 100644 test/Kevsoft.WLED.Tests/WledFixtureCustomization.cs diff --git a/src/Kevsoft.WLED/ColorTemperature.cs b/src/Kevsoft.WLED/ColorTemperature.cs new file mode 100644 index 0000000..f751b2d --- /dev/null +++ b/src/Kevsoft.WLED/ColorTemperature.cs @@ -0,0 +1,53 @@ +namespace Kevsoft.WLED; + +/// +/// A segment colour temperature. WLED accepts either a relative value (0–255) or an +/// absolute value in Kelvin (1900–10091), so this type makes the caller state which they +/// mean and guarantees the value is in range. +/// +[JsonConverter(typeof(ColorTemperatureJsonConverter))] +public readonly struct ColorTemperature : IEquatable +{ + internal const int MinKelvin = 1900; + internal const int MaxKelvin = 10091; + + private ColorTemperature(int value, bool isKelvin) + { + Value = value; + IsKelvin = isKelvin; + } + + /// The raw value, interpreted according to . + public int Value { get; } + + /// true if is in Kelvin; otherwise it is relative (0–255). + public bool IsKelvin { get; } + + /// A relative colour temperature (0 = warmest, 255 = coldest). + public static ColorTemperature Relative(byte value) => new(value, false); + + /// An absolute colour temperature in Kelvin (1900–10091). + public static ColorTemperature Kelvin(int kelvin) + { + if (kelvin < MinKelvin || kelvin > MaxKelvin) + { + throw new ArgumentOutOfRangeException(nameof(kelvin), kelvin, $"Kelvin must be between {MinKelvin} and {MaxKelvin}."); + } + + return new ColorTemperature(kelvin, true); + } + + internal static ColorTemperature FromWire(int value) => new(value, value > byte.MaxValue); + + public bool Equals(ColorTemperature other) => Value == other.Value && IsKelvin == other.IsKelvin; + + public override bool Equals(object? obj) => obj is ColorTemperature other && Equals(other); + + public override int GetHashCode() => (Value * 397) ^ (IsKelvin ? 1 : 0); + + public override string ToString() => IsKelvin ? $"{Value}K" : Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(ColorTemperature left, ColorTemperature right) => left.Equals(right); + + public static bool operator !=(ColorTemperature left, ColorTemperature right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/ColorTemperatureJsonConverter.cs b/src/Kevsoft.WLED/ColorTemperatureJsonConverter.cs new file mode 100644 index 0000000..633edd2 --- /dev/null +++ b/src/Kevsoft.WLED/ColorTemperatureJsonConverter.cs @@ -0,0 +1,20 @@ +namespace Kevsoft.WLED; + +/// Serializes as its numeric relative or Kelvin value. +public sealed class ColorTemperatureJsonConverter : JsonConverter +{ + public override ColorTemperature Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.Number) + { + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a colour temperature."); + } + + return ColorTemperature.FromWire(reader.GetInt32()); + } + + public override void Write(Utf8JsonWriter writer, ColorTemperature value, JsonSerializerOptions options) + { + writer.WriteNumberValue(value.Value); + } +} diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs index ad95877..3b1aa2d 100644 --- a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -50,5 +50,216 @@ public SegmentUpdate Palette(Selector palette) return this; } + /// Set the segment's color slots (primary, optional secondary and tertiary). + public SegmentUpdate Color(SegmentColors colors) + { + _request.Colors = colors ?? throw new ArgumentNullException(nameof(colors)); + return this; + } + + /// Set the segment's primary color. + public SegmentUpdate Color(Color primary) => Color(new SegmentColors(primary)); + + /// Set the segment's primary, secondary and optional tertiary colors. + public SegmentUpdate Color(Color primary, Color secondary, Color? tertiary = null) + => Color(new SegmentColors(primary, secondary, tertiary)); + + /// Set, nudge or wrap the relative effect speed (0–255). + public SegmentUpdate Speed(ByteAdjust speed) + { + _request.EffectSpeed = speed; + return this; + } + + /// Set, nudge or wrap the effect intensity (0–255). + public SegmentUpdate Intensity(ByteAdjust intensity) + { + _request.EffectIntensity = intensity; + return this; + } + + /// Set, nudge or wrap the segment brightness (0–255). + public SegmentUpdate Brightness(ByteAdjust brightness) + { + _request.Brightness = brightness; + return this; + } + + /// Set the segment name. + public SegmentUpdate Name(string name) + { + _request.Name = name ?? throw new ArgumentNullException(nameof(name)); + return this; + } + + /// Set the segment's color temperature. + public SegmentUpdate Cct(ColorTemperature cct) + { + _request.Cct = cct; + return this; + } + + /// Set custom slider 1 (0–255, effect dependent). + public SegmentUpdate CustomSlider1(byte value) + { + _request.CustomSlider1 = value; + return this; + } + + /// Set custom slider 2 (0–255, effect dependent). + public SegmentUpdate CustomSlider2(byte value) + { + _request.CustomSlider2 = value; + return this; + } + + /// Set custom slider 3 (0–31, effect dependent). + public SegmentUpdate CustomSlider3(byte value) + { + if (value > 31) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Custom slider 3 must be between 0 and 31."); + } + + _request.CustomSlider3 = value; + return this; + } + + /// Set effect option 1 (effect dependent checkbox). + public SegmentUpdate Option1(bool value) + { + _request.Option1 = value; + return this; + } + + /// Set effect option 2 (effect dependent checkbox). + public SegmentUpdate Option2(bool value) + { + _request.Option2 = value; + return this; + } + + /// Set effect option 3 (effect dependent checkbox). + public SegmentUpdate Option3(bool value) + { + _request.Option3 = value; + return this; + } + + /// Set how a 1D effect is expanded onto a 2D matrix. + public SegmentUpdate Expand1D(Expand1D expand) + { + _request.Expand1D = expand; + return this; + } + + /// Set the sound simulation type for audio-reactive effects. + public SegmentUpdate SoundSimulation(SoundSimulation simulation) + { + _request.SoundSimulation = simulation; + return this; + } + + /// Set the segment group/set id (0–3). + public SegmentUpdate Set(byte value) + { + if (value > 3) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Set must be between 0 and 3."); + } + + _request.Set = value; + return this; + } + + /// Set the segment bounds on a 1D strip. + public SegmentUpdate Range(int start, int stop) + { + _request.Start = start; + _request.Stop = stop; + return this; + } + + /// Set the 2D matrix bounds of the segment. + public SegmentUpdate Range2D(int startX, int stopX, int startY, int stopY) + { + _request.Start = startX; + _request.Stop = stopX; + _request.StartY = startY; + _request.StopY = stopY; + return this; + } + + /// Reverse the segment (flips animation direction). + public SegmentUpdate Reverse(bool value = true) + { + _request.Reverse = value; + return this; + } + + /// Mirror the segment. + public SegmentUpdate Mirror(bool value = true) + { + _request.Mirror = value; + return this; + } + + /// Reverse the segment vertically (2D matrix only). + public SegmentUpdate ReverseY(bool value = true) + { + _request.ReverseY = value; + return this; + } + + /// Mirror the segment vertically (2D matrix only). + public SegmentUpdate MirrorY(bool value = true) + { + _request.MirrorY = value; + return this; + } + + /// Transpose the segment, swapping X and Y (2D matrix only). + public SegmentUpdate Transpose(bool value = true) + { + _request.Transpose = value; + return this; + } + + /// Select (or deselect) the segment. + public SegmentUpdate Select(bool value = true) + { + _request.Selected = value; + return this; + } + + /// Set grouping and spacing for the segment. + public SegmentUpdate Grouping(int group, int spacing) + { + _request.Group = group; + _request.Spacing = spacing; + return this; + } + + /// Rotate the virtual start of the segment by the given offset. + public SegmentUpdate Offset(int offset) + { + _request.Offset = offset; + return this; + } + + /// Reset all effect parameters to the effect defaults. + public SegmentUpdate LoadEffectDefaults() + { + _request.LoadEffectDefaults = true; + return this; + } + + /// Repeat the segment's settings to fill the whole strip. + public SegmentUpdate RepeatToFill() + { + _request.RepeatToFill = true; + return this; + } + internal SegmentRequest Build() => _request; } diff --git a/src/Kevsoft.WLED/SegmentRequest.cs b/src/Kevsoft.WLED/SegmentRequest.cs index 849a80f..d083c74 100644 --- a/src/Kevsoft.WLED/SegmentRequest.cs +++ b/src/Kevsoft.WLED/SegmentRequest.cs @@ -40,7 +40,7 @@ public sealed class SegmentRequest /// [JsonPropertyName("col")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int[][]? Colors { get; set; } + public SegmentColors? Colors { get; set; } /// [JsonPropertyName("fx")] @@ -50,12 +50,12 @@ public sealed class SegmentRequest /// [JsonPropertyName("sx")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? EffectSpeed { get; set; } + public ByteAdjust? EffectSpeed { get; set; } /// [JsonPropertyName("ix")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? EffectIntensity { get; set; } + public ByteAdjust? EffectIntensity { get; set; } /// [JsonPropertyName("pal")] @@ -85,13 +85,103 @@ public sealed class SegmentRequest /// [JsonPropertyName("bri")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? Brightness { get; set; } + public ByteAdjust? Brightness { get; set; } /// [JsonPropertyName("mi")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? Mirror { get; set; } + /// + [JsonPropertyName("n")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } + + /// + [JsonPropertyName("cct")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ColorTemperature? Cct { get; set; } + + /// + [JsonPropertyName("c1")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? CustomSlider1 { get; set; } + + /// + [JsonPropertyName("c2")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? CustomSlider2 { get; set; } + + /// + [JsonPropertyName("c3")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? CustomSlider3 { get; set; } + + /// + [JsonPropertyName("o1")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Option1 { get; set; } + + /// + [JsonPropertyName("o2")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Option2 { get; set; } + + /// + [JsonPropertyName("o3")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Option3 { get; set; } + + /// + [JsonPropertyName("m12")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Expand1D? Expand1D { get; set; } + + /// + [JsonPropertyName("si")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public SoundSimulation? SoundSimulation { get; set; } + + /// + [JsonPropertyName("set")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? Set { get; set; } + + /// + [JsonPropertyName("startY")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? StartY { get; set; } + + /// + [JsonPropertyName("stopY")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? StopY { get; set; } + + /// + [JsonPropertyName("rY")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? ReverseY { get; set; } + + /// + [JsonPropertyName("mY")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? MirrorY { get; set; } + + /// + [JsonPropertyName("tp")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Transpose { get; set; } + + /// Write-only: reset all effect parameters (speed, intensity, custom sliders, options) to the effect defaults. + [JsonPropertyName("fxdef")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? LoadEffectDefaults { get; set; } + + /// Write-only: repeat the segment's settings to fill the whole strip. + [JsonPropertyName("rpt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? RepeatToFill { get; set; } + public static SegmentRequest From(SegmentResponse segmentResponse) { return new SegmentRequest @@ -113,7 +203,23 @@ public static SegmentRequest From(SegmentResponse segmentResponse) Freeze = segmentResponse.Freeze, SegmentState = segmentResponse.SegmentState, Brightness = segmentResponse.Brightness, - Mirror = segmentResponse.Mirror + Mirror = segmentResponse.Mirror, + Name = segmentResponse.Name, + Cct = segmentResponse.Cct, + CustomSlider1 = segmentResponse.CustomSlider1, + CustomSlider2 = segmentResponse.CustomSlider2, + CustomSlider3 = segmentResponse.CustomSlider3, + Option1 = segmentResponse.Option1, + Option2 = segmentResponse.Option2, + Option3 = segmentResponse.Option3, + Expand1D = segmentResponse.Expand1D, + SoundSimulation = segmentResponse.SoundSimulation, + Set = segmentResponse.Set, + StartY = segmentResponse.StartY, + StopY = segmentResponse.StopY, + ReverseY = segmentResponse.ReverseY, + MirrorY = segmentResponse.MirrorY, + Transpose = segmentResponse.Transpose }; } diff --git a/src/Kevsoft.WLED/SegmentResponse.cs b/src/Kevsoft.WLED/SegmentResponse.cs index 550c3d8..0e0106c 100644 --- a/src/Kevsoft.WLED/SegmentResponse.cs +++ b/src/Kevsoft.WLED/SegmentResponse.cs @@ -45,10 +45,10 @@ public sealed class SegmentResponse public int Offset { get; set; } /// - /// Array that has up to 3 color arrays as elements, the primary, secondary (background) and tertiary colors of the segment. Each color is an array of 3 or 4 bytes, which represent an RGB(W) color. + /// The primary, secondary (background) and tertiary colors of the segment. /// [JsonPropertyName("col")] - public int[][] Colors { get; set; } = null!; + public SegmentColors Colors { get; set; } = null!; /// /// ID of the effect. @@ -57,13 +57,16 @@ public sealed class SegmentResponse public int EffectId { get; set; } /// - /// Relative effect speed + /// Relative effect speed (0–255). /// [JsonPropertyName("sx")] - public int EffectSpeed { get; set; } + public byte EffectSpeed { get; set; } + /// + /// Effect intensity (0–255). + /// [JsonPropertyName("ix")] - public int EffectIntensity { get; set; } + public byte EffectIntensity { get; set; } /// /// ID of the color palette @@ -96,14 +99,109 @@ public sealed class SegmentResponse public bool SegmentState { get; set; } /// - /// Sets the individual segment brightness (available since 0.10.0) + /// Sets the individual segment brightness (0–255, available since 0.10.0) /// [JsonPropertyName("bri")] - public int Brightness { get; set; } + public byte Brightness { get; set; } /// /// Mirrors the segment (available since 0.10.2) /// [JsonPropertyName("mi")] public bool Mirror { get; set; } + + /// + /// Segment name. + /// + [JsonPropertyName("n")] + public string? Name { get; set; } + + /// + /// Color temperature of the segment. + /// + [JsonPropertyName("cct")] + public ColorTemperature Cct { get; set; } + + /// + /// Custom slider 1 (effect dependent, 0–255). + /// + [JsonPropertyName("c1")] + public byte CustomSlider1 { get; set; } + + /// + /// Custom slider 2 (effect dependent, 0–255). + /// + [JsonPropertyName("c2")] + public byte CustomSlider2 { get; set; } + + /// + /// Custom slider 3 (effect dependent, 0–31). + /// + [JsonPropertyName("c3")] + public byte CustomSlider3 { get; set; } + + /// + /// Effect option 1 (effect dependent checkbox). + /// + [JsonPropertyName("o1")] + public bool Option1 { get; set; } + + /// + /// Effect option 2 (effect dependent checkbox). + /// + [JsonPropertyName("o2")] + public bool Option2 { get; set; } + + /// + /// Effect option 3 (effect dependent checkbox). + /// + [JsonPropertyName("o3")] + public bool Option3 { get; set; } + + /// + /// How a 1D effect is expanded onto a 2D matrix. + /// + [JsonPropertyName("m12")] + public Expand1D Expand1D { get; set; } + + /// + /// The sound simulation type used for audio-reactive effects. + /// + [JsonPropertyName("si")] + public SoundSimulation SoundSimulation { get; set; } + + /// + /// Group/set id (0–3). + /// + [JsonPropertyName("set")] + public byte Set { get; set; } + + /// + /// Source segment this segment was cloned from, or null if not a clone. + /// + [JsonPropertyName("cln")] + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? Clones { get; set; } + + // 2D matrix only. These are ignored on 1D strips. + + /// 2D matrix: LED row the segment starts at. + [JsonPropertyName("startY")] + public int? StartY { get; set; } + + /// 2D matrix: LED row the segment stops at (exclusive). + [JsonPropertyName("stopY")] + public int? StopY { get; set; } + + /// 2D matrix: flip the segment vertically. + [JsonPropertyName("rY")] + public bool ReverseY { get; set; } + + /// 2D matrix: mirror the segment vertically. + [JsonPropertyName("mY")] + public bool MirrorY { get; set; } + + /// 2D matrix: transpose (swap X and Y). + [JsonPropertyName("tp")] + public bool Transpose { get; set; } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/JsonBuilder.cs b/test/Kevsoft.WLED.Tests/JsonBuilder.cs index 3f9bc62..f729acd 100644 --- a/test/Kevsoft.WLED.Tests/JsonBuilder.cs +++ b/test/Kevsoft.WLED.Tests/JsonBuilder.cs @@ -36,7 +36,7 @@ public static string CreateStateJson(StateResponse state) ""spc"": {seg.Spacing}, ""of"": {seg.Offset}, ""col"": [ - {String.Join(", ", seg.Colors.Select(col => $"[{String.Join(",", col)}]"))} + {String.Join(", ", seg.Colors.Slots.Select(col => $"[{String.Join(",", col.ToBytes())}]"))} ], ""fx"": {seg.EffectId}, ""sx"": {seg.EffectSpeed}, @@ -47,7 +47,24 @@ public static string CreateStateJson(StateResponse state) ""frz"": {seg.Freeze.ToString().ToLower()}, ""on"": {seg.SegmentState.ToString().ToLower()}, ""bri"": {seg.Brightness}, - ""mi"": {seg.Mirror.ToString().ToLower()} + ""mi"": {seg.Mirror.ToString().ToLower()}, + ""n"": ""{seg.Name}"", + ""cct"": {seg.Cct.Value}, + ""c1"": {seg.CustomSlider1}, + ""c2"": {seg.CustomSlider2}, + ""c3"": {seg.CustomSlider3}, + ""o1"": {seg.Option1.ToString().ToLower()}, + ""o2"": {seg.Option2.ToString().ToLower()}, + ""o3"": {seg.Option3.ToString().ToLower()}, + ""m12"": {(byte)seg.Expand1D}, + ""si"": {(byte)seg.SoundSimulation}, + ""set"": {seg.Set}, + ""cln"": {seg.Clones ?? -1}, + ""startY"": {seg.StartY}, + ""stopY"": {seg.StopY}, + ""rY"": {seg.ReverseY.ToString().ToLower()}, + ""mY"": {seg.MirrorY.ToString().ToLower()}, + ""tp"": {seg.Transpose.ToString().ToLower()} }}"; }))}], ""tb"": {state.Timebase} diff --git a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs new file mode 100644 index 0000000..c9f2314 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs @@ -0,0 +1,144 @@ +namespace Kevsoft.WLED.Tests; + +public class SegmentObjectTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Fact] + public void RelativeColorTemperatureRoundTrips() + { + var cct = ColorTemperature.Relative(128); + + var json = JsonSerializer.Serialize(cct, Options); + var roundTripped = JsonSerializer.Deserialize(json, Options); + + json.Should().Be("128"); + roundTripped.Should().Be(cct); + roundTripped.IsKelvin.Should().BeFalse(); + } + + [Fact] + public void KelvinColorTemperatureRoundTrips() + { + var cct = ColorTemperature.Kelvin(6500); + + var json = JsonSerializer.Serialize(cct, Options); + var roundTripped = JsonSerializer.Deserialize(json, Options); + + json.Should().Be("6500"); + roundTripped.Should().Be(cct); + roundTripped.IsKelvin.Should().BeTrue(); + } + + [Theory] + [InlineData(1899)] + [InlineData(10092)] + public void KelvinOutOfRangeThrows(int kelvin) + { + var act = () => ColorTemperature.Kelvin(kelvin); + + act.Should().Throw(); + } + + [Fact] + public void ColorTemperatureReadInfersKelvinAboveByteRange() + { + var relative = JsonSerializer.Deserialize("200", Options); + var kelvin = JsonSerializer.Deserialize("4200", Options); + + relative.IsKelvin.Should().BeFalse(); + kelvin.IsKelvin.Should().BeTrue(); + kelvin.Value.Should().Be(4200); + } + + [Fact] + public void SegmentColorsRoundTripViaSegmentResponse() + { + var json = @"{""col"":[[255,170,0],[0,0,0,128]]}"; + + var segment = JsonSerializer.Deserialize(json, Options)!; + + segment.Colors.Primary.Should().Be(Color.Rgb(255, 170, 0)); + segment.Colors.Secondary.Should().Be(Color.Rgbw(0, 0, 0, 128)); + segment.Colors.Tertiary.Should().BeNull(); + } + + [Fact] + public async Task SegmentColorBuilderEmitsColorSlots() + { + var (_, segment) = await CaptureSegment(seg => seg + .Color(Color.Rgb(255, 0, 0), Color.Rgb(0, 255, 0))); + + var colors = segment.GetProperty("col"); + colors.GetArrayLength().Should().Be(2); + colors[0].EnumerateArray().Select(x => x.GetInt32()).Should().Equal(255, 0, 0); + colors[1].EnumerateArray().Select(x => x.GetInt32()).Should().Equal(0, 255, 0); + } + + [Fact] + public async Task SegmentBuilderEmitsTwoDimensionalFields() + { + var (_, segment) = await CaptureSegment(seg => seg + .Range2D(0, 16, 0, 8) + .Transpose() + .MirrorY()); + + segment.GetProperty("start").GetInt32().Should().Be(0); + segment.GetProperty("stop").GetInt32().Should().Be(16); + segment.GetProperty("startY").GetInt32().Should().Be(0); + segment.GetProperty("stopY").GetInt32().Should().Be(8); + segment.GetProperty("tp").GetBoolean().Should().BeTrue(); + segment.GetProperty("mY").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task SegmentBuilderEmitsEffectParameters() + { + var (_, segment) = await CaptureSegment(seg => seg + .Speed(200) + .Intensity(ByteAdjust.Increment(10)) + .Cct(ColorTemperature.Kelvin(5000)) + .CustomSlider1(5) + .CustomSlider3(31) + .Set(2) + .SoundSimulation(SoundSimulation.WeWillRockYou)); + + segment.GetProperty("sx").GetInt32().Should().Be(200); + segment.GetProperty("ix").GetString().Should().Be("~10"); + segment.GetProperty("cct").GetInt32().Should().Be(5000); + segment.GetProperty("c1").GetInt32().Should().Be(5); + segment.GetProperty("c3").GetInt32().Should().Be(31); + segment.GetProperty("set").GetInt32().Should().Be(2); + segment.GetProperty("si").GetInt32().Should().Be((byte)SoundSimulation.WeWillRockYou); + } + + [Fact] + public void CustomSlider3RejectsOutOfRange() + { + var act = () => new StateUpdate().Segment(0, seg => seg.CustomSlider3(32)); + + act.Should().Throw(); + } + + [Fact] + public void SetRejectsOutOfRange() + { + var act = () => new StateUpdate().Segment(0, seg => seg.Set(4)); + + act.Should().Throw(); + } + + private static async Task<(string Uri, JsonElement Segment)> CaptureSegment(Action configure) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s.Segment(0, configure)); + + var (uri, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + return (uri, root.GetProperty("seg")[0].Clone()); + } +} diff --git a/test/Kevsoft.WLED.Tests/WLedClientGetTests.cs b/test/Kevsoft.WLED.Tests/WLedClientGetTests.cs index f3911f6..8c114e2 100644 --- a/test/Kevsoft.WLED.Tests/WLedClientGetTests.cs +++ b/test/Kevsoft.WLED.Tests/WLedClientGetTests.cs @@ -2,7 +2,7 @@ namespace Kevsoft.WLED.Tests; public class WLedClientGetTests { - private readonly Fixture _fixture = new(); + private readonly IFixture _fixture = new Fixture().Customize(new WledFixtureCustomization()); [Fact] public async Task GetsAllData() diff --git a/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs b/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs index 9a2b158..eeed97a 100644 --- a/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs +++ b/test/Kevsoft.WLED.Tests/WLedClientPostTests.cs @@ -2,7 +2,7 @@ namespace Kevsoft.WLED.Tests; public class WLedClientPostTests { - private readonly Fixture _fixture = new(); + private readonly IFixture _fixture = new Fixture().Customize(new WledFixtureCustomization()); [Fact] public async Task PostEmptyWLedRootRequestData() diff --git a/test/Kevsoft.WLED.Tests/WledFixtureCustomization.cs b/test/Kevsoft.WLED.Tests/WledFixtureCustomization.cs new file mode 100644 index 0000000..8f17cdc --- /dev/null +++ b/test/Kevsoft.WLED.Tests/WledFixtureCustomization.cs @@ -0,0 +1,20 @@ +namespace Kevsoft.WLED.Tests; + +/// +/// Teaches AutoFixture how to build the library's value types, which only expose +/// validating factory methods rather than public constructors. +/// +public sealed class WledFixtureCustomization : ICustomization +{ + public void Customize(IFixture fixture) + { + fixture.Register(() => Color.Rgb( + fixture.Create(), + fixture.Create(), + fixture.Create())); + + fixture.Register((Color primary) => new SegmentColors(primary)); + + fixture.Register(() => ColorTemperature.Relative(fixture.Create())); + } +} From 47c233041c4fc92ef7950f02a8ab67d964f2776a Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:27:39 +0100 Subject: [PATCH 07/32] Plan 5: complete info object and add si/net/live read endpoints --- src/Kevsoft.WLED/FilesystemResponse.cs | 41 ++++++ .../HexRgbColorArrayJsonConverter.cs | 35 +++++ src/Kevsoft.WLED/IWLedClient.cs | 16 +++ src/Kevsoft.WLED/InformationResponse.cs | 38 +++++ src/Kevsoft.WLED/LedsResponse.cs | 26 +++- src/Kevsoft.WLED/LiveResponse.cs | 17 +++ src/Kevsoft.WLED/NetworkResponse.cs | 34 +++++ src/Kevsoft.WLED/StateInfoResponse.cs | 15 ++ src/Kevsoft.WLED/WLedClient.cs | 38 +++++ src/Kevsoft.WLED/WifiResponse.cs | 19 +++ test/Kevsoft.WLED.Tests/InfoReadTests.cs | 133 ++++++++++++++++++ test/Kevsoft.WLED.Tests/JsonBuilder.cs | 20 ++- 12 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 src/Kevsoft.WLED/FilesystemResponse.cs create mode 100644 src/Kevsoft.WLED/HexRgbColorArrayJsonConverter.cs create mode 100644 src/Kevsoft.WLED/LiveResponse.cs create mode 100644 src/Kevsoft.WLED/NetworkResponse.cs create mode 100644 src/Kevsoft.WLED/StateInfoResponse.cs create mode 100644 src/Kevsoft.WLED/WifiResponse.cs create mode 100644 test/Kevsoft.WLED.Tests/InfoReadTests.cs diff --git a/src/Kevsoft.WLED/FilesystemResponse.cs b/src/Kevsoft.WLED/FilesystemResponse.cs new file mode 100644 index 0000000..d11135e --- /dev/null +++ b/src/Kevsoft.WLED/FilesystemResponse.cs @@ -0,0 +1,41 @@ +namespace Kevsoft.WLED; + +/// +/// Information about the embedded LittleFS filesystem (available since 0.11.0). +/// +public sealed class FilesystemResponse +{ + /// Estimated used filesystem space, in kilobytes. + [JsonPropertyName("u")] + public uint Used { get; set; } + + /// Total filesystem size, in kilobytes. + [JsonPropertyName("t")] + public uint Total { get; set; } + + /// + /// Raw unix timestamp of the last modification to presets.json. Not accurate + /// after boot or after using /edit. 0 when unknown. + /// + [JsonPropertyName("pmt")] + public long PresetsModifiedTimestamp { get; set; } + + /// Free filesystem space, in kilobytes. + [JsonIgnore] + public long Free => Total - (long)Used; + + /// Used filesystem space as a percentage of the total (0–100). + [JsonIgnore] + public double UsedPercentage => Total == 0 ? 0 : (double)Used / Total * 100; + + /// Free filesystem space as a percentage of the total (0–100). + [JsonIgnore] + public double FreePercentage => Total == 0 ? 0 : (double)Free / Total * 100; + + /// + /// The last modification time of presets.json, or null when unknown. + /// + [JsonIgnore] + public DateTimeOffset? LastModified => + PresetsModifiedTimestamp > 0 ? DateTimeOffset.FromUnixTimeSeconds(PresetsModifiedTimestamp) : null; +} diff --git a/src/Kevsoft.WLED/HexRgbColorArrayJsonConverter.cs b/src/Kevsoft.WLED/HexRgbColorArrayJsonConverter.cs new file mode 100644 index 0000000..04f5f68 --- /dev/null +++ b/src/Kevsoft.WLED/HexRgbColorArrayJsonConverter.cs @@ -0,0 +1,35 @@ +namespace Kevsoft.WLED; + +/// +/// Reads an array of hex colour strings (e.g. ["FF0000","00FF00"]) into +/// values. +/// +public sealed class HexRgbColorArrayJsonConverter : JsonConverter +{ + public override RgbColor[] Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a colour array."); + } + + var colors = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + colors.Add(RgbColor.FromHex(reader.GetString()!)); + } + + return colors.ToArray(); + } + + public override void Write(Utf8JsonWriter writer, RgbColor[] value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + foreach (var color in value) + { + writer.WriteStringValue(color.ToHex()); + } + + writer.WriteEndArray(); + } +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index a8778ee..f71f458 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -8,6 +8,22 @@ public interface IWLedClient Task GetInformation(); + /// + /// Gets the lighter /json/si response, containing only the state and info objects. + /// + Task GetStateInfo(); + + /// + /// Gets the nearby Wi-Fi networks reported by /json/net. + /// + Task GetNetworks(); + + /// + /// Gets the live LED colour stream from /json/live, or null if the firmware + /// was not built with JSON-live support. + /// + Task GetLiveColors(); + Task GetEffects(); Task GetPalettes(); diff --git a/src/Kevsoft.WLED/InformationResponse.cs b/src/Kevsoft.WLED/InformationResponse.cs index 35c4a0c..3cfc1f7 100644 --- a/src/Kevsoft.WLED/InformationResponse.cs +++ b/src/Kevsoft.WLED/InformationResponse.cs @@ -56,6 +56,44 @@ public sealed class InformationResponse [JsonPropertyName("palcount")] public ushort PalettesCount { get; set; } + /// + /// Info about the realtime data source. + /// + [JsonPropertyName("lm")] + public string LiveMode { get; set; } = null!; + + /// + /// Realtime data source IP address. + /// + [JsonPropertyName("lip")] + public string LiveIp { get; set; } = null!; + + /// + /// Number of currently connected WebSocket clients, or null if WebSockets are unsupported in this build. + /// + [JsonPropertyName("ws")] + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? WebSocketClients { get; set; } + + /// + /// Info about the current Wi-Fi signal strength. + /// + [JsonPropertyName("wifi")] + public WifiResponse Wifi { get; set; } = null!; + + /// + /// Info about the embedded LittleFS filesystem. + /// + [JsonPropertyName("fs")] + public FilesystemResponse Filesystem { get; set; } = null!; + + /// + /// Number of other WLED devices discovered on the network, or null if node discovery is disabled. + /// + [JsonPropertyName("ndc")] + [JsonConverter(typeof(NullableSentinelInt32JsonConverter))] + public int? DiscoveredDevices { get; set; } + /// /// Name of the platform. /// diff --git a/src/Kevsoft.WLED/LedsResponse.cs b/src/Kevsoft.WLED/LedsResponse.cs index dd0c0eb..65ffb39 100644 --- a/src/Kevsoft.WLED/LedsResponse.cs +++ b/src/Kevsoft.WLED/LedsResponse.cs @@ -18,7 +18,31 @@ public sealed class LedsResponse /// Logical AND of all active segment's virtual light capabilities /// [JsonPropertyName("lc")] - public byte LightCapabilities { get; set; } + public LightCapability LightCapabilities { get; set; } + + /// + /// Per-segment virtual light capabilities. + /// + [JsonPropertyName("seglc")] + public LightCapability[] SegmentLightCapabilities { get; set; } = Array.Empty(); + + /// + /// true if LEDs are 4-channel (RGB + White). Deprecated in favour of . + /// + [JsonPropertyName("rgbw")] + public bool Rgbw { get; set; } + + /// + /// true if a white channel slider should be displayed. Deprecated in favour of . + /// + [JsonPropertyName("wv")] + public bool WhiteValueSlider { get; set; } + + /// + /// true if the light supports colour temperature control. Deprecated in favour of . + /// + [JsonPropertyName("cct")] + public bool SupportsColorTemperature { get; set; } /// /// Current LED power usage in milliamps as determined by the ABL. 0 if ABL is disabled. diff --git a/src/Kevsoft.WLED/LiveResponse.cs b/src/Kevsoft.WLED/LiveResponse.cs new file mode 100644 index 0000000..b670710 --- /dev/null +++ b/src/Kevsoft.WLED/LiveResponse.cs @@ -0,0 +1,17 @@ +namespace Kevsoft.WLED; + +/// +/// The live LED colour stream from the /json/live endpoint (only available when the +/// firmware is built with WLED_ENABLE_JSONLIVE). +/// +public sealed class LiveResponse +{ + /// The current colour of each LED. + [JsonPropertyName("leds")] + [JsonConverter(typeof(HexRgbColorArrayJsonConverter))] + public RgbColor[] Leds { get; set; } = Array.Empty(); + + /// The strip length the colours map onto. + [JsonPropertyName("n")] + public int Length { get; set; } +} diff --git a/src/Kevsoft.WLED/NetworkResponse.cs b/src/Kevsoft.WLED/NetworkResponse.cs new file mode 100644 index 0000000..f143c6a --- /dev/null +++ b/src/Kevsoft.WLED/NetworkResponse.cs @@ -0,0 +1,34 @@ +namespace Kevsoft.WLED; + +/// +/// A nearby Wi-Fi network reported by the /json/net endpoint. +/// +public sealed class NetworkResponse +{ + /// The network SSID. + [JsonPropertyName("ssid")] + public string Ssid { get; set; } = null!; + + /// The received signal strength indicator, in dBm. + [JsonPropertyName("rssi")] + public int Rssi { get; set; } + + /// The network BSSID. + [JsonPropertyName("bssid")] + public string Bssid { get; set; } = null!; + + /// The Wi-Fi channel. + [JsonPropertyName("channel")] + public int Channel { get; set; } + + /// The raw encryption type as reported by the device. + [JsonPropertyName("enc")] + public int Encryption { get; set; } +} + +/// Wrapper for the /json/net response. +internal sealed class NetworksResponse +{ + [JsonPropertyName("networks")] + public NetworkResponse[] Networks { get; set; } = Array.Empty(); +} diff --git a/src/Kevsoft.WLED/StateInfoResponse.cs b/src/Kevsoft.WLED/StateInfoResponse.cs new file mode 100644 index 0000000..9fb6eb2 --- /dev/null +++ b/src/Kevsoft.WLED/StateInfoResponse.cs @@ -0,0 +1,15 @@ +namespace Kevsoft.WLED; + +/// +/// The lighter /json/si response, containing only the state and info objects. +/// +public sealed class StateInfoResponse +{ + /// The current device state. + [JsonPropertyName("state")] + public StateResponse State { get; set; } = null!; + + /// The device information. + [JsonPropertyName("info")] + public InformationResponse Info { get; set; } = null!; +} diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index abf2ed9..4ead81a 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -47,6 +47,44 @@ public async Task GetInformation() return (await message.Content.ReadFromJsonAsync())!; } + public async Task GetStateInfo() + { + var message = await _client.GetAsync("json/si"); + + message.EnsureSuccessStatusCode(); + + return (await message.Content.ReadFromJsonAsync())!; + } + + public async Task GetNetworks() + { + var message = await _client.GetAsync("json/net"); + + message.EnsureSuccessStatusCode(); + + var response = await message.Content.ReadFromJsonAsync(); + return response?.Networks ?? Array.Empty(); + } + + public async Task GetLiveColors() + { + var message = await _client.GetAsync("json/live"); + + if (message.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + message.EnsureSuccessStatusCode(); + + if (message.Content.Headers.ContentLength == 0) + { + return null; + } + + return await message.Content.ReadFromJsonAsync(); + } + public async Task GetEffects() { var message = await _client.GetAsync("json/eff"); diff --git a/src/Kevsoft.WLED/WifiResponse.cs b/src/Kevsoft.WLED/WifiResponse.cs new file mode 100644 index 0000000..6e1810e --- /dev/null +++ b/src/Kevsoft.WLED/WifiResponse.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// Information about the current Wi-Fi connection's signal strength. +/// +public sealed class WifiResponse +{ + /// The BSSID of the currently connected network. + [JsonPropertyName("bssid")] + public string Bssid { get; set; } = null!; + + /// Relative signal quality of the current connection (0–100). + [JsonPropertyName("signal")] + public int Signal { get; set; } + + /// The current Wi-Fi channel (1–14). + [JsonPropertyName("channel")] + public int Channel { get; set; } +} diff --git a/test/Kevsoft.WLED.Tests/InfoReadTests.cs b/test/Kevsoft.WLED.Tests/InfoReadTests.cs new file mode 100644 index 0000000..dd0dcc5 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/InfoReadTests.cs @@ -0,0 +1,133 @@ +namespace Kevsoft.WLED.Tests; + +public class InfoReadTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Fact] + public void LightCapabilitiesReadAsFlags() + { + var info = JsonSerializer.Deserialize( + @"{""leds"":{""lc"":7,""seglc"":[1,3,6]}}", Options)!; + + info.Leds.LightCapabilities.Should() + .Be(LightCapability.Rgb | LightCapability.WhiteChannel | LightCapability.ColorTemperature); + info.Leds.SegmentLightCapabilities.Should().Equal( + LightCapability.Rgb, + LightCapability.Rgb | LightCapability.WhiteChannel, + LightCapability.WhiteChannel | LightCapability.ColorTemperature); + } + + [Fact] + public void WebSocketClientsSentinelMapsToNull() + { + var unsupported = JsonSerializer.Deserialize(@"{""ws"":-1}", Options)!; + var connected = JsonSerializer.Deserialize(@"{""ws"":3}", Options)!; + + unsupported.WebSocketClients.Should().BeNull(); + connected.WebSocketClients.Should().Be(3); + } + + [Fact] + public void DiscoveredDevicesSentinelMapsToNull() + { + var disabled = JsonSerializer.Deserialize(@"{""ndc"":-1}", Options)!; + var enabled = JsonSerializer.Deserialize(@"{""ndc"":5}", Options)!; + + disabled.DiscoveredDevices.Should().BeNull(); + enabled.DiscoveredDevices.Should().Be(5); + } + + [Fact] + public void WifiAndFilesystemDeserialize() + { + var json = @"{ + ""wifi"":{""bssid"":""AA:BB:CC:DD:EE:FF"",""signal"":72,""channel"":6}, + ""fs"":{""u"":256,""t"":1024,""pmt"":1700000000} + }"; + + var info = JsonSerializer.Deserialize(json, Options)!; + + info.Wifi.Bssid.Should().Be("AA:BB:CC:DD:EE:FF"); + info.Wifi.Signal.Should().Be(72); + info.Wifi.Channel.Should().Be(6); + info.Filesystem.Used.Should().Be(256); + info.Filesystem.Total.Should().Be(1024); + info.Filesystem.LastModified.Should().Be(DateTimeOffset.FromUnixTimeSeconds(1700000000)); + } + + [Fact] + public void FilesystemComputedProperties() + { + var fs = new FilesystemResponse { Used = 256, Total = 1024, PresetsModifiedTimestamp = 0 }; + + fs.Free.Should().Be(768); + fs.UsedPercentage.Should().BeApproximately(25, 0.0001); + fs.FreePercentage.Should().BeApproximately(75, 0.0001); + fs.LastModified.Should().BeNull(); + } + + [Fact] + public async Task GetStateInfoReadsStateAndInfo() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/si", + @"{""state"":{""bri"":128},""info"":{""name"":""Desk""}}"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var result = await client.GetStateInfo(); + + result.State.Brightness.Should().Be(128); + result.Info.Name.Should().Be("Desk"); + } + + [Fact] + public async Task GetNetworksReadsNetworkArray() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/net", + @"{""networks"":[{""ssid"":""Home"",""rssi"":-60,""bssid"":""AA"",""channel"":11,""enc"":4}]}"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var networks = await client.GetNetworks(); + + networks.Should().HaveCount(1); + networks[0].Ssid.Should().Be("Home"); + networks[0].Rssi.Should().Be(-60); + networks[0].Channel.Should().Be(11); + networks[0].Encryption.Should().Be(4); + } + + [Fact] + public async Task GetLiveColorsReadsHexLeds() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/live", + @"{""leds"":[""FF0000"",""00FF00"",""0000FF""],""n"":3}"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var live = await client.GetLiveColors(); + + live.Should().NotBeNull(); + live!.Length.Should().Be(3); + live.Leds.Should().Equal( + new RgbColor(255, 0, 0), + new RgbColor(0, 255, 0), + new RgbColor(0, 0, 255)); + } + + [Fact] + public async Task GetLiveColorsReturnsNullWhenUnsupported() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var live = await client.GetLiveColors(); + + live.Should().BeNull(); + } +} diff --git a/test/Kevsoft.WLED.Tests/JsonBuilder.cs b/test/Kevsoft.WLED.Tests/JsonBuilder.cs index f729acd..521b3ec 100644 --- a/test/Kevsoft.WLED.Tests/JsonBuilder.cs +++ b/test/Kevsoft.WLED.Tests/JsonBuilder.cs @@ -79,7 +79,11 @@ public static string CreateInformationJson(InformationResponse information) ""leds"": {{ ""count"": {information.Leds.Count}, ""fps"": {information.Leds.Fps}, - ""lc"": {information.Leds.LightCapabilities}, + ""lc"": {(byte)information.Leds.LightCapabilities}, + ""seglc"": [{String.Join(",", information.Leds.SegmentLightCapabilities.Select(x => (byte)x))}], + ""rgbw"": {information.Leds.Rgbw.ToString().ToLower()}, + ""wv"": {information.Leds.WhiteValueSlider.ToString().ToLower()}, + ""cct"": {information.Leds.SupportsColorTemperature.ToString().ToLower()}, ""pwr"": {information.Leds.PowerUsage}, ""maxpwr"": {information.Leds.MaximumPower}, ""maxseg"": {information.Leds.MaximumSegments} @@ -90,6 +94,20 @@ public static string CreateInformationJson(InformationResponse information) ""live"": {information.Live.ToString().ToLower()}, ""fxcount"": {information.EffectsCount}, ""palcount"": {information.PalettesCount}, + ""lm"": ""{information.LiveMode}"", + ""lip"": ""{information.LiveIp}"", + ""ws"": {information.WebSocketClients ?? -1}, + ""wifi"": {{ + ""bssid"": ""{information.Wifi.Bssid}"", + ""signal"": {information.Wifi.Signal}, + ""channel"": {information.Wifi.Channel} + }}, + ""fs"": {{ + ""u"": {information.Filesystem.Used}, + ""t"": {information.Filesystem.Total}, + ""pmt"": {information.Filesystem.PresetsModifiedTimestamp} + }}, + ""ndc"": {information.DiscoveredDevices ?? -1}, ""arch"": ""{information.Arch}"", ""core"": ""{information.Core}"", ""freeheap"": {information.FreeHeapMemory}, From 4d1bb36cd0feaeb900f2f5be6997cd5ab4a0dd5c Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:29:46 +0100 Subject: [PATCH 08/32] Plan 6: presets API with list, apply, save and delete --- src/Kevsoft.WLED/IWLedClient.cs | 20 +++++ src/Kevsoft.WLED/Preset.cs | 12 +++ src/Kevsoft.WLED/PresetsParser.cs | 72 +++++++++++++++++ src/Kevsoft.WLED/SavePresetOptions.cs | 26 +++++++ src/Kevsoft.WLED/StateRequest.cs | 49 ++++++++++++ src/Kevsoft.WLED/WLedClient.cs | 29 +++++++ test/Kevsoft.WLED.Tests/PresetTests.cs | 102 +++++++++++++++++++++++++ 7 files changed, 310 insertions(+) create mode 100644 src/Kevsoft.WLED/Preset.cs create mode 100644 src/Kevsoft.WLED/PresetsParser.cs create mode 100644 src/Kevsoft.WLED/SavePresetOptions.cs create mode 100644 test/Kevsoft.WLED.Tests/PresetTests.cs diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index f71f458..bb41e69 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -36,4 +36,24 @@ public interface IWLedClient /// Builds and posts a sparse state update using a fluent builder. /// Task UpdateState(Action configure); + + /// + /// Gets the saved presets, keyed by slot id. Playlists and the scratch slot are excluded. + /// + Task> GetPresets(); + + /// + /// Applies a preset by id, or cycles/randomises between presets. + /// + Task ApplyPreset(PresetSelector preset); + + /// + /// Saves the device's current live state to the given preset slot. + /// + Task SavePreset(int id, SavePresetOptions? options = null); + + /// + /// Deletes the preset in the given slot. + /// + Task DeletePreset(int id); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/Preset.cs b/src/Kevsoft.WLED/Preset.cs new file mode 100644 index 0000000..45c5f09 --- /dev/null +++ b/src/Kevsoft.WLED/Preset.cs @@ -0,0 +1,12 @@ +namespace Kevsoft.WLED; + +/// +/// A saved WLED preset: a named snapshot of the device state that can be re-applied. +/// +public sealed record Preset( + int Id, + string? Name, + string? QuickLabel, + bool On, + int MainSegmentId, + IReadOnlyList Segments); diff --git a/src/Kevsoft.WLED/PresetsParser.cs b/src/Kevsoft.WLED/PresetsParser.cs new file mode 100644 index 0000000..d0ea9c6 --- /dev/null +++ b/src/Kevsoft.WLED/PresetsParser.cs @@ -0,0 +1,72 @@ +namespace Kevsoft.WLED; + +/// +/// Parses the /presets.json file, which is an object keyed by preset id where each +/// value is either a preset or a playlist. Playlists and the scratch slot (id 0) are excluded. +/// +internal static class PresetsParser +{ + public static IReadOnlyDictionary ParsePresets(string json, JsonSerializerOptions options) + { + using var document = JsonDocument.Parse(json); + var presets = new Dictionary(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!int.TryParse(property.Name, out var id) || id == 0) + { + continue; + } + + var element = property.Value; + if (element.ValueKind != JsonValueKind.Object || !HasAnyProperty(element) || IsPlaylist(element)) + { + continue; + } + + presets[id] = ToPreset(id, element, options); + } + + return presets; + } + + internal static bool IsPlaylist(JsonElement element) => + element.TryGetProperty("playlist", out var playlist) + && playlist.ValueKind == JsonValueKind.Object + && playlist.TryGetProperty("ps", out var presetIds) + && presetIds.ValueKind == JsonValueKind.Array + && presetIds.GetArrayLength() > 0; + + private static bool HasAnyProperty(JsonElement element) + { + foreach (var _ in element.EnumerateObject()) + { + return true; + } + + return false; + } + + private static Preset ToPreset(int id, JsonElement element, JsonSerializerOptions options) + { + var name = element.TryGetProperty("n", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() + : null; + var quickLabel = element.TryGetProperty("ql", out var ql) && ql.ValueKind == JsonValueKind.String + ? ql.GetString() + : null; + var on = element.TryGetProperty("on", out var onElement) + && (onElement.ValueKind == JsonValueKind.True + || (onElement.ValueKind == JsonValueKind.Number && onElement.GetInt32() != 0)); + var mainSegment = element.TryGetProperty("mainseg", out var mainSegElement) + && mainSegElement.ValueKind == JsonValueKind.Number + ? mainSegElement.GetInt32() + : 0; + + var segments = element.TryGetProperty("seg", out var seg) && seg.ValueKind == JsonValueKind.Array + ? seg.Deserialize(options) ?? Array.Empty() + : Array.Empty(); + + return new Preset(id, name, quickLabel, on, mainSegment, segments); + } +} diff --git a/src/Kevsoft.WLED/SavePresetOptions.cs b/src/Kevsoft.WLED/SavePresetOptions.cs new file mode 100644 index 0000000..0fa44ed --- /dev/null +++ b/src/Kevsoft.WLED/SavePresetOptions.cs @@ -0,0 +1,26 @@ +namespace Kevsoft.WLED; + +/// +/// Options controlling how the current device state is captured when saving a preset. +/// +/// +/// Saving a preset persists the device's current live state, not an arbitrary +/// state you supply. Set the desired state first, then save. +/// +public sealed class SavePresetOptions +{ + /// The preset name. + public string? Name { get; init; } + + /// The quick-load label shown in the UI. + public string? QuickLabel { get; init; } + + /// Save each segment's start/stop bounds with the preset. Defaults to true. + public bool SaveSegmentBounds { get; init; } = true; + + /// Include the master brightness in the preset. Defaults to true. + public bool IncludeBrightness { get; init; } = true; + + /// Save which segments are selected with the preset. Defaults to true. + public bool SaveSelectedSegments { get; init; } = true; +} diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index 17a9a80..63b91bf 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -102,6 +102,55 @@ public sealed class StateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? NextPreset { get; set; } + /// + /// Save the current state to this preset slot (the psave field, write-only). + /// + [JsonPropertyName("psave")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? SavePresetSlot { get; set; } + + /// + /// Delete the preset in this slot (the pdel field, write-only). + /// + [JsonPropertyName("pdel")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? DeletePresetSlot { get; set; } + + /// + /// Name for a preset being saved (the n field, write-only). + /// + [JsonPropertyName("n")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PresetName { get; set; } + + /// + /// Quick-load label for a preset being saved (the ql field, write-only). + /// + [JsonPropertyName("ql")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? QuickLabel { get; set; } + + /// + /// Save segment bounds with the preset (the sb field, write-only). + /// + [JsonPropertyName("sb")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SaveSegmentBounds { get; set; } + + /// + /// Include brightness in the saved preset (the ib field, write-only). + /// + [JsonPropertyName("ib")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? IncludeBrightness { get; set; } + + /// + /// Save which segments are selected with the preset (the sc field, write-only). + /// + [JsonPropertyName("sc")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? SaveSelectedSegments { get; set; } + public static StateRequest From(StateResponse stateResponse) { return new StateRequest() diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 4ead81a..7c204b6 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -132,4 +132,33 @@ public Task UpdateState(Action configure) configure(update); return Post(update.Build()); } + + public async Task> GetPresets() + { + var message = await _client.GetAsync("presets.json"); + + message.EnsureSuccessStatusCode(); + + var json = await message.Content.ReadAsStringAsync(); + return PresetsParser.ParsePresets(json, new JsonSerializerOptions()); + } + + public Task ApplyPreset(PresetSelector preset) => Post(new StateRequest { PresetId = preset }); + + public Task SavePreset(int id, SavePresetOptions? options = null) + { + options ??= new SavePresetOptions(); + + return Post(new StateRequest + { + SavePresetSlot = id, + PresetName = options.Name, + QuickLabel = options.QuickLabel, + SaveSegmentBounds = options.SaveSegmentBounds, + IncludeBrightness = options.IncludeBrightness, + SaveSelectedSegments = options.SaveSelectedSegments + }); + } + + public Task DeletePreset(int id) => Post(new StateRequest { DeletePresetSlot = id }); } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/PresetTests.cs b/test/Kevsoft.WLED.Tests/PresetTests.cs new file mode 100644 index 0000000..5845b75 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/PresetTests.cs @@ -0,0 +1,102 @@ +namespace Kevsoft.WLED.Tests; + +public class PresetTests +{ + [Fact] + public async Task GetPresetsExcludesScratchSlotAndPlaylists() + { + var json = @"{ + ""0"": {}, + ""1"": {""on"":true,""mainseg"":0,""n"":""Sunset"",""ql"":""SS"",""seg"":[{""id"":0,""start"":0,""stop"":10}]}, + ""2"": {""playlist"":{""ps"":[1,2],""dur"":[10,10]},""on"":true,""n"":""Party""}, + ""3"": {""on"":false,""n"":""Off scene"",""seg"":[]} + }"; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/presets.json", json); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var presets = await client.GetPresets(); + + presets.Keys.Should().BeEquivalentTo(new[] { 1, 3 }); + presets[1].Name.Should().Be("Sunset"); + presets[1].QuickLabel.Should().Be("SS"); + presets[1].On.Should().BeTrue(); + presets[1].Segments.Should().HaveCount(1); + presets[1].Segments[0].Stop.Should().Be(10); + presets[3].On.Should().BeFalse(); + } + + [Fact] + public async Task ApplyPresetCycleSerializesCycleToken() + { + var (_, body) = await Capture(client => client.ApplyPreset(PresetSelector.Cycle(1, 6))); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("ps").GetString().Should().Be("1~6~"); + } + + [Fact] + public async Task ApplyPresetByIdSerializesNumber() + { + var (_, body) = await Capture(client => client.ApplyPreset(5)); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("ps").GetInt32().Should().Be(5); + } + + [Fact] + public async Task SavePresetSerializesSlotAndFlags() + { + var (_, body) = await Capture(client => client.SavePreset(3, new SavePresetOptions + { + Name = "X", + QuickLabel = "QL", + IncludeBrightness = false + })); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("psave").GetInt32().Should().Be(3); + root.GetProperty("n").GetString().Should().Be("X"); + root.GetProperty("ql").GetString().Should().Be("QL"); + root.GetProperty("sb").GetBoolean().Should().BeTrue(); + root.GetProperty("ib").GetBoolean().Should().BeFalse(); + root.GetProperty("sc").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task SavePresetWithoutOptionsUsesDefaults() + { + var (_, body) = await Capture(client => client.SavePreset(2)); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("psave").GetInt32().Should().Be(2); + root.GetProperty("sb").GetBoolean().Should().BeTrue(); + root.GetProperty("ib").GetBoolean().Should().BeTrue(); + root.GetProperty("sc").GetBoolean().Should().BeTrue(); + root.TryGetProperty("n", out _).Should().BeFalse(); + } + + [Fact] + public async Task DeletePresetSerializesSlot() + { + var (_, body) = await Capture(client => client.DeletePreset(3)); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("pdel").GetInt32().Should().Be(3); + } + + private static async Task<(string Uri, string? Body)> Capture(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + var (uri, body) = mockHttpMessageHandler.CapturedRequests.Single(); + return (uri, body); + } +} From 69537cd17167ff421c88668845d1e6c6a027fb35 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:37:05 +0100 Subject: [PATCH 09/32] Plan 7: playlists API with typed entries and parallel-array transform --- src/Kevsoft.WLED/Fluent/PlaylistBuilder.cs | 48 ++++++++ src/Kevsoft.WLED/IWLedClient.cs | 20 ++++ src/Kevsoft.WLED/Playlist.cs | 6 + src/Kevsoft.WLED/PlaylistDefinition.cs | 19 +++ src/Kevsoft.WLED/PlaylistEntry.cs | 7 ++ src/Kevsoft.WLED/PlaylistRequest.cs | 24 ++++ .../PlaylistRequestJsonConverter.cs | 69 +++++++++++ src/Kevsoft.WLED/PlaylistsParser.cs | 112 ++++++++++++++++++ src/Kevsoft.WLED/StateRequest.cs | 7 ++ src/Kevsoft.WLED/WLedClient.cs | 53 +++++++++ test/Kevsoft.WLED.Tests/PlaylistTests.cs | 98 +++++++++++++++ 11 files changed, 463 insertions(+) create mode 100644 src/Kevsoft.WLED/Fluent/PlaylistBuilder.cs create mode 100644 src/Kevsoft.WLED/Playlist.cs create mode 100644 src/Kevsoft.WLED/PlaylistDefinition.cs create mode 100644 src/Kevsoft.WLED/PlaylistEntry.cs create mode 100644 src/Kevsoft.WLED/PlaylistRequest.cs create mode 100644 src/Kevsoft.WLED/PlaylistRequestJsonConverter.cs create mode 100644 src/Kevsoft.WLED/PlaylistsParser.cs create mode 100644 test/Kevsoft.WLED.Tests/PlaylistTests.cs diff --git a/src/Kevsoft.WLED/Fluent/PlaylistBuilder.cs b/src/Kevsoft.WLED/Fluent/PlaylistBuilder.cs new file mode 100644 index 0000000..e67bcaf --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/PlaylistBuilder.cs @@ -0,0 +1,48 @@ +namespace Kevsoft.WLED; + +/// +/// Fluent builder for a . +/// +public sealed class PlaylistBuilder +{ + private readonly List _entries = new(); + private int _repeat; + private int? _endPresetId; + private bool _shuffle; + + /// Add a step that shows for . + public PlaylistBuilder Add(int preset, TimeSpan duration, TimeSpan? transition = null) + { + _entries.Add(new PlaylistEntry(preset, duration, transition)); + return this; + } + + /// Set how many times the playlist cycles. 0 means indefinitely. + public PlaylistBuilder Repeat(int times) + { + _repeat = times; + return this; + } + + /// Apply the given preset once the playlist finishes. + public PlaylistBuilder EndOn(int presetId) + { + _endPresetId = presetId; + return this; + } + + /// Play the entries in a random order. + public PlaylistBuilder Shuffle(bool shuffle = true) + { + _shuffle = shuffle; + return this; + } + + internal PlaylistDefinition Build() => new() + { + Entries = _entries.ToArray(), + Repeat = _repeat, + EndPresetId = _endPresetId, + Shuffle = _shuffle + }; +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index bb41e69..61d8947 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -56,4 +56,24 @@ public interface IWLedClient /// Deletes the preset in the given slot. /// Task DeletePreset(int id); + + /// + /// Gets the saved playlists, keyed by slot id. + /// + Task> GetPlaylists(); + + /// + /// Starts the given playlist immediately. + /// + Task StartPlaylist(PlaylistDefinition playlist); + + /// + /// Builds and starts a playlist using a fluent builder. + /// + Task StartPlaylist(Action configure); + + /// + /// Saves a playlist to the given preset slot. + /// + Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/Playlist.cs b/src/Kevsoft.WLED/Playlist.cs new file mode 100644 index 0000000..b627d92 --- /dev/null +++ b/src/Kevsoft.WLED/Playlist.cs @@ -0,0 +1,6 @@ +namespace Kevsoft.WLED; + +/// +/// A saved WLED playlist read from presets.json. +/// +public sealed record Playlist(int Id, string? Name, PlaylistDefinition Definition); diff --git a/src/Kevsoft.WLED/PlaylistDefinition.cs b/src/Kevsoft.WLED/PlaylistDefinition.cs new file mode 100644 index 0000000..d17277e --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistDefinition.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// A playlist: an ordered set of steps with repeat/end behaviour. +/// +public sealed class PlaylistDefinition +{ + /// The ordered steps that make up the playlist. + public IReadOnlyList Entries { get; init; } = Array.Empty(); + + /// How many times the playlist cycles before finishing. 0 means indefinitely. + public int Repeat { get; init; } + + /// The preset to apply once the playlist finishes, or null to stay on the last step. + public int? EndPresetId { get; init; } + + /// Play the entries in a random order. + public bool Shuffle { get; init; } +} diff --git a/src/Kevsoft.WLED/PlaylistEntry.cs b/src/Kevsoft.WLED/PlaylistEntry.cs new file mode 100644 index 0000000..9cbf62e --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistEntry.cs @@ -0,0 +1,7 @@ +namespace Kevsoft.WLED; + +/// +/// A single step in a playlist: which preset to show, for how long, and how long to take +/// transitioning to it. Coupling these together makes mismatched parallel arrays impossible. +/// +public sealed record PlaylistEntry(int PresetId, TimeSpan Duration, TimeSpan? Transition = null); diff --git a/src/Kevsoft.WLED/PlaylistRequest.cs b/src/Kevsoft.WLED/PlaylistRequest.cs new file mode 100644 index 0000000..dbaf9ea --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistRequest.cs @@ -0,0 +1,24 @@ +namespace Kevsoft.WLED; + +/// +/// Write-only representation of a playlist, serialized into WLED's parallel +/// ps/dur/transition arrays by . +/// +[JsonConverter(typeof(PlaylistRequestJsonConverter))] +public sealed class PlaylistRequest +{ + internal PlaylistDefinition Definition { get; } + + internal PlaylistRequest(PlaylistDefinition definition) => Definition = definition; + + /// Creates a request from a playlist definition. + public static PlaylistRequest From(PlaylistDefinition definition) + { + if (definition is null) + { + throw new ArgumentNullException(nameof(definition)); + } + + return new PlaylistRequest(definition); + } +} diff --git a/src/Kevsoft.WLED/PlaylistRequestJsonConverter.cs b/src/Kevsoft.WLED/PlaylistRequestJsonConverter.cs new file mode 100644 index 0000000..79ceb71 --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistRequestJsonConverter.cs @@ -0,0 +1,69 @@ +namespace Kevsoft.WLED; + +/// +/// Unzips a into WLED's parallel ps/dur/ +/// transition arrays plus repeat/end/r. +/// +public sealed class PlaylistRequestJsonConverter : JsonConverter +{ + public override PlaylistRequest Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException("Playlists are read via /presets.json, not deserialized from a request."); + + public override void Write(Utf8JsonWriter writer, PlaylistRequest value, JsonSerializerOptions options) + { + var definition = value.Definition; + var entries = definition.Entries; + + writer.WriteStartObject(); + + writer.WritePropertyName("ps"); + writer.WriteStartArray(); + foreach (var entry in entries) + { + writer.WriteNumberValue(entry.PresetId); + } + + writer.WriteEndArray(); + + writer.WritePropertyName("dur"); + writer.WriteStartArray(); + foreach (var entry in entries) + { + writer.WriteNumberValue(ToTenths(entry.Duration)); + } + + writer.WriteEndArray(); + + if (entries.Any(entry => entry.Transition.HasValue)) + { + writer.WritePropertyName("transition"); + writer.WriteStartArray(); + foreach (var entry in entries) + { + writer.WriteNumberValue(entry.Transition.HasValue ? ToTenths(entry.Transition.Value) : 0); + } + + writer.WriteEndArray(); + } + + writer.WriteNumber("repeat", definition.Repeat); + + if (definition.EndPresetId is { } end) + { + writer.WriteNumber("end", end); + } + + if (definition.Shuffle) + { + writer.WriteBoolean("r", true); + } + + writer.WriteEndObject(); + } + + internal static int ToTenths(TimeSpan duration) + { + var tenths = Math.Round(duration.TotalMilliseconds / 100.0, MidpointRounding.AwayFromZero); + return tenths < 0 ? 0 : (int)tenths; + } +} diff --git a/src/Kevsoft.WLED/PlaylistsParser.cs b/src/Kevsoft.WLED/PlaylistsParser.cs new file mode 100644 index 0000000..9808f2c --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistsParser.cs @@ -0,0 +1,112 @@ +namespace Kevsoft.WLED; + +/// +/// Parses playlist entries out of the /presets.json file, zipping WLED's parallel +/// ps/dur/transition arrays back into typed steps. +/// +internal static class PlaylistsParser +{ + public static IReadOnlyDictionary ParsePlaylists(string json) + { + using var document = JsonDocument.Parse(json); + var playlists = new Dictionary(); + + foreach (var property in document.RootElement.EnumerateObject()) + { + if (!int.TryParse(property.Name, out var id) || id == 0) + { + continue; + } + + var element = property.Value; + if (element.ValueKind != JsonValueKind.Object || !PresetsParser.IsPlaylist(element)) + { + continue; + } + + var name = element.TryGetProperty("n", out var n) && n.ValueKind == JsonValueKind.String + ? n.GetString() + : null; + + playlists[id] = new Playlist(id, name, ToDefinition(element.GetProperty("playlist"))); + } + + return playlists; + } + + private static PlaylistDefinition ToDefinition(JsonElement playlist) + { + var presetIds = playlist.GetProperty("ps"); + var count = presetIds.GetArrayLength(); + + var durations = ReadTenthsArray(playlist, "dur", count); + var transitions = ReadTenthsArray(playlist, "transition", count); + + var entries = new List(count); + for (var i = 0; i < count; i++) + { + entries.Add(new PlaylistEntry( + presetIds[i].GetInt32(), + durations is { } d ? d[i] : TimeSpan.Zero, + transitions is { } t ? t[i] : null)); + } + + var repeat = playlist.TryGetProperty("repeat", out var repeatElement) && repeatElement.ValueKind == JsonValueKind.Number + ? repeatElement.GetInt32() + : 0; + int? end = playlist.TryGetProperty("end", out var endElement) && endElement.ValueKind == JsonValueKind.Number + ? endElement.GetInt32() + : null; + var shuffle = playlist.TryGetProperty("r", out var shuffleElement) + && (shuffleElement.ValueKind == JsonValueKind.True + || (shuffleElement.ValueKind == JsonValueKind.Number && shuffleElement.GetInt32() != 0)); + + return new PlaylistDefinition + { + Entries = entries, + Repeat = repeat, + EndPresetId = end, + Shuffle = shuffle + }; + } + + /// + /// Reads a field that WLED stores either as a scalar (broadcast to every entry) or as an + /// array. Returns null when the field is absent. + /// + private static TimeSpan[]? ReadTenthsArray(JsonElement playlist, string name, int count) + { + if (!playlist.TryGetProperty(name, out var element)) + { + return null; + } + + var values = new TimeSpan[count]; + if (element.ValueKind == JsonValueKind.Number) + { + var broadcast = FromTenths(element.GetInt32()); + for (var i = 0; i < count; i++) + { + values[i] = broadcast; + } + + return values; + } + + if (element.ValueKind != JsonValueKind.Array) + { + return null; + } + + var length = element.GetArrayLength(); + for (var i = 0; i < count; i++) + { + var index = length == 0 ? 0 : Math.Min(i, length - 1); + values[i] = length == 0 ? TimeSpan.Zero : FromTenths(element[index].GetInt32()); + } + + return values; + } + + private static TimeSpan FromTenths(int tenths) => TimeSpan.FromMilliseconds(tenths * 100.0); +} diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index 63b91bf..09f1b34 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -151,6 +151,13 @@ public sealed class StateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? SaveSelectedSegments { get; set; } + /// + /// Start a playlist (the playlist field, write-only). + /// + [JsonPropertyName("playlist")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public PlaylistRequest? Playlist { get; set; } + public static StateRequest From(StateResponse stateResponse) { return new StateRequest() diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 7c204b6..782fdda 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -161,4 +161,57 @@ public Task SavePreset(int id, SavePresetOptions? options = null) } public Task DeletePreset(int id) => Post(new StateRequest { DeletePresetSlot = id }); + + public async Task> GetPlaylists() + { + var message = await _client.GetAsync("presets.json"); + + message.EnsureSuccessStatusCode(); + + var json = await message.Content.ReadAsStringAsync(); + return PlaylistsParser.ParsePlaylists(json); + } + + public Task StartPlaylist(PlaylistDefinition playlist) + { + if (playlist is null) + { + throw new ArgumentNullException(nameof(playlist)); + } + + return Post(new StateRequest { Playlist = PlaylistRequest.From(playlist) }); + } + + public Task StartPlaylist(Action configure) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var builder = new PlaylistBuilder(); + configure(builder); + return StartPlaylist(builder.Build()); + } + + public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null) + { + if (playlist is null) + { + throw new ArgumentNullException(nameof(playlist)); + } + + options ??= new SavePresetOptions(); + + return Post(new StateRequest + { + SavePresetSlot = id, + PresetName = options.Name, + QuickLabel = options.QuickLabel, + SaveSegmentBounds = options.SaveSegmentBounds, + IncludeBrightness = options.IncludeBrightness, + SaveSelectedSegments = options.SaveSelectedSegments, + Playlist = PlaylistRequest.From(playlist) + }); + } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/PlaylistTests.cs b/test/Kevsoft.WLED.Tests/PlaylistTests.cs new file mode 100644 index 0000000..c6cb6de --- /dev/null +++ b/test/Kevsoft.WLED.Tests/PlaylistTests.cs @@ -0,0 +1,98 @@ +namespace Kevsoft.WLED.Tests; + +public class PlaylistTests +{ + [Fact] + public async Task StartPlaylistUnzipsEntriesIntoParallelArrays() + { + var (_, body) = await Capture(client => client.StartPlaylist(p => p + .Add(26, TimeSpan.FromSeconds(3)) + .Add(20, TimeSpan.FromSeconds(2), TimeSpan.FromMilliseconds(700)) + .Add(18, TimeSpan.FromSeconds(1)) + .Add(20, TimeSpan.FromSeconds(5)) + .Repeat(10) + .EndOn(21))); + + var playlist = JsonDocument.Parse(body!).RootElement.GetProperty("playlist"); + playlist.GetProperty("ps").EnumerateArray().Select(x => x.GetInt32()).Should().Equal(26, 20, 18, 20); + playlist.GetProperty("dur").EnumerateArray().Select(x => x.GetInt32()).Should().Equal(30, 20, 10, 50); + playlist.GetProperty("transition").EnumerateArray().Select(x => x.GetInt32()).Should().Equal(0, 7, 0, 0); + playlist.GetProperty("repeat").GetInt32().Should().Be(10); + playlist.GetProperty("end").GetInt32().Should().Be(21); + playlist.TryGetProperty("r", out _).Should().BeFalse(); + } + + [Fact] + public async Task StartPlaylistWithoutTransitionsOmitsTransitionArray() + { + var (_, body) = await Capture(client => client.StartPlaylist(p => p + .Add(1, TimeSpan.FromSeconds(1)) + .Add(2, TimeSpan.FromSeconds(2)) + .Shuffle())); + + var playlist = JsonDocument.Parse(body!).RootElement.GetProperty("playlist"); + playlist.TryGetProperty("transition", out _).Should().BeFalse(); + playlist.GetProperty("repeat").GetInt32().Should().Be(0); + playlist.GetProperty("r").GetBoolean().Should().BeTrue(); + playlist.TryGetProperty("end", out _).Should().BeFalse(); + } + + [Fact] + public async Task SavePlaylistCombinesPlaylistWithPsave() + { + var definition = new PlaylistDefinition + { + Entries = new[] { new PlaylistEntry(1, TimeSpan.FromSeconds(2)) }, + Repeat = 3 + }; + + var (_, body) = await Capture(client => client.SavePlaylist(7, definition, new SavePresetOptions { Name = "Loop" })); + + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("psave").GetInt32().Should().Be(7); + root.GetProperty("n").GetString().Should().Be("Loop"); + root.GetProperty("playlist").GetProperty("repeat").GetInt32().Should().Be(3); + } + + [Fact] + public async Task GetPlaylistsZipsParallelArraysAndBroadcastsScalars() + { + var json = @"{ + ""0"": {}, + ""1"": {""on"":true,""n"":""Scene"",""seg"":[]}, + ""2"": {""n"":""Cycle"",""playlist"":{""ps"":[5,6,7],""dur"":20,""transition"":[0,10,0],""repeat"":4,""end"":1}} + }"; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/presets.json", json); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var playlists = await client.GetPlaylists(); + + playlists.Keys.Should().BeEquivalentTo(new[] { 2 }); + var definition = playlists[2].Definition; + definition.Entries.Should().HaveCount(3); + definition.Entries[0].PresetId.Should().Be(5); + definition.Entries[0].Duration.Should().Be(TimeSpan.FromSeconds(2)); + definition.Entries[1].Duration.Should().Be(TimeSpan.FromSeconds(2)); + definition.Entries[1].Transition.Should().Be(TimeSpan.FromSeconds(1)); + definition.Entries[0].Transition.Should().Be(TimeSpan.Zero); + definition.Repeat.Should().Be(4); + definition.EndPresetId.Should().Be(1); + playlists[2].Name.Should().Be("Cycle"); + } + + private static async Task<(string Uri, string? Body)> Capture(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + var (uri, body) = mockHttpMessageHandler.CapturedRequests.Single(); + return (uri, body); + } +} From 351af856c4f9de325018b67afcf3a35ba08ee2aa Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:41:24 +0100 Subject: [PATCH 10/32] Plan 8: per-segment individual LED control with auto-chunking --- .../Fluent/IndividualLedBuilder.cs | 191 ++++++++++++++++++ src/Kevsoft.WLED/IWLedClient.cs | 10 + src/Kevsoft.WLED/IndividualLedData.cs | 40 ++++ .../IndividualLedDataJsonConverter.cs | 39 ++++ src/Kevsoft.WLED/SegmentRequest.cs | 5 + src/Kevsoft.WLED/WLedClient.cs | 22 ++ test/Kevsoft.WLED.Tests/IndividualLedTests.cs | 120 +++++++++++ .../MockHttpMessageHandler.cs | 9 +- 8 files changed, 434 insertions(+), 2 deletions(-) create mode 100644 src/Kevsoft.WLED/Fluent/IndividualLedBuilder.cs create mode 100644 src/Kevsoft.WLED/IndividualLedData.cs create mode 100644 src/Kevsoft.WLED/IndividualLedDataJsonConverter.cs create mode 100644 test/Kevsoft.WLED.Tests/IndividualLedTests.cs diff --git a/src/Kevsoft.WLED/Fluent/IndividualLedBuilder.cs b/src/Kevsoft.WLED/Fluent/IndividualLedBuilder.cs new file mode 100644 index 0000000..717fa93 --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/IndividualLedBuilder.cs @@ -0,0 +1,191 @@ +namespace Kevsoft.WLED; + +/// +/// Fluent builder for addressing individual LEDs within a segment. +/// +/// +/// Three intents are supported, mirroring the WLED i property: +/// +/// — colours applied sequentially from the start of the segment. +/// — a single LED at a segment-relative index. +/// — a contiguous run of LEDs. +/// +/// Indices are segment-relative. The builder owns the wire encoding (preferring compact hex over byte arrays) +/// and, via the client, the sequential chunking required for large sets. +/// +public sealed class IndividualLedBuilder +{ + private readonly List _ops = new(); + + /// Set LEDs sequentially starting at the first LED of the segment. + public IndividualLedBuilder Set(params Color[] sequentialFromStart) + { + if (sequentialFromStart is null) + { + throw new ArgumentNullException(nameof(sequentialFromStart)); + } + + if (sequentialFromStart.Length > 0) + { + _ops.Add(new SequentialOp(sequentialFromStart)); + } + + return this; + } + + /// Set a single LED at the given segment-relative . + public IndividualLedBuilder Set(int index, Color color) + { + if (index < 0) + { + throw new ArgumentOutOfRangeException(nameof(index), index, "LED index must not be negative."); + } + + _ops.Add(new IndexedOp(index, color)); + return this; + } + + /// Set a contiguous range of LEDs from (inclusive) to (exclusive). + public IndividualLedBuilder SetRange(int start, int stopExclusive, Color color) + { + if (start < 0) + { + throw new ArgumentOutOfRangeException(nameof(start), start, "Range start must not be negative."); + } + + if (stopExclusive <= start) + { + throw new ArgumentOutOfRangeException(nameof(stopExclusive), stopExclusive, "Range stop must be greater than start."); + } + + _ops.Add(new RangeOp(start, stopExclusive, color)); + return this; + } + + /// + /// Encode the accumulated operations into one or more requests, each containing at most + /// colours. + /// + internal IReadOnlyList Build(int maxColorsPerRequest) + { + if (maxColorsPerRequest < 1) + { + throw new ArgumentOutOfRangeException(nameof(maxColorsPerRequest), maxColorsPerRequest, "At least one colour per request is required."); + } + + var requests = new List(); + var current = new List(); + var colorCount = 0; + + void Flush() + { + if (current.Count > 0) + { + requests.Add(new IndividualLedData(current.ToArray())); + current = new List(); + colorCount = 0; + } + } + + foreach (var op in _ops) + { + switch (op) + { + case SequentialOp sequential: + var pos = 0; + while (pos < sequential.Colors.Count) + { + if (colorCount == maxColorsPerRequest) + { + Flush(); + } + + var take = Math.Min(maxColorsPerRequest - colorCount, sequential.Colors.Count - pos); + + // A piece needs an explicit start index unless it begins at LED 0 of an empty request. + if (pos != 0 || current.Count > 0) + { + current.Add(IndividualLedToken.FromIndex(pos)); + } + + for (var i = 0; i < take; i++) + { + current.Add(IndividualLedToken.FromColor(sequential.Colors[pos + i])); + } + + colorCount += take; + pos += take; + } + + break; + + case IndexedOp indexed: + if (colorCount + 1 > maxColorsPerRequest) + { + Flush(); + } + + current.Add(IndividualLedToken.FromIndex(indexed.Index)); + current.Add(IndividualLedToken.FromColor(indexed.Color)); + colorCount++; + break; + + case RangeOp range: + if (colorCount + 1 > maxColorsPerRequest) + { + Flush(); + } + + current.Add(IndividualLedToken.FromIndex(range.Start)); + current.Add(IndividualLedToken.FromIndex(range.StopExclusive)); + current.Add(IndividualLedToken.FromColor(range.Color)); + colorCount++; + break; + } + } + + Flush(); + + return requests; + } + + private interface IIndividualLedOp + { + } + + private sealed class SequentialOp : IIndividualLedOp + { + public SequentialOp(IReadOnlyList colors) => Colors = colors; + + public IReadOnlyList Colors { get; } + } + + private sealed class IndexedOp : IIndividualLedOp + { + public IndexedOp(int index, Color color) + { + Index = index; + Color = color; + } + + public int Index { get; } + + public Color Color { get; } + } + + private sealed class RangeOp : IIndividualLedOp + { + public RangeOp(int start, int stopExclusive, Color color) + { + Start = start; + StopExclusive = stopExclusive; + Color = color; + } + + public int Start { get; } + + public int StopExclusive { get; } + + public Color Color { get; } + } +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 61d8947..1646294 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -76,4 +76,14 @@ public interface IWLedClient /// Saves a playlist to the given preset slot. /// Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null); + + /// + /// Sets individual LEDs within a segment. + /// + /// + /// Setting LEDs freezes the segment, so brightness and power must be set in an earlier request. + /// Large sets are transparently split into multiple sequential requests (never parallel), each + /// carrying at most colours. + /// + Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/IndividualLedData.cs b/src/Kevsoft.WLED/IndividualLedData.cs new file mode 100644 index 0000000..ad15c81 --- /dev/null +++ b/src/Kevsoft.WLED/IndividualLedData.cs @@ -0,0 +1,40 @@ +namespace Kevsoft.WLED; + +/// +/// A single, write-only batch of individual-LED assignments destined for a segment's i property. +/// +/// +/// The WLED i array packs three different addressing forms (sequential, indexed and range) into one +/// heterogeneous JSON array. This type stores the already-encoded tokens for a single request so they can be +/// serialised verbatim; build instances through rather than by hand. +/// +[JsonConverter(typeof(IndividualLedDataJsonConverter))] +public sealed class IndividualLedData +{ + internal IndividualLedData(IReadOnlyList tokens) + { + Tokens = tokens; + } + + internal IReadOnlyList Tokens { get; } +} + +/// A single token within an array: either a positional index or a colour. +internal readonly struct IndividualLedToken +{ + private IndividualLedToken(int? index, Color? color) + { + Index = index; + Color = color; + } + + public int? Index { get; } + + public Color? Color { get; } + + public bool IsIndex => Index.HasValue; + + public static IndividualLedToken FromIndex(int index) => new(index, null); + + public static IndividualLedToken FromColor(Color color) => new(null, color); +} diff --git a/src/Kevsoft.WLED/IndividualLedDataJsonConverter.cs b/src/Kevsoft.WLED/IndividualLedDataJsonConverter.cs new file mode 100644 index 0000000..f20b870 --- /dev/null +++ b/src/Kevsoft.WLED/IndividualLedDataJsonConverter.cs @@ -0,0 +1,39 @@ +namespace Kevsoft.WLED; + +internal sealed class IndividualLedDataJsonConverter : JsonConverter +{ + public override IndividualLedData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotSupportedException($"{nameof(IndividualLedData)} is write-only; read current colours from /json/live instead."); + + public override void Write(Utf8JsonWriter writer, IndividualLedData value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var token in value.Tokens) + { + if (token.IsIndex) + { + writer.WriteNumberValue(token.Index!.Value); + } + else + { + var color = token.Color!.Value; + if (color.IsRgbw) + { + writer.WriteStartArray(); + writer.WriteNumberValue(color.R); + writer.WriteNumberValue(color.G); + writer.WriteNumberValue(color.B); + writer.WriteNumberValue(color.W!.Value); + writer.WriteEndArray(); + } + else + { + writer.WriteStringValue(color.ToHex()); + } + } + } + + writer.WriteEndArray(); + } +} diff --git a/src/Kevsoft.WLED/SegmentRequest.cs b/src/Kevsoft.WLED/SegmentRequest.cs index d083c74..47cb3c6 100644 --- a/src/Kevsoft.WLED/SegmentRequest.cs +++ b/src/Kevsoft.WLED/SegmentRequest.cs @@ -182,6 +182,11 @@ public sealed class SegmentRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? RepeatToFill { get; set; } + /// Write-only: individual LED assignments. Build through . + [JsonPropertyName("i")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IndividualLedData? IndividualLeds { get; set; } + public static SegmentRequest From(SegmentResponse segmentResponse) { return new SegmentRequest diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 782fdda..b02a31e 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -214,4 +214,26 @@ public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? Playlist = PlaylistRequest.From(playlist) }); } + + public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256) + { + if (build is null) + { + throw new ArgumentNullException(nameof(build)); + } + + var builder = new IndividualLedBuilder(); + build(builder); + + foreach (var request in builder.Build(maxColorsPerRequest)) + { + await Post(new StateRequest + { + Segments = new[] + { + new SegmentRequest { Id = segmentId, IndividualLeds = request } + } + }); + } + } } \ No newline at end of file diff --git a/test/Kevsoft.WLED.Tests/IndividualLedTests.cs b/test/Kevsoft.WLED.Tests/IndividualLedTests.cs new file mode 100644 index 0000000..7542002 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/IndividualLedTests.cs @@ -0,0 +1,120 @@ +namespace Kevsoft.WLED.Tests; + +public class IndividualLedTests +{ + [Fact] + public async Task SequentialFormEmitsBareHexColours() + { + var body = await CaptureSingle(client => client.SetIndividualLeds(0, b => b + .Set(Color.Rgb(255, 0, 0), Color.Rgb(0, 255, 0), Color.Rgb(0, 0, 255)))); + + var i = SegmentI(body); + i.Select(x => x.GetString()).Should().Equal("FF0000", "00FF00", "0000FF"); + } + + [Fact] + public async Task IndexedFormEmitsIndexColourPairs() + { + var body = await CaptureSingle(client => client.SetIndividualLeds(0, b => b + .Set(0, Color.Rgb(255, 0, 0)) + .Set(2, Color.Rgb(0, 255, 0)) + .Set(4, Color.Rgb(0, 0, 255)))); + + var i = SegmentI(body); + i[0].GetInt32().Should().Be(0); + i[1].GetString().Should().Be("FF0000"); + i[2].GetInt32().Should().Be(2); + i[3].GetString().Should().Be("00FF00"); + i[4].GetInt32().Should().Be(4); + i[5].GetString().Should().Be("0000FF"); + } + + [Fact] + public async Task RangeFormEmitsStartStopColourTriples() + { + var body = await CaptureSingle(client => client.SetIndividualLeds(0, b => b + .SetRange(0, 8, Color.Rgb(255, 0, 0)) + .SetRange(10, 18, Color.Rgb(0, 0, 255)))); + + var i = SegmentI(body); + i[0].GetInt32().Should().Be(0); + i[1].GetInt32().Should().Be(8); + i[2].GetString().Should().Be("FF0000"); + i[3].GetInt32().Should().Be(10); + i[4].GetInt32().Should().Be(18); + i[5].GetString().Should().Be("0000FF"); + } + + [Fact] + public async Task RgbwColoursFallBackToByteArrays() + { + var body = await CaptureSingle(client => client.SetIndividualLeds(0, b => b + .Set(Color.Rgbw(255, 0, 0, 128)))); + + var i = SegmentI(body); + i[0].ValueKind.Should().Be(JsonValueKind.Array); + i[0].EnumerateArray().Select(x => x.GetInt32()).Should().Equal(255, 0, 0, 128); + } + + [Fact] + public async Task LargeSequentialSetIsSplitIntoSequentialRequests() + { + var colors = Enumerable.Range(0, 600).Select(n => Color.Rgb((byte)(n % 256), 0, 0)).ToArray(); + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.SetIndividualLeds(0, b => b.Set(colors)); + + var requests = mockHttpMessageHandler.CapturedRequestList; + requests.Should().HaveCount(3); + + var first = SegmentI(requests[0].Body); + first[0].GetString().Should().Be("000000"); + first.Length.Should().Be(256); + + var second = SegmentI(requests[1].Body); + second[0].GetInt32().Should().Be(256); + second.Length.Should().Be(257); + + var third = SegmentI(requests[2].Body); + third[0].GetInt32().Should().Be(512); + third.Length.Should().Be(89); + } + + [Fact] + public void NegativeIndexThrows() + { + var builder = new IndividualLedBuilder(); + Action act = () => builder.Set(-1, Color.Rgb(0, 0, 0)); + act.Should().Throw(); + } + + [Fact] + public void RangeStopNotGreaterThanStartThrows() + { + var builder = new IndividualLedBuilder(); + Action act = () => builder.SetRange(5, 5, Color.Rgb(0, 0, 0)); + act.Should().Throw(); + } + + private static JsonElement[] SegmentI(string? body) + { + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg")[0]; + return seg.GetProperty("i").EnumerateArray().ToArray(); + } + + private static async Task CaptureSingle(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + return mockHttpMessageHandler.CapturedRequestList.Single().Body; + } +} diff --git a/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs b/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs index 745b145..9f718a3 100644 --- a/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs +++ b/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs @@ -8,8 +8,12 @@ public class MockHttpMessageHandler : HttpMessageHandler private readonly Dictionary _capturedRequests = new(StringComparer.InvariantCultureIgnoreCase); + private readonly List<(string Uri, string? Body)> _capturedRequestList = new(); + public Dictionary CapturedRequests => _capturedRequests; + public IReadOnlyList<(string Uri, string? Body)> CapturedRequestList => _capturedRequestList; + public void AppendResponse(string uri, string body) { _mockedResponses.Add(uri, (HttpStatusCode.OK, body)); @@ -23,8 +27,9 @@ public void AppendResponse(string uri) protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - CapturedRequests.Add(request.RequestUri!.AbsoluteUri, - await (request.Content?.ReadAsStringAsync(cancellationToken) ?? Task.FromResult(""))); + var body = await (request.Content?.ReadAsStringAsync(cancellationToken) ?? Task.FromResult("")); + _capturedRequests[request.RequestUri!.AbsoluteUri] = body; + _capturedRequestList.Add((request.RequestUri!.AbsoluteUri, body)); if (_mockedResponses.TryGetValue(request.RequestUri!.AbsoluteUri, out var value)) { From c0f364cc6da637b799d0d5448da72ce67e064792 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Fri, 29 May 2026 23:55:34 +0100 Subject: [PATCH 11/32] Plan 9: effect metadata parsing from /json/fxdata --- src/Kevsoft.WLED/EffectColorSlot.cs | 8 + src/Kevsoft.WLED/EffectControl.cs | 10 + src/Kevsoft.WLED/EffectDimensionality.cs | 19 ++ src/Kevsoft.WLED/EffectMetadata.cs | 90 +++++++ src/Kevsoft.WLED/EffectMetadataParser.cs | 221 ++++++++++++++++++ src/Kevsoft.WLED/IWLedClient.cs | 6 + src/Kevsoft.WLED/SegmentControl.cs | 43 ++++ src/Kevsoft.WLED/WLedClient.cs | 11 + .../Kevsoft.WLED.Tests/EffectMetadataTests.cs | 146 ++++++++++++ 9 files changed, 554 insertions(+) create mode 100644 src/Kevsoft.WLED/EffectColorSlot.cs create mode 100644 src/Kevsoft.WLED/EffectControl.cs create mode 100644 src/Kevsoft.WLED/EffectDimensionality.cs create mode 100644 src/Kevsoft.WLED/EffectMetadata.cs create mode 100644 src/Kevsoft.WLED/EffectMetadataParser.cs create mode 100644 src/Kevsoft.WLED/SegmentControl.cs create mode 100644 test/Kevsoft.WLED.Tests/EffectMetadataTests.cs diff --git a/src/Kevsoft.WLED/EffectColorSlot.cs b/src/Kevsoft.WLED/EffectColorSlot.cs new file mode 100644 index 0000000..8fb671b --- /dev/null +++ b/src/Kevsoft.WLED/EffectColorSlot.cs @@ -0,0 +1,8 @@ +namespace Kevsoft.WLED; + +/// +/// A colour slot an effect uses, with its slot key and display label. +/// +/// The colour slot key (Fx, Bg or Cs). +/// The display label, with effect-metadata defaults already applied. +public sealed record EffectColorSlot(string Key, string Label); diff --git a/src/Kevsoft.WLED/EffectControl.cs b/src/Kevsoft.WLED/EffectControl.cs new file mode 100644 index 0000000..62dfbe5 --- /dev/null +++ b/src/Kevsoft.WLED/EffectControl.cs @@ -0,0 +1,10 @@ +namespace Kevsoft.WLED; + +/// +/// A slider or checkbox control an effect uses, with its segment parameter key, label and value range. +/// +/// The segment parameter key the control maps to (e.g. sx, c3, o1). +/// The display label, with effect-metadata defaults already applied. +/// The lowest valid value for the control. +/// The highest valid value for the control. +public sealed record EffectControl(string Key, string Label, int Minimum, int Maximum); diff --git a/src/Kevsoft.WLED/EffectDimensionality.cs b/src/Kevsoft.WLED/EffectDimensionality.cs new file mode 100644 index 0000000..6f285aa --- /dev/null +++ b/src/Kevsoft.WLED/EffectDimensionality.cs @@ -0,0 +1,19 @@ +namespace Kevsoft.WLED; + +/// +/// The dimensionality an effect is designed for, derived from the effect metadata flags. +/// +public enum EffectDimensionality +{ + /// Optimised for 1D LED strips (flag 1, and the default). + OneDimensional, + + /// Requires a 2D matrix (flag 2). + TwoDimensional, + + /// Requires a 3D cube (flag 3). + ThreeDimensional, + + /// Works well on a single LED (flag 0). + SingleLed, +} diff --git a/src/Kevsoft.WLED/EffectMetadata.cs b/src/Kevsoft.WLED/EffectMetadata.cs new file mode 100644 index 0000000..18cf950 --- /dev/null +++ b/src/Kevsoft.WLED/EffectMetadata.cs @@ -0,0 +1,90 @@ +namespace Kevsoft.WLED; + +/// +/// Parsed metadata for a single effect, describing the controls it actually uses. +/// +/// The effect's id, matching its index in the effects list. +/// The effect's display name. +/// The slider controls the effect uses (a subset of sx, ix, c1, c2, c3). +/// The checkbox controls the effect uses (a subset of o1, o2, o3). +/// The colour slots the effect uses (a subset of Fx, Bg, Cs). +/// true if palette selection is enabled for the effect. +/// The dimensionality the effect is designed for. +/// true if the effect reacts to audio amplitude/volume. +/// true if the effect reacts to the audio frequency distribution. +/// Recommended default values for segment parameters, keyed by parameter name. +public sealed record EffectMetadata( + int EffectId, + string Name, + IReadOnlyList Sliders, + IReadOnlyList Options, + IReadOnlyList Colors, + bool UsesPalette, + EffectDimensionality Dimensionality, + bool ReactsToVolume, + bool ReactsToFrequency, + IReadOnlyDictionary Defaults) +{ + /// true if the effect is audio reactive (to either volume or frequency). + public bool IsAudioReactive => ReactsToVolume || ReactsToFrequency; + + /// + /// Returns true if the effect uses the given control. Setting a control the effect + /// does not use has no visible result. + /// + public bool Supports(SegmentControl control) => control switch + { + SegmentControl.Speed => HasSlider("sx"), + SegmentControl.Intensity => HasSlider("ix"), + SegmentControl.Custom1 => HasSlider("c1"), + SegmentControl.Custom2 => HasSlider("c2"), + SegmentControl.Custom3 => HasSlider("c3"), + SegmentControl.Option1 => HasOption("o1"), + SegmentControl.Option2 => HasOption("o2"), + SegmentControl.Option3 => HasOption("o3"), + SegmentControl.Color1 => HasColor("Fx"), + SegmentControl.Color2 => HasColor("Bg"), + SegmentControl.Color3 => HasColor("Cs"), + SegmentControl.Palette => UsesPalette, + _ => false + }; + + private bool HasSlider(string key) + { + foreach (var slider in Sliders) + { + if (slider.Key == key) + { + return true; + } + } + + return false; + } + + private bool HasOption(string key) + { + foreach (var option in Options) + { + if (option.Key == key) + { + return true; + } + } + + return false; + } + + private bool HasColor(string key) + { + foreach (var color in Colors) + { + if (color.Key == key) + { + return true; + } + } + + return false; + } +} diff --git a/src/Kevsoft.WLED/EffectMetadataParser.cs b/src/Kevsoft.WLED/EffectMetadataParser.cs new file mode 100644 index 0000000..cd7fefd --- /dev/null +++ b/src/Kevsoft.WLED/EffectMetadataParser.cs @@ -0,0 +1,221 @@ +namespace Kevsoft.WLED; + +internal static class EffectMetadataParser +{ + private static readonly (string Key, string DefaultLabel, int Min, int Max)[] SliderSlots = + { + ("sx", "Effect speed", 0, 255), + ("ix", "Effect intensity", 0, 255), + ("c1", "Custom 1", 0, 255), + ("c2", "Custom 2", 0, 255), + ("c3", "Custom 3", 0, 31), + }; + + private static readonly (string Key, string DefaultLabel)[] OptionSlots = + { + ("o1", "Option 1"), + ("o2", "Option 2"), + ("o3", "Option 3"), + }; + + private static readonly (string Key, string DefaultLabel)[] ColorSlots = + { + ("Fx", "Fx"), + ("Bg", "Bg"), + ("Cs", "Cs"), + }; + + public static IReadOnlyList Parse(string[] fxdata, string[] effectNames) + { + var result = new List(fxdata.Length); + + for (var id = 0; id < fxdata.Length; id++) + { + var name = id < effectNames.Length ? effectNames[id] : string.Empty; + + if (IsReserved(name)) + { + continue; + } + + result.Add(ParseSingle(id, name, fxdata[id])); + } + + return result; + } + + private static bool IsReserved(string name) + => name == "RSVD" || name == "-"; + + private static EffectMetadata ParseSingle(int id, string name, string data) + { + var sections = data.Split(';'); + + var (sliders, options) = ParseParameters(SectionOrNull(sections, 0)); + var colors = ParseColors(SectionOrNull(sections, 1)); + var usesPalette = ParsePalette(SectionOrNull(sections, 2)); + var (dimensionality, volume, frequency) = ParseFlags(SectionOrNull(sections, 3)); + var defaults = ParseDefaults(SectionOrNull(sections, 4)); + + return new EffectMetadata(id, name, sliders, options, colors, usesPalette, dimensionality, volume, frequency, defaults); + } + + private static string? SectionOrNull(string[] sections, int index) + => index < sections.Length ? sections[index] : null; + + private static (IReadOnlyList Sliders, IReadOnlyList Options) ParseParameters(string? section) + { + // A missing section falls back to two sliders; a present-but-empty section means no controls. + if (section is null) + { + return (new[] + { + new EffectControl(SliderSlots[0].Key, SliderSlots[0].DefaultLabel, SliderSlots[0].Min, SliderSlots[0].Max), + new EffectControl(SliderSlots[1].Key, SliderSlots[1].DefaultLabel, SliderSlots[1].Min, SliderSlots[1].Max), + }, Array.Empty()); + } + + var sliders = new List(); + var options = new List(); + + if (section.Length == 0) + { + return (sliders, options); + } + + var tokens = section.Split(','); + + for (var i = 0; i < tokens.Length; i++) + { + var label = tokens[i]; + if (label.Length == 0) + { + continue; + } + + if (i < SliderSlots.Length) + { + var slot = SliderSlots[i]; + sliders.Add(new EffectControl(slot.Key, Label(label, slot.DefaultLabel), slot.Min, slot.Max)); + } + else if (i - SliderSlots.Length < OptionSlots.Length) + { + var slot = OptionSlots[i - SliderSlots.Length]; + options.Add(new EffectControl(slot.Key, Label(label, slot.DefaultLabel), 0, 1)); + } + } + + return (sliders, options); + } + + private static IReadOnlyList ParseColors(string? section) + { + if (section is null) + { + return new[] + { + new EffectColorSlot(ColorSlots[0].Key, ColorSlots[0].DefaultLabel), + new EffectColorSlot(ColorSlots[1].Key, ColorSlots[1].DefaultLabel), + new EffectColorSlot(ColorSlots[2].Key, ColorSlots[2].DefaultLabel), + }; + } + + var colors = new List(); + + if (section.Length == 0) + { + return colors; + } + + var tokens = section.Split(','); + + for (var i = 0; i < tokens.Length && i < ColorSlots.Length; i++) + { + var label = tokens[i]; + if (label.Length == 0) + { + continue; + } + + colors.Add(new EffectColorSlot(ColorSlots[i].Key, Label(label, ColorSlots[i].DefaultLabel))); + } + + return colors; + } + + private static bool ParsePalette(string? section) + { + // Fallback (missing) is enabled; present-but-empty means no palette. + if (section is null) + { + return true; + } + + return section.Length > 0; + } + + private static (EffectDimensionality Dimensionality, bool Volume, bool Frequency) ParseFlags(string? section) + { + if (string.IsNullOrEmpty(section)) + { + return (EffectDimensionality.OneDimensional, false, false); + } + + var volume = false; + var frequency = false; + bool single = false, oneD = false, twoD = false, threeD = false; + + foreach (var flag in section!) + { + switch (flag) + { + case '0': single = true; break; + case '1': oneD = true; break; + case '2': twoD = true; break; + case '3': threeD = true; break; + case 'v': volume = true; break; + case 'f': frequency = true; break; + } + } + + var dimensionality = single ? EffectDimensionality.SingleLed + : oneD ? EffectDimensionality.OneDimensional + : twoD ? EffectDimensionality.TwoDimensional + : threeD ? EffectDimensionality.ThreeDimensional + : EffectDimensionality.OneDimensional; + + return (dimensionality, volume, frequency); + } + + private static IReadOnlyDictionary ParseDefaults(string? section) + { + var defaults = new Dictionary(); + + if (string.IsNullOrEmpty(section)) + { + return defaults; + } + + foreach (var pair in section!.Split(',')) + { + var separator = pair.IndexOf('='); + if (separator <= 0) + { + continue; + } + + var key = pair.Substring(0, separator); + var value = pair.Substring(separator + 1); + + if (int.TryParse(value, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + defaults[key] = parsed; + } + } + + return defaults; + } + + private static string Label(string label, string defaultLabel) + => label == "!" ? defaultLabel : label; +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 1646294..3510b06 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -86,4 +86,10 @@ public interface IWLedClient /// carrying at most colours. /// Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256); + + /// + /// Gets parsed effect metadata from /json/fxdata, describing which controls each effect uses. + /// Reserved effects are excluded; stays aligned with the effects list. + /// + Task> GetEffectMetadata(); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/SegmentControl.cs b/src/Kevsoft.WLED/SegmentControl.cs new file mode 100644 index 0000000..a14ac79 --- /dev/null +++ b/src/Kevsoft.WLED/SegmentControl.cs @@ -0,0 +1,43 @@ +namespace Kevsoft.WLED; + +/// +/// A single segment control an effect exposes, used to query effect metadata. +/// +public enum SegmentControl +{ + /// Effect speed slider (sx). + Speed, + + /// Effect intensity slider (ix). + Intensity, + + /// Custom slider 1 (c1). + Custom1, + + /// Custom slider 2 (c2). + Custom2, + + /// Custom slider 3 (c3). + Custom3, + + /// Option checkbox 1 (o1). + Option1, + + /// Option checkbox 2 (o2). + Option2, + + /// Option checkbox 3 (o3). + Option3, + + /// Primary colour slot (Fx). + Color1, + + /// Background colour slot (Bg). + Color2, + + /// Custom colour slot (Cs). + Color3, + + /// Palette selection. + Palette, +} diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index b02a31e..a07d7ab 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -215,6 +215,17 @@ public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? }); } + public async Task> GetEffectMetadata() + { + var fxdataMessage = await _client.GetAsync("json/fxdata"); + fxdataMessage.EnsureSuccessStatusCode(); + var fxdata = (await fxdataMessage.Content.ReadFromJsonAsync())!; + + var effects = await GetEffects(); + + return EffectMetadataParser.Parse(fxdata, effects); + } + public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256) { if (build is null) diff --git a/test/Kevsoft.WLED.Tests/EffectMetadataTests.cs b/test/Kevsoft.WLED.Tests/EffectMetadataTests.cs new file mode 100644 index 0000000..69ffdc5 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/EffectMetadataTests.cs @@ -0,0 +1,146 @@ +namespace Kevsoft.WLED.Tests; + +public class EffectMetadataTests +{ + [Fact] + public async Task ParsesAuroraExample() + { + var metadata = await Parse( + new[] { "!,!;;!;1;sx=24,pal=50" }, + new[] { "Aurora" }); + + var aurora = metadata.Single(); + aurora.EffectId.Should().Be(0); + aurora.Name.Should().Be("Aurora"); + aurora.Sliders.Select(s => (s.Key, s.Label)).Should().Equal(("sx", "Effect speed"), ("ix", "Effect intensity")); + aurora.Options.Should().BeEmpty(); + aurora.Colors.Should().BeEmpty(); + aurora.UsesPalette.Should().BeTrue(); + aurora.Dimensionality.Should().Be(EffectDimensionality.OneDimensional); + aurora.Defaults.Should().BeEquivalentTo(new Dictionary { ["sx"] = 24, ["pal"] = 50 }); + } + + [Fact] + public async Task ParsesSliderAndCheckboxPositions() + { + var metadata = await Parse( + new[] { ",Saturation,,,,Invert" }, + new[] { "X" }); + + var x = metadata.Single(); + x.Sliders.Select(s => (s.Key, s.Label)).Should().Equal(("ix", "Saturation")); + x.Options.Select(o => (o.Key, o.Label)).Should().Equal(("o1", "Invert")); + } + + [Fact] + public async Task ParsesCheckboxOnly() + { + var metadata = await Parse( + new[] { ",,,,,Random colors" }, + new[] { "X" }); + + var x = metadata.Single(); + x.Sliders.Should().BeEmpty(); + x.Options.Select(o => (o.Key, o.Label)).Should().Equal(("o1", "Random colors")); + } + + [Fact] + public async Task Custom3SliderReportsRestrictedRange() + { + var metadata = await Parse( + new[] { "!,!,Custom1,Custom2,Custom3" }, + new[] { "X" }); + + var custom3 = metadata.Single().Sliders.Single(s => s.Key == "c3"); + custom3.Minimum.Should().Be(0); + custom3.Maximum.Should().Be(31); + } + + [Fact] + public async Task ParsesVolumeReactiveTwoDimensionalFlags() + { + var metadata = await Parse( + new[] { "!,!;!,!,!;!;2v" }, + new[] { "X" }); + + var x = metadata.Single(); + x.Dimensionality.Should().Be(EffectDimensionality.TwoDimensional); + x.ReactsToVolume.Should().BeTrue(); + x.ReactsToFrequency.Should().BeFalse(); + x.IsAudioReactive.Should().BeTrue(); + } + + [Fact] + public async Task AppliesFallbacksWhenLaterSectionsMissing() + { + var metadata = await Parse( + new[] { "!,!" }, + new[] { "Solid" }); + + var solid = metadata.Single(); + solid.Sliders.Select(s => s.Key).Should().Equal("sx", "ix"); + solid.Colors.Select(c => c.Key).Should().Equal("Fx", "Bg", "Cs"); + solid.UsesPalette.Should().BeTrue(); + solid.Dimensionality.Should().Be(EffectDimensionality.OneDimensional); + } + + [Fact] + public async Task EmptyParameterSectionMeansNoSliders() + { + var metadata = await Parse( + new[] { "" }, + new[] { "Solid" }); + + var solid = metadata.Single(); + solid.Sliders.Should().BeEmpty(); + solid.Options.Should().BeEmpty(); + solid.Colors.Select(c => c.Key).Should().Equal("Fx", "Bg", "Cs"); + solid.UsesPalette.Should().BeTrue(); + } + + [Fact] + public async Task EmptyColorSectionMeansNoColors() + { + var metadata = await Parse( + new[] { "!;;!;1" }, + new[] { "X" }); + + metadata.Single().Colors.Should().BeEmpty(); + } + + [Fact] + public async Task FiltersReservedEffectsButKeepsIdsAligned() + { + var metadata = await Parse( + new[] { "!,!", "", "!,!" }, + new[] { "Aurora", "RSVD", "Solid" }); + + metadata.Select(m => (m.EffectId, m.Name)).Should().Equal((0, "Aurora"), (2, "Solid")); + } + + [Fact] + public async Task SupportsReportsControlUsage() + { + var metadata = await Parse( + new[] { "!,!;!;!;1" }, + new[] { "X" }); + + var x = metadata.Single(); + x.Supports(SegmentControl.Speed).Should().BeTrue(); + x.Supports(SegmentControl.Custom3).Should().BeFalse(); + x.Supports(SegmentControl.Color1).Should().BeTrue(); + x.Supports(SegmentControl.Color2).Should().BeFalse(); + x.Supports(SegmentControl.Palette).Should().BeTrue(); + } + + private static async Task> Parse(string[] fxdata, string[] effects) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/fxdata", JsonSerializer.Serialize(fxdata)); + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/eff", JsonSerializer.Serialize(effects)); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + return await client.GetEffectMetadata(); + } +} From c8196587b36b0d47986ec39b15506e14895f7297 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 00:03:02 +0100 Subject: [PATCH 12/32] Plan 10: config API with safe partial writes and node discovery --- src/Kevsoft.WLED/Config/DeviceConfig.cs | 100 +++++++++++++++ .../Config/UpdateConfigOptions.cs | 13 ++ src/Kevsoft.WLED/IWLedClient.cs | 17 +++ src/Kevsoft.WLED/WLedClient.cs | 47 +++++++ src/Kevsoft.WLED/WledNode.cs | 34 +++++ .../Kevsoft.WLED.Tests/ConfigAndNodesTests.cs | 120 ++++++++++++++++++ 6 files changed, 331 insertions(+) create mode 100644 src/Kevsoft.WLED/Config/DeviceConfig.cs create mode 100644 src/Kevsoft.WLED/Config/UpdateConfigOptions.cs create mode 100644 src/Kevsoft.WLED/WledNode.cs create mode 100644 test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs diff --git a/src/Kevsoft.WLED/Config/DeviceConfig.cs b/src/Kevsoft.WLED/Config/DeviceConfig.cs new file mode 100644 index 0000000..1524558 --- /dev/null +++ b/src/Kevsoft.WLED/Config/DeviceConfig.cs @@ -0,0 +1,100 @@ +namespace Kevsoft.WLED; + +/// +/// Base for a device configuration section. Any keys not explicitly modelled are preserved in +/// so a read-modify-write cycle never drops firmware-specific configuration. +/// +public abstract class ConfigSection +{ + /// Configuration keys within this section that are not explicitly modelled. + [JsonExtensionData] + public Dictionary Unknown { get; set; } = new(); +} + +/// Device identity configuration (cfg.id). +public sealed class IdentityConfig : ConfigSection +{ + /// The friendly device name. + [JsonPropertyName("name")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Name { get; set; } +} + +/// Network/Wi-Fi client configuration (cfg.nw). Changing this can disconnect the device. +public sealed class NetworkConfig : ConfigSection +{ +} + +/// Access-point configuration (cfg.ap). Changing this can disconnect the device. +public sealed class AccessPointConfig : ConfigSection +{ +} + +/// Hardware configuration including LED bus layout (cfg.hw). +public sealed class HardwareConfig : ConfigSection +{ +} + +/// Interface configuration such as sync, MQTT and time (cfg.if). +public sealed class InterfacesConfig : ConfigSection +{ +} + +/// Light/behaviour configuration (cfg.light). +public sealed class LightConfig : ConfigSection +{ +} + +/// Boot default configuration (cfg.def). +public sealed class DefaultsConfig : ConfigSection +{ +} + +/// +/// The device configuration exposed by /json/cfg. +/// +/// +/// Only the sections you set are serialised, so a partial update never blanks untouched configuration. +/// Unknown top-level keys round-trip losslessly through . +/// +public sealed class DeviceConfig +{ + /// Identity configuration (id). + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IdentityConfig? Identity { get; set; } + + /// Network/Wi-Fi client configuration (nw). + [JsonPropertyName("nw")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public NetworkConfig? Network { get; set; } + + /// Access-point configuration (ap). + [JsonPropertyName("ap")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AccessPointConfig? AccessPoint { get; set; } + + /// Hardware configuration (hw). + [JsonPropertyName("hw")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public HardwareConfig? Hardware { get; set; } + + /// Interface configuration (if). + [JsonPropertyName("if")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public InterfacesConfig? Interfaces { get; set; } + + /// Light/behaviour configuration (light). + [JsonPropertyName("light")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LightConfig? Light { get; set; } + + /// Boot default configuration (def). + [JsonPropertyName("def")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DefaultsConfig? Defaults { get; set; } + + /// Top-level configuration keys that are not explicitly modelled. + [JsonExtensionData] + public Dictionary Unknown { get; set; } = new(); +} diff --git a/src/Kevsoft.WLED/Config/UpdateConfigOptions.cs b/src/Kevsoft.WLED/Config/UpdateConfigOptions.cs new file mode 100644 index 0000000..c5c1cfe --- /dev/null +++ b/src/Kevsoft.WLED/Config/UpdateConfigOptions.cs @@ -0,0 +1,13 @@ +namespace Kevsoft.WLED; + +/// +/// Options controlling how a update is applied. +/// +public sealed class UpdateConfigOptions +{ + /// + /// Must be explicitly set to true to allow updating the network (nw) or + /// access-point (ap) sections, which can disconnect the device. Defaults to false. + /// + public bool AllowNetworkChanges { get; set; } +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 3510b06..3efb7f0 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -92,4 +92,21 @@ public interface IWLedClient /// Reserved effects are excluded; stays aligned with the effects list. /// Task> GetEffectMetadata(); + + /// + /// Gets other WLED devices discovered on the local network via /json/nodes. + /// + Task> GetNodes(); + + /// + /// Gets the full device configuration from /json/cfg. + /// + Task GetConfig(); + + /// + /// Applies a partial device configuration update to /json/cfg. Only the sections set on + /// are sent. Updating the network or access-point sections requires + /// opting in via . + /// + Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null); } \ No newline at end of file diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index a07d7ab..e1fb561 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -226,6 +226,53 @@ public async Task> GetEffectMetadata() return EffectMetadataParser.Parse(fxdata, effects); } + public async Task> GetNodes() + { + var message = await _client.GetAsync("json/nodes"); + + message.EnsureSuccessStatusCode(); + + if (message.Content.Headers.ContentLength == 0) + { + return Array.Empty(); + } + + var response = await message.Content.ReadFromJsonAsync(); + return response?.Nodes ?? Array.Empty(); + } + + public async Task GetConfig() + { + var message = await _client.GetAsync("json/cfg"); + + message.EnsureSuccessStatusCode(); + + return (await message.Content.ReadFromJsonAsync())!; + } + + public async Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null) + { + if (partial is null) + { + throw new ArgumentNullException(nameof(partial)); + } + + options ??= new UpdateConfigOptions(); + + if (!options.AllowNetworkChanges && (partial.Network is not null || partial.AccessPoint is not null)) + { + throw new InvalidOperationException( + "Updating the network (nw) or access-point (ap) configuration can disconnect the device. " + + "Set UpdateConfigOptions.AllowNetworkChanges to true to permit it."); + } + + var configString = JsonSerializer.Serialize(partial); + + using var content = new StringContentWithoutCharset(configString, "application/json"); + var result = await _client.PostAsync("/json/cfg", content); + result.EnsureSuccessStatusCode(); + } + public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256) { if (build is null) diff --git a/src/Kevsoft.WLED/WledNode.cs b/src/Kevsoft.WLED/WledNode.cs new file mode 100644 index 0000000..4044eae --- /dev/null +++ b/src/Kevsoft.WLED/WledNode.cs @@ -0,0 +1,34 @@ +namespace Kevsoft.WLED; + +/// +/// Another WLED device discovered on the local network via /json/nodes. +/// +public sealed class WledNode +{ + /// The friendly name of the device. + [JsonPropertyName("name")] + public string Name { get; set; } = null!; + + /// The device IP address. + [JsonPropertyName("ip")] + public string IpAddress { get; set; } = null!; + + /// The board/hardware type identifier. + [JsonPropertyName("type")] + public int BoardType { get; set; } + + /// The build id (vid, format YYMMDDB). + [JsonPropertyName("vid")] + public uint BuildId { get; set; } + + /// Seconds since the node was last seen. + [JsonPropertyName("age")] + public int Age { get; set; } +} + +/// Wrapper for the /json/nodes response. +internal sealed class NodesResponse +{ + [JsonPropertyName("nodes")] + public WledNode[] Nodes { get; set; } = Array.Empty(); +} diff --git a/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs new file mode 100644 index 0000000..518eaae --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs @@ -0,0 +1,120 @@ +namespace Kevsoft.WLED.Tests; + +public class ConfigAndNodesTests +{ + [Fact] + public async Task GetNodesParsesNodeList() + { + var json = @"{""nodes"":[ + {""name"":""Desk"",""type"":32,""ip"":""192.168.1.5"",""age"":0,""vid"":2310130}, + {""name"":""Shelf"",""type"":32,""ip"":""192.168.1.6"",""age"":3,""vid"":2310130} + ]}"; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/nodes", json); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var nodes = await client.GetNodes(); + + nodes.Should().HaveCount(2); + nodes[0].Name.Should().Be("Desk"); + nodes[0].IpAddress.Should().Be("192.168.1.5"); + nodes[0].BoardType.Should().Be(32); + nodes[0].BuildId.Should().Be(2310130u); + nodes[1].Age.Should().Be(3); + } + + [Fact] + public async Task GetNodesReturnsEmptyForEmptyPayload() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/nodes", @"{""nodes"":[]}"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var nodes = await client.GetNodes(); + + nodes.Should().BeEmpty(); + } + + [Fact] + public async Task GetConfigPreservesUnknownKeys() + { + var json = @"{ + ""id"":{""name"":""WLED"",""mdns"":""wled-desk""}, + ""nw"":{""ins"":[{""ssid"":""home""}]}, + ""hw"":{""led"":{""total"":30}}, + ""custom_firmware_key"":42 + }"; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/cfg", json); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var config = await client.GetConfig(); + + config.Identity!.Name.Should().Be("WLED"); + config.Identity.Unknown.Should().ContainKey("mdns"); + config.Unknown.Should().ContainKey("custom_firmware_key"); + + // Round-trip should preserve the unknown keys. + var roundTripped = JsonSerializer.Serialize(config); + var element = JsonDocument.Parse(roundTripped).RootElement; + element.GetProperty("id").GetProperty("mdns").GetString().Should().Be("wled-desk"); + element.GetProperty("custom_firmware_key").GetInt32().Should().Be(42); + element.GetProperty("hw").GetProperty("led").GetProperty("total").GetInt32().Should().Be(30); + } + + [Fact] + public async Task UpdateConfigEmitsOnlyTouchedSection() + { + var partial = new DeviceConfig { Identity = new IdentityConfig { Name = "Kitchen" } }; + + var (_, body) = await Capture(client => client.UpdateConfig(partial)); + + var element = JsonDocument.Parse(body!).RootElement; + element.GetProperty("id").GetProperty("name").GetString().Should().Be("Kitchen"); + element.TryGetProperty("nw", out _).Should().BeFalse(); + element.TryGetProperty("hw", out _).Should().BeFalse(); + } + + [Fact] + public async Task UpdateConfigThrowsOnNetworkChangeWithoutOptIn() + { + var partial = new DeviceConfig { Network = new NetworkConfig() }; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/cfg"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + Func act = () => client.UpdateConfig(partial); + + await act.Should().ThrowAsync(); + mockHttpMessageHandler.CapturedRequestList.Should().BeEmpty(); + } + + [Fact] + public async Task UpdateConfigAllowsNetworkChangeWhenOptedIn() + { + var partial = new DeviceConfig { Network = new NetworkConfig() }; + + var (_, body) = await Capture(client => client.UpdateConfig(partial, new UpdateConfigOptions { AllowNetworkChanges = true })); + + JsonDocument.Parse(body!).RootElement.TryGetProperty("nw", out _).Should().BeTrue(); + } + + private static async Task<(string Uri, string? Body)> Capture(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/cfg"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + return mockHttpMessageHandler.CapturedRequestList.Single(); + } +} From cf30b9a97a60d2ddbcbfaf296db992a55ee00381 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 00:11:23 +0100 Subject: [PATCH 13/32] Plan 11: client ergonomics, typed exceptions, cancellation and DI - Add intent methods (TurnOn/TurnOff/Toggle/SetBrightness/SetColor/SetEffect/SetPalette/Reboot) - Add CancellationToken to every client method - Add typed exception hierarchy (WledException + connection/response/version) - Add HttpClient constructor and Kevsoft.WLED.DependencyInjection AddWledClient - Add intent, exception and DI tests --- WLED.NET.sln | 15 + .../Kevsoft.WLED.DependencyInjection.csproj | 34 ++ .../WLedServiceCollectionExtensions.cs | 48 +++ src/Kevsoft.WLED/Exceptions/WledExceptions.cs | 68 ++++ src/Kevsoft.WLED/IWLedClient.cs | 77 +++-- src/Kevsoft.WLED/StateRequest.cs | 7 + src/Kevsoft.WLED/WLedClient.cs | 307 +++++++++++------- .../DependencyInjectionTests.cs | 44 +++ test/Kevsoft.WLED.Tests/ExceptionTests.cs | 80 +++++ test/Kevsoft.WLED.Tests/IntentMethodTests.cs | 118 +++++++ .../Kevsoft.WLED.Tests.csproj | 1 + .../MockHttpMessageHandler.cs | 13 + 12 files changed, 663 insertions(+), 149 deletions(-) create mode 100644 src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj create mode 100644 src/Kevsoft.WLED.DependencyInjection/WLedServiceCollectionExtensions.cs create mode 100644 src/Kevsoft.WLED/Exceptions/WledExceptions.cs create mode 100644 test/Kevsoft.WLED.Tests/DependencyInjectionTests.cs create mode 100644 test/Kevsoft.WLED.Tests/ExceptionTests.cs create mode 100644 test/Kevsoft.WLED.Tests/IntentMethodTests.cs diff --git a/WLED.NET.sln b/WLED.NET.sln index f53d13a..0cba09a 100644 --- a/WLED.NET.sln +++ b/WLED.NET.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{4717 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BasicConsole", "samples\BasicConsole\BasicConsole.csproj", "{C999A473-8438-4CB8-B17D-1E41FCFA8FE2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kevsoft.WLED.DependencyInjection", "src\Kevsoft.WLED.DependencyInjection\Kevsoft.WLED.DependencyInjection.csproj", "{9605865D-1F16-4545-8E58-10E01A73556A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,18 @@ Global {C999A473-8438-4CB8-B17D-1E41FCFA8FE2}.Release|x64.Build.0 = Release|Any CPU {C999A473-8438-4CB8-B17D-1E41FCFA8FE2}.Release|x86.ActiveCfg = Release|Any CPU {C999A473-8438-4CB8-B17D-1E41FCFA8FE2}.Release|x86.Build.0 = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|x64.Build.0 = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Debug|x86.Build.0 = Debug|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|Any CPU.Build.0 = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|x64.ActiveCfg = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|x64.Build.0 = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|x86.ActiveCfg = Release|Any CPU + {9605865D-1F16-4545-8E58-10E01A73556A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +83,7 @@ Global {47DCC7AD-B808-4EB8-A97C-9CB15741491A} = {7E21839E-604A-430D-8130-65A2BE734BAE} {11742304-9022-452B-BB99-5F243014C2AC} = {F98C6650-E3D8-44E7-AD39-0B94E45D9C61} {C999A473-8438-4CB8-B17D-1E41FCFA8FE2} = {4717F3F2-EB30-4C37-873D-FDF6E11A0FA6} + {9605865D-1F16-4545-8E58-10E01A73556A} = {7E21839E-604A-430D-8130-65A2BE734BAE} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E2436D4C-C409-42E9-A77E-8113D7ECFC5B} diff --git a/src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj b/src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj new file mode 100644 index 0000000..0ecfff6 --- /dev/null +++ b/src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj @@ -0,0 +1,34 @@ + + + + $(LibraryTargetFrameworks) + Kevsoft.WLED + + + + WLED.DependencyInjection + WLED.NET Dependency Injection + WLED;DependencyInjection;IHttpClientFactory + icon.png + MIT + Microsoft.Extensions.DependencyInjection integration for WLED.NET. + True + true + true + snupkg + true + + + + + + + + + + + + + + + diff --git a/src/Kevsoft.WLED.DependencyInjection/WLedServiceCollectionExtensions.cs b/src/Kevsoft.WLED.DependencyInjection/WLedServiceCollectionExtensions.cs new file mode 100644 index 0000000..7738957 --- /dev/null +++ b/src/Kevsoft.WLED.DependencyInjection/WLedServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Kevsoft.WLED; + +/// +/// Registers with an IServiceCollection using +/// IHttpClientFactory so the underlying is pooled and managed +/// correctly. +/// +public static class WLedServiceCollectionExtensions +{ + /// + /// Registers for the WLED device at . + /// + public static IHttpClientBuilder AddWledClient(this IServiceCollection services, string baseAddress) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (string.IsNullOrWhiteSpace(baseAddress)) + { + throw new ArgumentException("A base address is required.", nameof(baseAddress)); + } + + return services.AddWledClient(client => client.BaseAddress = new Uri(baseAddress, UriKind.Absolute)); + } + + /// + /// Registers , configuring the underlying + /// (for example its and timeout). + /// + public static IHttpClientBuilder AddWledClient(this IServiceCollection services, Action configureClient) + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureClient is null) + { + throw new ArgumentNullException(nameof(configureClient)); + } + + return services.AddHttpClient(configureClient); + } +} diff --git a/src/Kevsoft.WLED/Exceptions/WledExceptions.cs b/src/Kevsoft.WLED/Exceptions/WledExceptions.cs new file mode 100644 index 0000000..e7f9d09 --- /dev/null +++ b/src/Kevsoft.WLED/Exceptions/WledExceptions.cs @@ -0,0 +1,68 @@ +namespace Kevsoft.WLED; + +/// +/// Base type for all errors raised by the WLED client. +/// +public class WledException : Exception +{ + /// Creates a new . + public WledException(string message) : base(message) + { + } + + /// Creates a new wrapping an inner exception. + public WledException(string message, Exception innerException) : base(message, innerException) + { + } +} + +/// +/// Raised when the device could not be reached (transport failure, DNS, timeout, refused connection). +/// +public sealed class WledConnectionException : WledException +{ + /// Creates a new . + public WledConnectionException(string message, Exception innerException) : base(message, innerException) + { + } +} + +/// +/// Raised when the device returned a non-success (non-2xx) HTTP response. +/// +public sealed class WledResponseException : WledException +{ + /// Creates a new . + public WledResponseException(int statusCode, string? body) + : base($"The WLED device responded with HTTP status {statusCode}.") + { + StatusCode = statusCode; + Body = body; + } + + /// The HTTP status code returned by the device. + public int StatusCode { get; } + + /// The response body, if any. + public string? Body { get; } +} + +/// +/// Raised when the device firmware is older than the minimum version the client supports. +/// +public sealed class WledUnsupportedVersionException : WledException +{ + /// Creates a new . + public WledUnsupportedVersionException(string version, string minimumVersion) + : base($"The WLED device firmware version '{version}' is older than the minimum supported version '{minimumVersion}'.") + { + Version = version; + MinimumVersion = minimumVersion; + } + + /// The firmware version reported by the device. + public string Version { get; } + + /// The minimum firmware version supported by the client. + public string MinimumVersion { get; } +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 3efb7f0..3a4fc4c 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -2,80 +2,107 @@ public interface IWLedClient { - Task Get(); + Task Get(CancellationToken cancellationToken = default); - Task GetState(); + Task GetState(CancellationToken cancellationToken = default); - Task GetInformation(); + Task GetInformation(CancellationToken cancellationToken = default); /// /// Gets the lighter /json/si response, containing only the state and info objects. /// - Task GetStateInfo(); + Task GetStateInfo(CancellationToken cancellationToken = default); /// /// Gets the nearby Wi-Fi networks reported by /json/net. /// - Task GetNetworks(); + Task GetNetworks(CancellationToken cancellationToken = default); /// /// Gets the live LED colour stream from /json/live, or null if the firmware /// was not built with JSON-live support. /// - Task GetLiveColors(); + Task GetLiveColors(CancellationToken cancellationToken = default); - Task GetEffects(); + Task GetEffects(CancellationToken cancellationToken = default); - Task GetPalettes(); + Task GetPalettes(CancellationToken cancellationToken = default); - Task Post(WLedRootRequest request); + Task Post(WLedRootRequest request, CancellationToken cancellationToken = default); - Task Post(StateRequest request); + Task Post(StateRequest request, CancellationToken cancellationToken = default); /// /// Builds and posts a sparse state update using a fluent builder. /// - Task UpdateState(Action configure); + Task UpdateState(Action configure, CancellationToken cancellationToken = default); + + /// Turns the light on. + Task TurnOn(CancellationToken cancellationToken = default); + + /// Turns the light off. + Task TurnOff(CancellationToken cancellationToken = default); + + /// Toggles the light on/off. + Task Toggle(CancellationToken cancellationToken = default); + + /// Sets, nudges or wraps the master brightness (0–255). + Task SetBrightness(ByteAdjust brightness, CancellationToken cancellationToken = default); + + /// Sets an RGB colour on a segment, or the selected segments when no id is given. + Task SetColor(RgbColor color, int? segmentId = null, CancellationToken cancellationToken = default); + + /// Sets an RGBW colour on a segment, or the selected segments when no id is given. + Task SetColor(RgbwColor color, int? segmentId = null, CancellationToken cancellationToken = default); + + /// Sets the effect on a segment, or the selected segments when no id is given. + Task SetEffect(Selector effect, int? segmentId = null, CancellationToken cancellationToken = default); + + /// Sets the palette on a segment, or the selected segments when no id is given. + Task SetPalette(Selector palette, int? segmentId = null, CancellationToken cancellationToken = default); + + /// Reboots the device. + Task Reboot(CancellationToken cancellationToken = default); /// /// Gets the saved presets, keyed by slot id. Playlists and the scratch slot are excluded. /// - Task> GetPresets(); + Task> GetPresets(CancellationToken cancellationToken = default); /// /// Applies a preset by id, or cycles/randomises between presets. /// - Task ApplyPreset(PresetSelector preset); + Task ApplyPreset(PresetSelector preset, CancellationToken cancellationToken = default); /// /// Saves the device's current live state to the given preset slot. /// - Task SavePreset(int id, SavePresetOptions? options = null); + Task SavePreset(int id, SavePresetOptions? options = null, CancellationToken cancellationToken = default); /// /// Deletes the preset in the given slot. /// - Task DeletePreset(int id); + Task DeletePreset(int id, CancellationToken cancellationToken = default); /// /// Gets the saved playlists, keyed by slot id. /// - Task> GetPlaylists(); + Task> GetPlaylists(CancellationToken cancellationToken = default); /// /// Starts the given playlist immediately. /// - Task StartPlaylist(PlaylistDefinition playlist); + Task StartPlaylist(PlaylistDefinition playlist, CancellationToken cancellationToken = default); /// /// Builds and starts a playlist using a fluent builder. /// - Task StartPlaylist(Action configure); + Task StartPlaylist(Action configure, CancellationToken cancellationToken = default); /// /// Saves a playlist to the given preset slot. /// - Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null); + Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null, CancellationToken cancellationToken = default); /// /// Sets individual LEDs within a segment. @@ -85,28 +112,28 @@ public interface IWLedClient /// Large sets are transparently split into multiple sequential requests (never parallel), each /// carrying at most colours. /// - Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256); + Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256, CancellationToken cancellationToken = default); /// /// Gets parsed effect metadata from /json/fxdata, describing which controls each effect uses. /// Reserved effects are excluded; stays aligned with the effects list. /// - Task> GetEffectMetadata(); + Task> GetEffectMetadata(CancellationToken cancellationToken = default); /// /// Gets other WLED devices discovered on the local network via /json/nodes. /// - Task> GetNodes(); + Task> GetNodes(CancellationToken cancellationToken = default); /// /// Gets the full device configuration from /json/cfg. /// - Task GetConfig(); + Task GetConfig(CancellationToken cancellationToken = default); /// /// Applies a partial device configuration update to /json/cfg. Only the sections set on /// are sent. Updating the network or access-point sections requires /// opting in via . /// - Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null); -} \ No newline at end of file + Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default); +} diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index 09f1b34..f044ef2 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -158,6 +158,13 @@ public sealed class StateRequest [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public PlaylistRequest? Playlist { get; set; } + /// + /// Reboot the device (the rb field, write-only). + /// + [JsonPropertyName("rb")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Reboot { get; set; } + public static StateRequest From(StateResponse stateResponse) { return new StateRequest() diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index e1fb561..5ad4173 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -5,123 +5,89 @@ public sealed class WLedClient : IWLedClient private readonly HttpClient _client; public WLedClient(HttpMessageHandler httpMessageHandler, string baseUri) + : this(CreateClient(httpMessageHandler, baseUri)) { - _client = new HttpClient(httpMessageHandler) - { - BaseAddress = new Uri(baseUri, UriKind.Absolute) - }; - - // Add the keep-alive flag to the header - _client.DefaultRequestHeaders.Add("Connection", "keep-alive"); } public WLedClient(string baseUri) : this(new HttpClientHandler(), baseUri) { - } - public async Task Get() + /// + /// Creates a client over a pre-configured . The client's + /// must be set. Intended for IHttpClientFactory/DI use. + /// + public WLedClient(HttpClient client) { - var message = await _client.GetAsync("json"); - - message.EnsureSuccessStatusCode(); + _client = client ?? throw new ArgumentNullException(nameof(client)); - return (await message.Content.ReadFromJsonAsync())!; + if (_client.BaseAddress is null) + { + throw new ArgumentException("The HttpClient must have a BaseAddress set.", nameof(client)); + } } - public async Task GetState() + private static HttpClient CreateClient(HttpMessageHandler httpMessageHandler, string baseUri) { - var message = await _client.GetAsync("json/state"); - - message.EnsureSuccessStatusCode(); + var client = new HttpClient(httpMessageHandler) + { + BaseAddress = new Uri(baseUri, UriKind.Absolute) + }; - return (await message.Content.ReadFromJsonAsync())!; + client.DefaultRequestHeaders.Add("Connection", "keep-alive"); + return client; } - public async Task GetInformation() - { - var message = await _client.GetAsync("json/info"); - - message.EnsureSuccessStatusCode(); - - return (await message.Content.ReadFromJsonAsync())!; - } + public Task Get(CancellationToken cancellationToken = default) + => GetJson("json", cancellationToken); - public async Task GetStateInfo() - { - var message = await _client.GetAsync("json/si"); + public Task GetState(CancellationToken cancellationToken = default) + => GetJson("json/state", cancellationToken); - message.EnsureSuccessStatusCode(); + public Task GetInformation(CancellationToken cancellationToken = default) + => GetJson("json/info", cancellationToken); - return (await message.Content.ReadFromJsonAsync())!; - } + public Task GetStateInfo(CancellationToken cancellationToken = default) + => GetJson("json/si", cancellationToken); - public async Task GetNetworks() + public async Task GetNetworks(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/net"); - - message.EnsureSuccessStatusCode(); - - var response = await message.Content.ReadFromJsonAsync(); + var response = await GetJson("json/net", cancellationToken); return response?.Networks ?? Array.Empty(); } - public async Task GetLiveColors() + public async Task GetLiveColors(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/live"); + var message = await SendGetAsync("json/live", cancellationToken); if (message.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } - message.EnsureSuccessStatusCode(); + await EnsureSuccess(message); if (message.Content.Headers.ContentLength == 0) { return null; } - return await message.Content.ReadFromJsonAsync(); - } - - public async Task GetEffects() - { - var message = await _client.GetAsync("json/eff"); - - message.EnsureSuccessStatusCode(); - - return (await message.Content.ReadFromJsonAsync())!; + return await message.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); } - public async Task GetPalettes() - { - var message = await _client.GetAsync("json/pal"); - - message.EnsureSuccessStatusCode(); - - return (await message.Content.ReadFromJsonAsync())!; - } + public Task GetEffects(CancellationToken cancellationToken = default) + => GetJson("json/eff", cancellationToken); - public async Task Post(WLedRootRequest request) - { - var stateString = JsonSerializer.Serialize(request); + public Task GetPalettes(CancellationToken cancellationToken = default) + => GetJson("json/pal", cancellationToken); - using var content = new StringContentWithoutCharset(stateString, "application/json"); - var result = await _client.PostAsync("/json", content); - result.EnsureSuccessStatusCode(); - } - - public async Task Post(StateRequest request) - { - var stateString = JsonSerializer.Serialize(request); + public Task Post(WLedRootRequest request, CancellationToken cancellationToken = default) + => PostJson("/json", request, cancellationToken); - using var content = new StringContentWithoutCharset(stateString, "application/json"); - var result = await _client.PostAsync("/json/state", content); - result.EnsureSuccessStatusCode(); - } + public Task Post(StateRequest request, CancellationToken cancellationToken = default) + => PostJson("/json/state", request, cancellationToken); - public Task UpdateState(Action configure) + public Task UpdateState(Action configure, CancellationToken cancellationToken = default) { if (configure is null) { @@ -130,22 +96,46 @@ public Task UpdateState(Action configure) var update = new StateUpdate(); configure(update); - return Post(update.Build()); + return Post(update.Build(), cancellationToken); } - public async Task> GetPresets() - { - var message = await _client.GetAsync("presets.json"); + public Task TurnOn(CancellationToken cancellationToken = default) + => Post(new StateRequest { On = Toggleable.On }, cancellationToken); + + public Task TurnOff(CancellationToken cancellationToken = default) + => Post(new StateRequest { On = Toggleable.Off }, cancellationToken); + + public Task Toggle(CancellationToken cancellationToken = default) + => Post(new StateRequest { On = Toggleable.Toggle }, cancellationToken); - message.EnsureSuccessStatusCode(); + public Task SetBrightness(ByteAdjust brightness, CancellationToken cancellationToken = default) + => Post(new StateRequest { Brightness = brightness }, cancellationToken); - var json = await message.Content.ReadAsStringAsync(); + public Task SetColor(RgbColor color, int? segmentId = null, CancellationToken cancellationToken = default) + => Post(SingleSegment(segmentId, segment => segment.Colors = new SegmentColors(color)), cancellationToken); + + public Task SetColor(RgbwColor color, int? segmentId = null, CancellationToken cancellationToken = default) + => Post(SingleSegment(segmentId, segment => segment.Colors = new SegmentColors(color)), cancellationToken); + + public Task SetEffect(Selector effect, int? segmentId = null, CancellationToken cancellationToken = default) + => Post(SingleSegment(segmentId, segment => segment.EffectId = effect), cancellationToken); + + public Task SetPalette(Selector palette, int? segmentId = null, CancellationToken cancellationToken = default) + => Post(SingleSegment(segmentId, segment => segment.ColorPaletteId = palette), cancellationToken); + + public Task Reboot(CancellationToken cancellationToken = default) + => Post(new StateRequest { Reboot = true }, cancellationToken); + + public async Task> GetPresets(CancellationToken cancellationToken = default) + { + var json = await GetString("presets.json", cancellationToken); return PresetsParser.ParsePresets(json, new JsonSerializerOptions()); } - public Task ApplyPreset(PresetSelector preset) => Post(new StateRequest { PresetId = preset }); + public Task ApplyPreset(PresetSelector preset, CancellationToken cancellationToken = default) + => Post(new StateRequest { PresetId = preset }, cancellationToken); - public Task SavePreset(int id, SavePresetOptions? options = null) + public Task SavePreset(int id, SavePresetOptions? options = null, CancellationToken cancellationToken = default) { options ??= new SavePresetOptions(); @@ -157,32 +147,29 @@ public Task SavePreset(int id, SavePresetOptions? options = null) SaveSegmentBounds = options.SaveSegmentBounds, IncludeBrightness = options.IncludeBrightness, SaveSelectedSegments = options.SaveSelectedSegments - }); + }, cancellationToken); } - public Task DeletePreset(int id) => Post(new StateRequest { DeletePresetSlot = id }); + public Task DeletePreset(int id, CancellationToken cancellationToken = default) + => Post(new StateRequest { DeletePresetSlot = id }, cancellationToken); - public async Task> GetPlaylists() + public async Task> GetPlaylists(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("presets.json"); - - message.EnsureSuccessStatusCode(); - - var json = await message.Content.ReadAsStringAsync(); + var json = await GetString("presets.json", cancellationToken); return PlaylistsParser.ParsePlaylists(json); } - public Task StartPlaylist(PlaylistDefinition playlist) + public Task StartPlaylist(PlaylistDefinition playlist, CancellationToken cancellationToken = default) { if (playlist is null) { throw new ArgumentNullException(nameof(playlist)); } - return Post(new StateRequest { Playlist = PlaylistRequest.From(playlist) }); + return Post(new StateRequest { Playlist = PlaylistRequest.From(playlist) }, cancellationToken); } - public Task StartPlaylist(Action configure) + public Task StartPlaylist(Action configure, CancellationToken cancellationToken = default) { if (configure is null) { @@ -191,10 +178,10 @@ public Task StartPlaylist(Action configure) var builder = new PlaylistBuilder(); configure(builder); - return StartPlaylist(builder.Build()); + return StartPlaylist(builder.Build(), cancellationToken); } - public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null) + public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null, CancellationToken cancellationToken = default) { if (playlist is null) { @@ -212,45 +199,36 @@ public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? IncludeBrightness = options.IncludeBrightness, SaveSelectedSegments = options.SaveSelectedSegments, Playlist = PlaylistRequest.From(playlist) - }); + }, cancellationToken); } - public async Task> GetEffectMetadata() + public async Task> GetEffectMetadata(CancellationToken cancellationToken = default) { - var fxdataMessage = await _client.GetAsync("json/fxdata"); - fxdataMessage.EnsureSuccessStatusCode(); - var fxdata = (await fxdataMessage.Content.ReadFromJsonAsync())!; - - var effects = await GetEffects(); + var fxdata = await GetJson("json/fxdata", cancellationToken); + var effects = await GetEffects(cancellationToken); return EffectMetadataParser.Parse(fxdata, effects); } - public async Task> GetNodes() + public async Task> GetNodes(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/nodes"); + var message = await SendGetAsync("json/nodes", cancellationToken); - message.EnsureSuccessStatusCode(); + await EnsureSuccess(message); if (message.Content.Headers.ContentLength == 0) { return Array.Empty(); } - var response = await message.Content.ReadFromJsonAsync(); + var response = await message.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); return response?.Nodes ?? Array.Empty(); } - public async Task GetConfig() - { - var message = await _client.GetAsync("json/cfg"); - - message.EnsureSuccessStatusCode(); + public Task GetConfig(CancellationToken cancellationToken = default) + => GetJson("json/cfg", cancellationToken); - return (await message.Content.ReadFromJsonAsync())!; - } - - public async Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null) + public Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default) { if (partial is null) { @@ -266,14 +244,10 @@ public async Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? option "Set UpdateConfigOptions.AllowNetworkChanges to true to permit it."); } - var configString = JsonSerializer.Serialize(partial); - - using var content = new StringContentWithoutCharset(configString, "application/json"); - var result = await _client.PostAsync("/json/cfg", content); - result.EnsureSuccessStatusCode(); + return PostJson("/json/cfg", partial, cancellationToken); } - public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256) + public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256, CancellationToken cancellationToken = default) { if (build is null) { @@ -291,7 +265,92 @@ await Post(new StateRequest { new SegmentRequest { Id = segmentId, IndividualLeds = request } } - }); + }, cancellationToken); + } + } + + private static StateRequest SingleSegment(int? segmentId, Action configure) + { + var segment = new SegmentRequest { Id = segmentId }; + configure(segment); + return new StateRequest { Segments = new[] { segment } }; + } + + private async Task GetJson(string uri, CancellationToken cancellationToken) + { + var message = await SendGetAsync(uri, cancellationToken); + await EnsureSuccess(message); + return (await message.Content.ReadFromJsonAsync(cancellationToken: cancellationToken))!; + } + + private async Task GetString(string uri, CancellationToken cancellationToken) + { + var message = await SendGetAsync(uri, cancellationToken); + await EnsureSuccess(message); + return await message.Content.ReadAsStringAsync(); + } + + private async Task PostJson(string uri, T payload, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(payload); + + using var content = new StringContentWithoutCharset(json, "application/json"); + var result = await SendPostAsync(uri, content, cancellationToken); + await EnsureSuccess(result); + } + + private async Task SendGetAsync(string uri, CancellationToken cancellationToken) + { + try + { + return await _client.GetAsync(uri, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new WledConnectionException($"Failed to reach the WLED device at '{_client.BaseAddress}'.", ex); } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new WledConnectionException("The request to the WLED device timed out.", ex); + } + } + + private async Task SendPostAsync(string uri, HttpContent content, CancellationToken cancellationToken) + { + try + { + return await _client.PostAsync(uri, content, cancellationToken); + } + catch (HttpRequestException ex) + { + throw new WledConnectionException($"Failed to reach the WLED device at '{_client.BaseAddress}'.", ex); + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new WledConnectionException("The request to the WLED device timed out.", ex); + } + } + + private static async Task EnsureSuccess(HttpResponseMessage message) + { + if (message.IsSuccessStatusCode) + { + return; + } + + string? body = null; + if (message.Content is not null) + { + try + { + body = await message.Content.ReadAsStringAsync(); + } + catch + { + // Best-effort body capture for diagnostics. + } + } + + throw new WledResponseException((int)message.StatusCode, body); } -} \ No newline at end of file +} diff --git a/test/Kevsoft.WLED.Tests/DependencyInjectionTests.cs b/test/Kevsoft.WLED.Tests/DependencyInjectionTests.cs new file mode 100644 index 0000000..ed1c01a --- /dev/null +++ b/test/Kevsoft.WLED.Tests/DependencyInjectionTests.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Kevsoft.WLED.Tests; + +public class DependencyInjectionTests +{ + [Fact] + public void AddWledClientWithBaseAddressResolvesClient() + { + var services = new ServiceCollection(); + + services.AddWledClient("http://wled-desk/"); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + client.Should().BeOfType(); + } + + [Fact] + public void AddWledClientWithConfigureResolvesClient() + { + var services = new ServiceCollection(); + + services.AddWledClient(client => client.BaseAddress = new Uri("http://wled-desk/")); + + using var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + + client.Should().BeOfType(); + } + + [Fact] + public void AddWledClientRegistersHttpClientFactory() + { + var services = new ServiceCollection(); + + services.AddWledClient("http://wled-desk/"); + + using var provider = services.BuildServiceProvider(); + + provider.GetService().Should().NotBeNull(); + } +} diff --git a/test/Kevsoft.WLED.Tests/ExceptionTests.cs b/test/Kevsoft.WLED.Tests/ExceptionTests.cs new file mode 100644 index 0000000..2553558 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ExceptionTests.cs @@ -0,0 +1,80 @@ +using System.Net; + +namespace Kevsoft.WLED.Tests; + +public class ExceptionTests +{ + [Fact] + public async Task NonSuccessResponseThrowsWledResponseExceptionWithStatusAndBody() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state", HttpStatusCode.InternalServerError, "boom"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var act = () => client.GetState(); + + var exception = (await act.Should().ThrowAsync()).Which; + exception.StatusCode.Should().Be(500); + exception.Body.Should().Be("boom"); + } + + [Fact] + public async Task NotFoundOnGetThrowsWledResponseException() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var act = () => client.GetState(); + + (await act.Should().ThrowAsync()).Which.StatusCode.Should().Be(404); + } + + [Fact] + public async Task TransportFailureThrowsWledConnectionException() + { + var mockHttpMessageHandler = new MockHttpMessageHandler + { + ThrowOnSend = new HttpRequestException("no route to host") + }; + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var act = () => client.GetState(); + + var exception = (await act.Should().ThrowAsync()).Which; + exception.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task TransportFailureOnPostThrowsWledConnectionException() + { + var mockHttpMessageHandler = new MockHttpMessageHandler + { + ThrowOnSend = new HttpRequestException("connection refused") + }; + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var act = () => client.TurnOn(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CancelledTokenThrowsOperationCanceledException() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state", "{}"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var act = () => client.GetState(cts.Token); + + await act.Should().ThrowAsync(); + } +} diff --git a/test/Kevsoft.WLED.Tests/IntentMethodTests.cs b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs new file mode 100644 index 0000000..5acd717 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs @@ -0,0 +1,118 @@ +namespace Kevsoft.WLED.Tests; + +public class IntentMethodTests +{ + [Fact] + public async Task TurnOnPostsOnTrue() + { + var (uri, body) = await Capture(client => client.TurnOn()); + + uri.Should().EndWith("/json/state"); + JsonDocument.Parse(body!).RootElement.GetProperty("on").GetBoolean().Should().BeTrue(); + } + + [Fact] + public async Task TurnOffPostsOnFalse() + { + var (_, body) = await Capture(client => client.TurnOff()); + + JsonDocument.Parse(body!).RootElement.GetProperty("on").GetBoolean().Should().BeFalse(); + } + + [Fact] + public async Task TogglePostsOnToggle() + { + var (_, body) = await Capture(client => client.Toggle()); + + JsonDocument.Parse(body!).RootElement.GetProperty("on").GetString().Should().Be("t"); + } + + [Fact] + public async Task SetBrightnessPostsBri() + { + var (_, body) = await Capture(client => client.SetBrightness(128)); + + JsonDocument.Parse(body!).RootElement.GetProperty("bri").GetInt32().Should().Be(128); + } + + [Fact] + public async Task SetBrightnessRelativePostsSignedString() + { + var (_, body) = await Capture(client => client.SetBrightness(ByteAdjust.Increment(10))); + + JsonDocument.Parse(body!).RootElement.GetProperty("bri").GetString().Should().Be("~10"); + } + + [Fact] + public async Task SetColorRgbPostsSelectedSegmentColor() + { + var (_, body) = await Capture(client => client.SetColor(RgbColor.FromHex("FFAA00"))); + + var root = JsonDocument.Parse(body!).RootElement; + var segment = root.GetProperty("seg").EnumerateArray().Single(); + segment.TryGetProperty("id", out _).Should().BeFalse(); + var color = segment.GetProperty("col").EnumerateArray().First().EnumerateArray() + .Select(x => x.GetInt32()).ToArray(); + color.Should().Equal(255, 170, 0); + } + + [Fact] + public async Task SetColorWithSegmentIdTargetsThatSegment() + { + var (_, body) = await Capture(client => client.SetColor(RgbColor.FromHex("010203"), segmentId: 2)); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + segment.GetProperty("id").GetInt32().Should().Be(2); + } + + [Fact] + public async Task SetColorRgbwPostsFourChannels() + { + var (_, body) = await Capture(client => client.SetColor(RgbwColor.FromHex("01020304"))); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + var color = segment.GetProperty("col").EnumerateArray().First().EnumerateArray() + .Select(x => x.GetInt32()).ToArray(); + color.Should().Equal(1, 2, 3, 4); + } + + [Fact] + public async Task SetEffectPostsSegmentEffectId() + { + var (_, body) = await Capture(client => client.SetEffect(42)); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + segment.GetProperty("fx").GetInt32().Should().Be(42); + } + + [Fact] + public async Task SetPalettePostsSegmentPaletteId() + { + var (_, body) = await Capture(client => client.SetPalette(7, segmentId: 1)); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + segment.GetProperty("id").GetInt32().Should().Be(1); + segment.GetProperty("pal").GetInt32().Should().Be(7); + } + + [Fact] + public async Task RebootPostsRebootTrue() + { + var (_, body) = await Capture(client => client.Reboot()); + + JsonDocument.Parse(body!).RootElement.GetProperty("rb").GetBoolean().Should().BeTrue(); + } + + private static async Task<(string Uri, string? Body)> Capture(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + var (uri, body) = mockHttpMessageHandler.CapturedRequestList.Single(); + return (uri, body); + } +} diff --git a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj index fa3487f..636a10f 100644 --- a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj +++ b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs b/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs index 9f718a3..11f81d8 100644 --- a/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs +++ b/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs @@ -24,12 +24,25 @@ public void AppendResponse(string uri) _mockedResponses.Add(uri, (HttpStatusCode.OK, null)); } + public void AppendResponse(string uri, HttpStatusCode statusCode, string? body = null) + { + _mockedResponses.Add(uri, (statusCode, body)); + } + + /// When set, the handler throws this exception to simulate a transport failure. + public Exception? ThrowOnSend { get; set; } + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { var body = await (request.Content?.ReadAsStringAsync(cancellationToken) ?? Task.FromResult("")); _capturedRequests[request.RequestUri!.AbsoluteUri] = body; _capturedRequestList.Add((request.RequestUri!.AbsoluteUri, body)); + + if (ThrowOnSend is not null) + { + throw ThrowOnSend; + } if (_mockedResponses.TryGetValue(request.RequestUri!.AbsoluteUri, out var value)) { From 1ad3f77591e929aead2539aecd1f33b524276b03 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 00:13:59 +0100 Subject: [PATCH 14/32] Plan 11: refresh README with feature matrix, add CHANGELOG and modern sample - Rewrite README around intent methods, fluent builders, DI and error handling - Add a supported-features capability matrix and document target frameworks - Add CHANGELOG.md (Keep a Changelog format) - Rewrite BasicConsole sample to showcase the ergonomic API --- CHANGELOG.md | 45 +++++++++++ README.md | 137 ++++++++++++++++++++++++++++---- samples/BasicConsole/Program.cs | 60 ++++++++++++-- 3 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6dc886 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +This release is a substantial, breaking overhaul that reworks the library to cover the full +WLED JSON API with a strongly-typed, hard-to-misuse design. Legacy members were removed +rather than deprecated, so consuming code must be updated. + +### Added + +- **Multi-targeting** for `net8.0`, `net9.0`, `net10.0` and `netstandard2.0`. +- **Strong value types and enums** for colours (`RgbColor`, `RgbwColor`, `Color`), + brightness/relative adjustments (`ByteAdjust`), toggles (`Toggleable`), selectors and more. +- **Fluent builders** for state updates (`UpdateState`), segments, playlists and individual LEDs. +- **Intent methods**: `TurnOn`, `TurnOff`, `Toggle`, `SetBrightness`, `SetColor` (RGB/RGBW), + `SetEffect`, `SetPalette` and `Reboot`. +- **Presets**: read, apply, save and delete (`GetPresets`, `ApplyPreset`, `SavePreset`, `DeletePreset`). +- **Playlists**: read, start and save (`GetPlaylists`, `StartPlaylist`, `SavePlaylist`). +- **Individual LED control** with automatic, sequential request chunking (`SetIndividualLeds`). +- **Effect metadata** parsing from `/json/fxdata` (`GetEffectMetadata`). +- **Node discovery** via `/json/nodes` (`GetNodes`). +- **Device configuration** read and safe partial writes via `/json/cfg` + (`GetConfig`, `UpdateConfig`); network/access-point changes require explicit opt-in. +- **Typed exception hierarchy**: `WledException`, `WledConnectionException`, + `WledResponseException` (with `StatusCode`/`Body`) and `WledUnsupportedVersionException`. +- **`CancellationToken`** support on every asynchronous method. +- **Dependency-injection integration** in a new `WLED.DependencyInjection` package via + `services.AddWledClient(...)`, backed by `IHttpClientFactory`. +- A new `WLedClient(HttpClient)` constructor for DI / `IHttpClientFactory` scenarios. + +### Changed + +- Requests and responses are now modelled as separate immutable response types and mutable + request types, preventing accidental round-tripping of read-only fields. +- Posting state is now done through intent methods or `UpdateState(...)` rather than mutating + and re-posting a response object. + +### Removed + +- Legacy members were removed outright (no `[Obsolete]` shims). Update to the new API surface. diff --git a/README.md b/README.md index f106d21..6823901 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,154 @@ # WLED.NET [![Continuous Integration Workflow](https://github.com/kevbite/WLED.NET/actions/workflows/continuous-integration-workflow.yml/badge.svg)](https://github.com/kevbite/WLED.NET/actions/workflows/continuous-integration-workflow.yml) [![install from nuget](http://img.shields.io/nuget/v/WLED.svg?style=flat-square)](https://www.nuget.org/packages/WLED) [![downloads](http://img.shields.io/nuget/dt/WLED.svg?style=flat-square)](https://www.nuget.org/packages/Kevsoft.WLED) -A .NET Wrapper around the [WLED](https://github.com/Aircoookie/WLED) [JSON API](https://github.com/Aircoookie/WLED/wiki/JSON-API). +A .NET wrapper around the [WLED](https://github.com/Aircoookie/WLED) [JSON API](https://kno.wled.ge/interfaces/json-api/). + +WLED.NET aims to be **hard to misuse**: state is modelled with strong types and fluent +builders so that, wherever possible, an invalid request simply won't compile. + +## Supported frameworks + +The library multi-targets `net8.0`, `net9.0`, `net10.0` and `netstandard2.0`. ## Getting Started ### Installing Package -**WLED.NET** can be installed directly via the package manager console by executing the following commandlet: +**WLED.NET** can be installed via the dotnet CLI: -```powershell -Install-Package WLED +```bash +dotnet add package WLED ``` -alternative you can use the dotnet CLI. +For dependency-injection / `IHttpClientFactory` integration, also install: ```bash -dotnet add package WLED +dotnet add package WLED.DependencyInjection ``` ## Usage -### Getting data from the WLED device +### Connecting ```csharp var client = new WLedClient("http://office-computer-wled/"); +``` + +Or register it with dependency injection so the underlying `HttpClient` is pooled correctly: -var data = await client.Get(); +```csharp +services.AddWledClient("http://office-computer-wled/"); +// or +services.AddWledClient(client => client.BaseAddress = new Uri("http://office-computer-wled/")); ``` -### Post data to the WLED device +### Quick commands -Turn on the device on +Common operations have first-class "intent" methods: ```csharp -var client = new WLedClient("http://office-computer-wled/"); -await client.Post(new StateRequest { On = true }); +await client.TurnOn(); +await client.TurnOff(); +await client.Toggle(); + +await client.SetBrightness(200); +await client.SetColor(RgbColor.FromHex("FFAA00")); +await client.SetEffect(9); // by effect id +await client.SetPalette(11); // by palette id + +await client.Reboot(); +``` + +### Reading data + +```csharp +var root = await client.Get(); // full /json document +var state = await client.GetState(); // /json/state +var info = await client.GetInformation(); // /json/info + +Console.WriteLine($"{info.Name} is running WLED {info.VersionName}."); ``` + +### Fluent state updates + +Build a sparse update that only sends the fields you set: + +```csharp +await client.UpdateState(update => update + .TurnOn() + .Brightness(128) + .Segment(0, segment => segment + .Effect(0) + .Color(RgbColor.FromHex("0066FF")) + .Speed(200))); +``` + +### Individual LED control + +```csharp +await client.SetIndividualLeds(segmentId: 0, leds => leds + .Set(0, RgbColor.FromHex("FF0000")) + .SetRange(1, 10, RgbColor.FromHex("00FF00"))); +``` + +Large updates are transparently and safely split into multiple sequential requests. + +### Presets & playlists + +```csharp +var presets = await client.GetPresets(); +await client.ApplyPreset(1); +await client.SavePreset(5, new SavePresetOptions { Name = "Movie night" }); + +await client.StartPlaylist(playlist => playlist + .Add(1, TimeSpan.FromSeconds(10)) + .Add(2, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1)) + .Repeat(3)); +``` + +### Error handling + +All calls throw a typed exception hierarchy: + +```csharp +try +{ + await client.TurnOn(); +} +catch (WledConnectionException) { /* device unreachable / timed out */ } +catch (WledResponseException ex) { /* non-2xx: ex.StatusCode, ex.Body */ } +``` + +Every method also accepts an optional `CancellationToken`. + +## Supported features + +| Area | Endpoint(s) | Supported | +| --- | --- | --- | +| Full state/info/effects/palettes | `GET /json` | ✅ | +| Live state + info | `GET /json/si` | ✅ | +| State | `GET`/`POST /json/state` | ✅ | +| Device information | `GET /json/info` | ✅ | +| Effects & palettes lists | `GET /json/eff`, `GET /json/pal` | ✅ | +| Nearby Wi-Fi networks | `GET /json/net` | ✅ | +| Live LED stream | `GET /json/live` | ✅ | +| Brightness / on-off / toggle | `POST /json/state` | ✅ | +| Per-segment control (effect, palette, colours, options, 2D, grouping…) | `POST /json/state` | ✅ | +| Individual LED control (with auto-chunking) | `POST /json/state` | ✅ | +| Presets (read / apply / save / delete) | `presets.json`, `POST /json/state` | ✅ | +| Playlists (read / start / save) | `presets.json`, `POST /json/state` | ✅ | +| Effect metadata | `GET /json/fxdata` | ✅ | +| Node discovery | `GET /json/nodes` | ✅ | +| Device configuration (read / safe partial write) | `GET`/`POST /json/cfg` | ✅ | +| Typed exceptions & cancellation | — | ✅ | +| DI / `IHttpClientFactory` integration | — | ✅ | + ## Samples -The [samples](samples/) folder containers examples of how you could use the WLED.NET Library. +The [samples](samples/) folder contains examples of how you could use the WLED.NET library. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md). ## Contributing @@ -46,4 +156,3 @@ The [samples](samples/) folder containers examples of how you could use the WLED 1. Fork 1. Hack! 1. Pull Request - diff --git a/samples/BasicConsole/Program.cs b/samples/BasicConsole/Program.cs index 4257d69..2ebd001 100644 --- a/samples/BasicConsole/Program.cs +++ b/samples/BasicConsole/Program.cs @@ -1,8 +1,58 @@ -using Kevsoft.WLED; +using Kevsoft.WLED; -var client = new WLedClient("http://wled-office-computer-wle/"); -var wLedRootResponse = await client.Get(); +// Point this at your own device's address. +var client = new WLedClient("http://wled-office-computer-wled/"); -wLedRootResponse.State.On = true; +// --- Simple intent methods ------------------------------------------------- -await client.Post(wLedRootResponse.State); \ No newline at end of file +await client.TurnOn(); +await client.SetBrightness(200); +await client.SetColor(RgbColor.FromHex("FFAA00")); // warm orange +await client.SetEffect(9); // "Rainbow" +await client.SetPalette(11); // "Rainbow" + +// --- Reading state --------------------------------------------------------- + +var info = await client.GetInformation(); +Console.WriteLine($"Connected to {info.Name} running WLED {info.VersionName} with {info.Leds.Count} LEDs."); + +var state = await client.GetState(); +Console.WriteLine($"Power: {(state.On ? "on" : "off")}, brightness: {state.Brightness}"); + +// --- Sparse, fluent state updates ----------------------------------------- + +await client.UpdateState(update => update + .TurnOn() + .Brightness(128) + .Segment(0, segment => segment + .Effect(0) + .Color(RgbColor.FromHex("0066FF")))); + +// --- Individual LED control ------------------------------------------------ + +await client.SetIndividualLeds(0, leds => leds + .Set(0, RgbColor.FromHex("FF0000")) + .Set(1, RgbColor.FromHex("00FF00")) + .Set(2, RgbColor.FromHex("0000FF"))); + +// --- Presets & playlists --------------------------------------------------- + +var presets = await client.GetPresets(); +foreach (var (id, preset) in presets) +{ + Console.WriteLine($"Preset {id}: {preset.Name}"); +} + +await client.StartPlaylist(playlist => playlist + .Add(1, TimeSpan.FromSeconds(10)) + .Add(2, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1)) + .Repeat(3)); + +// --- Discovering other devices -------------------------------------------- + +foreach (var node in await client.GetNodes()) +{ + Console.WriteLine($"Found node {node.Name} at {node.IpAddress}"); +} + +await client.TurnOff(); From 30ed34fff6122a2ef16ad0771c612826da9e7177 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 12:30:18 +0100 Subject: [PATCH 15/32] Add WLED JSON API coverage review --- reviews/1-wled-json-api-review.md | 94 +++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 reviews/1-wled-json-api-review.md diff --git a/reviews/1-wled-json-api-review.md b/reviews/1-wled-json-api-review.md new file mode 100644 index 0000000..e9bc91a --- /dev/null +++ b/reviews/1-wled-json-api-review.md @@ -0,0 +1,94 @@ +# WLED JSON API coverage review + +Compared against the current WLED JSON API documentation at on 2026-05-30. + +Overall, the library is in good shape: the major documented endpoints are present (`/json`, `/json/state`, `/json/info`, `/json/si`, `/json/nodes`, `/json/eff`, `/json/pal`, `/json/fxdata`, `/json/net`, `/json/live`, `/json/cfg`), and the strongly typed command/value objects are a good fit for the "hard to misuse" goal. The review below focuses on gaps or behavior that does not line up with the docs. + +## Review comments + +### 1. High - "selected segments" intent methods currently target segment 0 + +**Docs:** The API says `seg` can be either an object or an array. When `seg` is an array and a segment object omits `id`, the ID is inferred from the object's array position. The docs' selected-segment examples use the object form, e.g. `{"seg":{"fx":"r"}}`, for applying to all selected segments. + +**Code:** `WLedClient.SetColor`, `SetEffect`, and `SetPalette` say "selected segments when no id is given" in `IWLedClient.cs`, but they all call `SingleSegment(...)`, which always serializes `seg` as a one-element array (`WLedClient.cs:114-124`, `WLedClient.cs:272-277`). The tests also assert that the no-id form is `seg: [{ ... }]` (`IntentMethodTests.cs:47-56`, `IntentMethodTests.cs:80-85`). + +**Why it matters:** Per the docs, `{"seg":[{"fx":42}]}` infers `id:0`, so the ergonomic methods likely update segment 0 rather than the selected segments. This is a behavior bug and contradicts the public XML comments. + +**Suggested fix:** Model the `seg` request as a union (`SegmentRequest` object or `SegmentRequest[]`) and have no-id intent methods emit the object form. Keep explicit `segmentId` calls as an array with `id`. + +### 2. High - Transition values are limited to `byte`, but the API allows `0..65535` + +**Docs:** State `transition` and one-shot `tt` are documented as `0 to 65535`, in 100 ms units. + +**Code:** `StateResponse.Transition` is `byte` (`StateResponse.cs:20-21`), `StateRequest.Transition` and `TransientTransition` are `byte?` (`StateRequest.cs:16-26`), and `StateUpdate.ToTransitionUnits` clamps to `byte.MaxValue` (`StateUpdate.cs:134-147`). The fluent comments also say the max is 25.5s (`StateUpdate.cs:35`), but the documented API supports roughly 109 minutes. + +**Why it matters:** The client cannot represent or send valid WLED transition values above 255. It will also deserialize valid responses incorrectly/fail if WLED returns a transition above 255. + +**Suggested fix:** Change transition fields and conversion helpers to `ushort`/`ushort?`, clamp to `ushort.MaxValue`, and update tests to include a value above 255 units. + +### 3. Medium - Effect/palette selector does not support documented ranged random syntax + +**Docs:** The examples include ranged random palette selection, e.g. `{"seg":[{"id":2,"pal":"5~10r"}]}`. Presets already support similar range syntax. + +**Code:** `Selector` supports only id, next, previous, and random (`Selector.cs:10-38`), and `SelectorJsonConverter` rejects anything except `"~"`, `"~-"`, and `"r"` (`SelectorJsonConverter.cs:13-21`). `PresetSelector` has `RandomInRange`, but effect/palette selection does not (`PresetSelector.cs:40-45`). + +**Why it matters:** A documented WLED command cannot be expressed with the strongly typed API, and JSON responses/payloads containing that token would fail to deserialize. + +**Suggested fix:** Extend `Selector` with a ranged random value (and possibly a range validator), serialize it as `"{from}~{to}r"`, and parse that token in `SelectorJsonConverter`. + +### 4. Medium - `/json/palx` is listed in the API routes but is not modeled + +**Docs:** The API route list includes `/json/palx` alongside `/json/pal` and `/json/cfg`. + +**Code:** There is support for palette names via `/json/pal` (`WLedClient.cs:81-82`), but no client method or model for `/json/palx`. + +**Why it matters:** If `/json/palx` is the custom palette read/write endpoint, the current README's "full JSON API" capability story is overstated for palette manipulation. + +**Suggested fix:** Confirm the exact `/json/palx` wire format from firmware/source or companion libraries, then add typed models and client methods. If it is intentionally unsupported, document it explicitly in the feature matrix. + +### 5. Medium - `info.sensor` draft API is not preserved or exposed + +**Docs:** The JSON API page includes a draft Sensors API where `info.sensor` is an array of sensor objects. The object shape is intentionally extensible (`type`, `n`, `val`, `unit`, `error`, timing fields, bounds, uncertainty, model, etc.). + +**Code:** `InformationResponse` ends at `ip` and has no `sensor` property or extension data (`InformationResponse.cs:151-155`). + +**Why it matters:** Devices with usermods exposing sensor data will silently lose that information. Because this is read-only data, exposing it would not risk invalid writes. + +**Suggested fix:** Add `InformationResponse.Sensors` as an array of a flexible `SensorResponse` type with `JsonElement? Value`, typed common metadata, and `JsonExtensionData` for usermod-specific fields. + +### 6. Medium - Config modeling is intentionally lossless, but not ergonomic enough for "hard to misuse" + +**Docs:** `/json/cfg` exposes many configuration sections. The project goal is not just field mapping, but ergonomic modeling that prevents invalid requests. + +**Code:** `DeviceConfig` uses typed section shells plus `JsonExtensionData` for almost everything (`DeviceConfig.cs:7-100`). This is safe for round-tripping and partial writes, and the network/access-point guard is good, but only `id.name` is modeled. + +**Why it matters:** Callers still have to hand-build raw `JsonElement` values for most configuration changes. That is safe for data preservation, but not yet "impossible to send the wrong information" for config. + +**Suggested fix:** Incrementally model high-value, low-risk sections with small value objects/enums (identity, sync/UDP, MQTT enablement, brightness defaults, LED count/bus read views), keeping `JsonExtensionData` as an escape hatch. + +### 7. Low - Color temperature range is stricter than the docs' forward-compat guidance + +**Docs:** The CCT section first describes `0..255` or `1900..10091`, then says integrations should expect Kelvin values in a broader future-compatible range (`1000..16000`, and later `1000..20000 K`) and prefer preserving the range received from WLED. + +**Code:** `ColorTemperature.Kelvin` only allows `1900..10091` (`ColorTemperature.cs:11-38`). Reading is permissive (`FromWire` treats any value above 255 as Kelvin), but callers cannot intentionally send a broader Kelvin value back. + +**Why it matters:** Newer firmware or hardware-specific CCT setups could legitimately use values outside the current constructor's limits, making the typed API more restrictive than WLED. + +**Suggested fix:** Consider widening the constructor to the docs' forward-compatible range, or add a clearly named escape hatch such as `ColorTemperature.KelvinUnchecked(int)` if strict validation is still preferred by default. + +### 8. Low - Random color slots are not represented + +**Docs:** Segment `col` can be color arrays, hex strings, and the docs mention random color slots using `"r"` (noting this is future/soon behavior). + +**Code:** `ColorJsonConverter` reads all strings as hex (`ColorJsonConverter.cs:11-14`) and writes only numeric arrays (`ColorJsonConverter.cs:41-52`). There is no `Random` color slot representation. + +**Why it matters:** Once WLED supports random color slots in stable firmware, the current `Color` model will reject that documented token and callers cannot request random colors through the strongly typed API. + +**Suggested fix:** Track this as a future compatibility item. If implementing now, introduce a separate `ColorSlot` union (`Rgb`, `Rgbw`, `Random`) rather than weakening the existing `Color` type. + +## Positive notes + +- Individual LED control matches the docs well: RGB writes use compact hex strings, RGBW uses arrays, and large updates are split into sequential requests rather than sent in parallel. +- Effect metadata parsing correctly accounts for missing vs empty sections and filters `RSVD` / `-` while preserving original effect IDs. +- Presets/playlists are modeled much more ergonomically than the raw WLED JSON and align with the documented parallel arrays. +- The config API's `JsonExtensionData` approach is a safe baseline because it avoids dropping firmware-specific or future settings. From 760e5382ea019af0190c36c0d26f3368d3a99fde Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:14:04 +0100 Subject: [PATCH 16/32] Add plan 12: usability and correctness improvements from review --- ...-usability-and-correctness-improvements.md | 192 ++++++++++++++++++ plans/README.md | 1 + 2 files changed, 193 insertions(+) create mode 100644 plans/12-usability-and-correctness-improvements.md diff --git a/plans/12-usability-and-correctness-improvements.md b/plans/12-usability-and-correctness-improvements.md new file mode 100644 index 0000000..37320b1 --- /dev/null +++ b/plans/12-usability-and-correctness-improvements.md @@ -0,0 +1,192 @@ +# Plan 12 — Usability & correctness improvements (post-review) + +**Theme:** Correctness · Quality · **Source:** [`reviews/1-wled-json-api-review.md`](../reviews/1-wled-json-api-review.md) + +## Why + +Plans 0–11 brought the library to full, strongly-typed coverage of the WLED JSON API. +A subsequent review against the [JSON API docs](https://kno.wled.ge/interfaces/json-api/) +surfaced a handful of **correctness bugs** and **ergonomic gaps**. This plan turns those +findings into actionable work. + +Per the project owner's steer, this plan **prioritises usability and correctness over +adding new endpoints**. New-endpoint findings (`/json/palx`, `info.sensor`) and +speculative future-WLED features (random color slots) are explicitly de-scoped here and +captured only as optional/back-log notes. + +Because we are pre-1.0 on this API surface and have decided **not** to keep `[Obsolete]` +shims, shape-changing members are **removed outright** — consumers update on upgrade. +(This overrides the obsolete-shim guidance in Plan 11 / the plans README.) + +--- + +## Priority 1 — Correctness bugs (must fix) + +### 1.1 "Selected segments" intent methods currently target segment 0 + +**Finding:** review #1 (High). `WLedClient.SetColor` / `SetEffect` / `SetPalette` +document "selected segments when no id is given", but `SingleSegment(...)` always emits +`seg` as a one-element **array** (`WLedClient.cs:272-277`). Per the docs, an array entry +with no `id` infers `id:0`, so these one-liners actually only touch segment 0 — directly +contradicting their XML docs. + +**Docs:** `seg` may be an **object** *or* an **array**. The selected-segment examples use +the object form, e.g. `{"seg":{"fx":"r"}}`. The array form infers `id` from array +position when `id` is omitted. + +**Proposed change:** + +- Model the request `seg` as a union that serializes as **either** a `SegmentRequest` + object **or** a `SegmentRequest[]`. Introduce a small wrapper (e.g. + `SegmentRequestPayload`) with a `JsonConverter` that writes: + - the **object** form when targeting selected segments (no id), and + - the **array** form when one or more explicit ids are given. +- No-id intent methods (`SetColor`/`SetEffect`/`SetPalette` with `segmentId == null`) + emit the **object** form → `{"seg":{...}}`. +- Explicit `segmentId` calls continue to emit `{"seg":[{"id":N,...}]}`. +- Fix the XML docs so wording matches behaviour. + +**Tests:** `IntentMethodTests.cs:47-85` currently asserts the buggy array/`id:0` form for +the no-id case — these expectations must be **updated** to the object form. Add explicit +`segmentId` tests that still assert the array+id form. + +### 1.2 Transition values limited to `byte`, but the API allows `0..65535` + +**Finding:** review #2 (High). `transition` and one-shot `tt` are documented as +`0..65535` (×100 ms ≈ up to ~109 min). Today: + +- `StateResponse.Transition` is `byte` (`StateResponse.cs:20-21`) +- `StateRequest.Transition` / `TransientTransition` are `byte?` (`StateRequest.cs:16-26`) +- `StateUpdate.ToTransitionUnits` clamps to `byte.MaxValue` (`StateUpdate.cs:134-147`); + the fluent comment claims a 25.5 s max (`StateUpdate.cs:35`). + +The client therefore **cannot represent valid values > 255** and will mis-handle a +response whose transition exceeds 255 units. + +**Proposed change:** + +- Change `transition`/`tt` fields to `ushort` / `ushort?`. +- Update `StateUpdate.ToTransitionUnits` to clamp to `ushort.MaxValue` and fix the + `TimeSpan` ↔ units helper/comment (max ≈ 6553.5 s, not 25.5 s). +- Mind the `netstandard2.0` target: no `Math.Clamp` — use a manual clamp helper. + +**Tests:** add cases with a transition **> 255 units** (e.g. a `TimeSpan` of 60 s ⇒ 600 +units) on both serialize and deserialize paths. + +--- + +## Priority 2 — Ergonomic / forward-compat improvements (should fix) + +### 2.1 Effect/palette `Selector` lacks ranged-random (`"5~10r"`) + +**Finding:** review #3 (Medium). Docs show `{"seg":[{"id":2,"pal":"5~10r"}]}`. +`Selector` supports only id/next/previous/random (`Selector.cs:10-38`) and +`SelectorJsonConverter` rejects anything except `"~"`, `"~-"`, `"r"` +(`SelectorJsonConverter.cs:13-21`). `PresetSelector.RandomInRange` already does this for +presets (`PresetSelector.cs:40-45`) — mirror it. + +**Proposed change:** + +- Add `Selector.RandomInRange(int from, int to)` producing the token `"{from}~{to}r"`, + with `from <= to` validation at construction. +- Extend `SelectorJsonConverter` to read/write that token (round-trip tested). + +### 2.2 Richer typed config sections (incremental) + +**Finding:** review #6 (Medium). `DeviceConfig` models only `id.name`; everything else is +`JsonExtensionData` (`DeviceConfig.cs:7-100`). Safe for round-tripping, but callers still +hand-build raw `JsonElement`s — not yet "impossible to send the wrong information". + +**Proposed change (incremental, low-risk only):** model a few high-value, low-risk +sections as small value objects/enums while keeping `JsonExtensionData` as the escape +hatch. Candidate first slices (keep scope tight): + +- identity (`id.*`: name, brightness factor), +- default-on/brightness defaults, +- sync/UDP enablement (already partly guarded), +- MQTT enablement. + +This is explicitly **incremental** — do not attempt full `/json/cfg` modelling. Each +added section must round-trip losslessly with the extension data. + +> If time-boxed, land 2.1 first; 2.2 can be split into its own follow-up plan/PR. + +### 2.3 `ColorTemperature` Kelvin range / escape hatch + +**Finding:** review #7 (Low). `ColorTemperature.Kelvin` allows only `1900..10091` +(`ColorTemperature.cs:11-38`), but the docs advise integrations expect a broader +forward-compatible Kelvin range (`1000..16000`, later `1000..20000`) and to preserve the +range received from WLED. + +**Proposed change:** pick one: + +- widen the validated constructor range to the docs' forward-compatible bounds, **or** +- add a clearly named escape hatch `ColorTemperature.KelvinUnchecked(int)` while keeping + strict validation as the default. + +Reading is already permissive (`FromWire` treats >255 as Kelvin); ensure a wide value +round-trips unchanged. + +--- + +## De-scoped (noted, not done in this plan) + +These are tracked for visibility but intentionally **out of scope** per the +"no new endpoints unless it really makes sense" steer: + +- **`/json/palx` custom palettes** (review #4) — new endpoint; wire format needs + confirming from firmware/companion libs. If not implemented, state it explicitly in the + README feature matrix rather than implying full palette manipulation. +- **`info.sensor` draft Sensors API** (review #5) — read-only; nice-to-have. If trivial, + could be added as a flexible `SensorResponse[]` with `JsonExtensionData`, but not + required here. +- **Random color slots `"r"` in `col`** (review #8) — future/soon WLED behaviour; defer + until stable in firmware, and prefer a separate `ColorSlot` union over weakening + `Color`. + +--- + +## Files + +- `src/Kevsoft.WLED/WLedClient.cs`, `IWLedClient.cs` — seg union payload + intent-method + object form (1.1) +- `src/Kevsoft.WLED/StateRequest.cs`, `StateResponse.cs`, + `src/Kevsoft.WLED/Fluent/StateUpdate.cs` — `ushort` transition (1.2) +- `src/Kevsoft.WLED/Commands/Selector.cs`, `Commands/SelectorJsonConverter.cs` — + ranged-random (2.1) +- `src/Kevsoft.WLED/Config/DeviceConfig.cs` (+ new small section types) — typed config + (2.2) +- `src/Kevsoft.WLED/ColorTemperature.cs` — widened range / escape hatch (2.3) +- `test/Kevsoft.WLED.Tests/IntentMethodTests.cs` and related tests — updated expectations + +## Tests + +- No-id `SetColor`/`SetEffect`/`SetPalette` emit `{"seg":{...}}` (object); explicit + `segmentId` emits `{"seg":[{"id":N,...}]}` (array). (1.1) +- Transition serialize/deserialize round-trips a value **> 255 units**; clamp at + `ushort.MaxValue`. (1.2) +- `Selector.RandomInRange(5,10)` ⇄ `"5~10r"`; invalid range throws. (2.1) +- New config sections round-trip losslessly alongside `JsonExtensionData`. (2.2) +- A Kelvin value outside the old `1900..10091` band round-trips (via wide range or + `KelvinUnchecked`). (2.3) + +## Definition of Done + +Per the [plans README](README.md) Definition of Done, **plus**: + +1. **Root `README.md`** — fix any examples implying no-id intent methods target a single + segment; ensure the feature matrix reflects transition range and ranged-random + selectors; note `/json/palx` status honestly. +2. **`samples/BasicConsole`** — ensure the showcased one-liners reflect the corrected + selected-segment behaviour. +3. **`CHANGELOG.md`** — record the breaking changes: `byte` → `ushort` transition, the + `seg` object-vs-array behaviour change, any `Selector`/`ColorTemperature` API additions. + **No `[Obsolete]` shims** — breaking members are removed outright (call this out in the + changelog so consumers know to update). + +## Acceptance + +The selected-segment one-liners actually target selected segments; transitions above 255 +units work end-to-end; ranged-random effect/palette selection is expressible; color +temperature is no longer stricter than WLED; and a first slice of config is ergonomically +typed — all with no `[Obsolete]` baggage and a documented, breaking-change CHANGELOG. diff --git a/plans/README.md b/plans/README.md index 43e22f5..a864e12 100644 --- a/plans/README.md +++ b/plans/README.md @@ -64,6 +64,7 @@ These plans cross-reference two mature community libraries (linked by the WLED d | 9 | [Effect metadata (`/json/fxdata`)](9-effect-metadata.md) | Feature | | 10 | [Configuration API (`/json/cfg`)](10-config-api.md) | Feature | | 11 | [Client ergonomics & cross-cutting concerns](11-client-ergonomics-and-cross-cutting.md) | Quality | +| 12 | [Usability & correctness improvements (post-review)](12-usability-and-correctness-improvements.md) | Correctness | Plan 0 modernises the toolchain (multi-targeting `netstandard2.0;net8.0;net9.0;net10.0`) and should land first. Plans 1–2 are the ergonomic foundation and unblock everything else. Plans 3–10 add From 09a2ee8316e7ca3426b3393c3f797e1a780aa807 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:21:59 +0100 Subject: [PATCH 17/32] Widen transition (transition/tt) from byte to ushort to support 0-65535 range --- src/Kevsoft.WLED/Fluent/StateUpdate.cs | 10 +++---- src/Kevsoft.WLED/StateRequest.cs | 6 ++-- src/Kevsoft.WLED/StateResponse.cs | 4 +-- .../StateUpdateBuilderTests.cs | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/Kevsoft.WLED/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs index cb54dc7..39e54a1 100644 --- a/src/Kevsoft.WLED/Fluent/StateUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -32,7 +32,7 @@ public StateUpdate Brightness(ByteAdjust brightness) return this; } - /// Set the crossfade transition duration (rounded to 100ms units, max 25.5s). + /// Set the crossfade transition duration (rounded to 100ms units, max ~109 minutes). public StateUpdate Transition(TimeSpan duration) { _request.Transition = ToTransitionUnits(duration); @@ -131,18 +131,18 @@ internal StateRequest Build() return _request; } - private static byte ToTransitionUnits(TimeSpan duration) + private static ushort ToTransitionUnits(TimeSpan duration) { var units = Math.Round(duration.TotalMilliseconds / 100.0, MidpointRounding.AwayFromZero); if (units < 0) { units = 0; } - else if (units > byte.MaxValue) + else if (units > ushort.MaxValue) { - units = byte.MaxValue; + units = ushort.MaxValue; } - return (byte)units; + return (ushort)units; } } diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index f044ef2..d16dd67 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -15,15 +15,15 @@ public sealed class StateRequest /// [JsonPropertyName("transition")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public byte? Transition { get; set; } + public ushort? Transition { get; set; } /// /// Sets the transition time for the current API call only (the tt field). - /// One unit is 100ms. + /// One unit is 100ms. Range 0–65535. /// [JsonPropertyName("tt")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public byte? TransientTransition { get; set; } + public ushort? TransientTransition { get; set; } /// [JsonPropertyName("ps")] diff --git a/src/Kevsoft.WLED/StateResponse.cs b/src/Kevsoft.WLED/StateResponse.cs index b576b3b..da9c803 100644 --- a/src/Kevsoft.WLED/StateResponse.cs +++ b/src/Kevsoft.WLED/StateResponse.cs @@ -15,10 +15,10 @@ public sealed class StateResponse public byte Brightness { get; set; } /// - /// Duration of the crossfade between different colors/brightness levels. One unit is 100ms, so a value of 4 results in a transition of 400ms. + /// Duration of the crossfade between different colors/brightness levels. One unit is 100ms, so a value of 4 results in a transition of 400ms. Range 0–65535. /// [JsonPropertyName("transition")] - public byte Transition { get; set; } + public ushort Transition { get; set; } /// /// ID of currently set preset, or null when none is active. diff --git a/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs index c12b82c..2577d52 100644 --- a/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs +++ b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs @@ -52,4 +52,34 @@ await client.UpdateState(s => s root.GetProperty("on").GetString().Should().Be("t"); root.GetProperty("tt").GetInt32().Should().Be(10); } + + [Fact] + public async Task UpdateStateSupportsTransitionAboveByteRange() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s.Transition(TimeSpan.FromSeconds(60))); + + var (_, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("transition").GetInt32().Should().Be(600); + } + + [Fact] + public async Task UpdateStateClampsTransitionToUshortMax() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s.Transition(TimeSpan.FromHours(3))); + + var (_, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + root.GetProperty("transition").GetInt32().Should().Be(ushort.MaxValue); + } } From ba0ed712e03049aa685d3015e0480ca976531bf9 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:23:13 +0100 Subject: [PATCH 18/32] Add ranged-random effect/palette selector (Selector.RandomInRange, 'from~tor') --- src/Kevsoft.WLED/Commands/Selector.cs | 47 ++++++++++++++++--- .../Commands/SelectorJsonConverter.cs | 30 ++++++++++-- test/Kevsoft.WLED.Tests/CommandValueTests.cs | 27 +++++++++++ 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/Kevsoft.WLED/Commands/Selector.cs b/src/Kevsoft.WLED/Commands/Selector.cs index 1a9b81d..bd813b6 100644 --- a/src/Kevsoft.WLED/Commands/Selector.cs +++ b/src/Kevsoft.WLED/Commands/Selector.cs @@ -1,8 +1,8 @@ namespace Kevsoft.WLED; /// -/// Selects an effect or palette by id, by relative movement ("~"/"~-") or -/// at random ("r"). +/// Selects an effect or palette by id, by relative movement ("~"/"~-"), +/// at random ("r") or at random within a range ("from~tor"). /// [JsonConverter(typeof(SelectorJsonConverter))] public readonly struct Selector : IEquatable @@ -13,17 +13,29 @@ internal enum Kind : byte Next, Previous, Random, + RandomInRange, } private Selector(Kind kind, int id) + : this(kind, id, id) + { + } + + private Selector(Kind kind, int from, int to) { Type = kind; - IdValue = id; + From = from; + To = to; } internal Kind Type { get; } - internal int IdValue { get; } + internal int From { get; } + + internal int To { get; } + + /// The selected id (only meaningful when this is an selector). + internal int IdValue => From; /// Select a specific id. public static Selector Id(int id) => new(Kind.Id, id); @@ -37,20 +49,41 @@ private Selector(Kind kind, int id) /// Select a random entry. public static Selector Random => new(Kind.Random, 0); + /// Select a random entry between and (inclusive). + public static Selector RandomInRange(int from, int to) + { + if (to < from) + { + throw new ArgumentException($"'to' ({to}) must be greater than or equal to 'from' ({from}).", nameof(to)); + } + + return new Selector(Kind.RandomInRange, from, to); + } + public static implicit operator Selector(int id) => Id(id); - public bool Equals(Selector other) => Type == other.Type && IdValue == other.IdValue; + public bool Equals(Selector other) => Type == other.Type && From == other.From && To == other.To; public override bool Equals(object? obj) => obj is Selector other && Equals(other); - public override int GetHashCode() => ((int)Type * 397) ^ IdValue; + public override int GetHashCode() + { + unchecked + { + var hash = (int)Type; + hash = (hash * 397) ^ From; + hash = (hash * 397) ^ To; + return hash; + } + } public override string ToString() => Type switch { - Kind.Id => IdValue.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind.Id => From.ToString(System.Globalization.CultureInfo.InvariantCulture), Kind.Next => "~", Kind.Previous => "~-", Kind.Random => "r", + Kind.RandomInRange => $"{From}~{To}r", _ => throw new InvalidOperationException(), }; diff --git a/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs b/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs index f3b95d6..08a8054 100644 --- a/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs +++ b/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs @@ -1,6 +1,6 @@ namespace Kevsoft.WLED; -/// Serializes as a number, "~", "~-" or "r". +/// Serializes as a number, "~", "~-", "r" or "from~tor". public sealed class SelectorJsonConverter : JsonConverter { public override Selector Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -12,18 +12,42 @@ public override Selector Read(ref Utf8JsonReader reader, Type typeToConvert, Jso if (reader.TokenType == JsonTokenType.String) { - return reader.GetString() switch + var value = reader.GetString(); + return value switch { "~" => Selector.Next, "~-" => Selector.Previous, "r" => Selector.Random, - var value => throw new JsonException($"'{value}' is not a valid selector token."), + _ => ParseRangedRandom(value), }; } throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a selector."); } + private static Selector ParseRangedRandom(string? value) + { + // Expected form: "{from}~{to}r", e.g. "5~10r". + if (value is not null && value.Length > 1 && value[value.Length - 1] == 'r') + { + var body = value.Substring(0, value.Length - 1); + var separator = body.IndexOf('~'); + if (separator > 0 && separator < body.Length - 1) + { + var fromText = body.Substring(0, separator); + var toText = body.Substring(separator + 1); + if (int.TryParse(fromText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var from) + && int.TryParse(toText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var to) + && to >= from) + { + return Selector.RandomInRange(from, to); + } + } + } + + throw new JsonException($"'{value}' is not a valid selector token."); + } + public override void Write(Utf8JsonWriter writer, Selector value, JsonSerializerOptions options) { if (value.Type == Selector.Kind.Id) diff --git a/test/Kevsoft.WLED.Tests/CommandValueTests.cs b/test/Kevsoft.WLED.Tests/CommandValueTests.cs index 285ce4f..56f867a 100644 --- a/test/Kevsoft.WLED.Tests/CommandValueTests.cs +++ b/test/Kevsoft.WLED.Tests/CommandValueTests.cs @@ -55,6 +55,33 @@ public void SelectorTokens() JsonSerializer.Serialize(Selector.Next, Options).Should().Be("\"~\""); JsonSerializer.Serialize(Selector.Previous, Options).Should().Be("\"~-\""); JsonSerializer.Serialize(Selector.Random, Options).Should().Be("\"r\""); + JsonSerializer.Serialize(Selector.RandomInRange(5, 10), Options).Should().Be("\"5~10r\""); + } + + [Fact] + public void SelectorRoundTrips() + { + JsonSerializer.Deserialize("5", Options).Should().Be(Selector.Id(5)); + JsonSerializer.Deserialize("\"~\"", Options).Should().Be(Selector.Next); + JsonSerializer.Deserialize("\"~-\"", Options).Should().Be(Selector.Previous); + JsonSerializer.Deserialize("\"r\"", Options).Should().Be(Selector.Random); + JsonSerializer.Deserialize("\"5~10r\"", Options).Should().Be(Selector.RandomInRange(5, 10)); + } + + [Fact] + public void SelectorRejectsInvalidRange() + { + var act = () => Selector.RandomInRange(10, 5); + + act.Should().Throw(); + } + + [Fact] + public void SelectorRejectsInvalidToken() + { + var act = () => JsonSerializer.Deserialize("\"nope\"", Options); + + act.Should().Throw(); } [Fact] From fa4bf07e0224a8160ef463e5f623abc1535a7bb1 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:24:07 +0100 Subject: [PATCH 19/32] Widen ColorTemperature Kelvin range to 1000-20000 and add KelvinUnchecked escape hatch --- src/Kevsoft.WLED/ColorTemperature.cs | 30 ++++++++++++++--- test/Kevsoft.WLED.Tests/SegmentObjectTests.cs | 33 +++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/src/Kevsoft.WLED/ColorTemperature.cs b/src/Kevsoft.WLED/ColorTemperature.cs index f751b2d..6153dfd 100644 --- a/src/Kevsoft.WLED/ColorTemperature.cs +++ b/src/Kevsoft.WLED/ColorTemperature.cs @@ -2,14 +2,18 @@ namespace Kevsoft.WLED; /// /// A segment colour temperature. WLED accepts either a relative value (0–255) or an -/// absolute value in Kelvin (1900–10091), so this type makes the caller state which they -/// mean and guarantees the value is in range. +/// absolute value in Kelvin, so this type makes the caller state which they mean. /// +/// +/// The Kelvin range is validated against the forward-compatible bounds the WLED docs +/// advise integrations to expect (). Use +/// if you need to send a value outside that range. +/// [JsonConverter(typeof(ColorTemperatureJsonConverter))] public readonly struct ColorTemperature : IEquatable { - internal const int MinKelvin = 1900; - internal const int MaxKelvin = 10091; + internal const int MinKelvin = 1000; + internal const int MaxKelvin = 20000; private ColorTemperature(int value, bool isKelvin) { @@ -26,7 +30,7 @@ private ColorTemperature(int value, bool isKelvin) /// A relative colour temperature (0 = warmest, 255 = coldest). public static ColorTemperature Relative(byte value) => new(value, false); - /// An absolute colour temperature in Kelvin (1900–10091). + /// An absolute colour temperature in Kelvin (). public static ColorTemperature Kelvin(int kelvin) { if (kelvin < MinKelvin || kelvin > MaxKelvin) @@ -37,6 +41,22 @@ public static ColorTemperature Kelvin(int kelvin) return new ColorTemperature(kelvin, true); } + /// + /// An absolute colour temperature in Kelvin without range validation. Use this when newer + /// firmware or hardware legitimately reports/accepts a value outside + /// . The value must be above 255 to be + /// interpreted as Kelvin by WLED. + /// + public static ColorTemperature KelvinUnchecked(int kelvin) + { + if (kelvin <= byte.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(kelvin), kelvin, "Kelvin must be greater than 255; lower values are treated as relative (0–255)."); + } + + return new ColorTemperature(kelvin, true); + } + internal static ColorTemperature FromWire(int value) => new(value, value > byte.MaxValue); public bool Equals(ColorTemperature other) => Value == other.Value && IsKelvin == other.IsKelvin; diff --git a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs index c9f2314..e9a01ea 100644 --- a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs +++ b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs @@ -31,8 +31,8 @@ public void KelvinColorTemperatureRoundTrips() } [Theory] - [InlineData(1899)] - [InlineData(10092)] + [InlineData(999)] + [InlineData(20001)] public void KelvinOutOfRangeThrows(int kelvin) { var act = () => ColorTemperature.Kelvin(kelvin); @@ -40,6 +40,35 @@ public void KelvinOutOfRangeThrows(int kelvin) act.Should().Throw(); } + [Theory] + [InlineData(1000)] + [InlineData(16000)] + [InlineData(20000)] + public void KelvinAcceptsForwardCompatibleRange(int kelvin) + { + var cct = ColorTemperature.Kelvin(kelvin); + + JsonSerializer.Serialize(cct, Options).Should().Be(kelvin.ToString()); + cct.IsKelvin.Should().BeTrue(); + } + + [Fact] + public void KelvinUncheckedAllowsValuesBeyondRange() + { + var cct = ColorTemperature.KelvinUnchecked(25000); + + JsonSerializer.Serialize(cct, Options).Should().Be("25000"); + cct.IsKelvin.Should().BeTrue(); + } + + [Fact] + public void KelvinUncheckedRejectsRelativeRangeValues() + { + var act = () => ColorTemperature.KelvinUnchecked(200); + + act.Should().Throw(); + } + [Fact] public void ColorTemperatureReadInfersKelvinAboveByteRange() { From 71dc741d1308109cfb229d0b7e08e20096965d2f Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:27:58 +0100 Subject: [PATCH 20/32] Fix no-id intent methods to target selected segments via seg object form Model the state 'seg' field as a union (SegmentPayload) that serializes as either an object (selected segments) or an array (id-targeted), matching the WLED API. No-id SetColor/SetEffect/SetPalette now emit the object form instead of an array that WLED would infer as id:0. --- src/Kevsoft.WLED/SegmentPayload.cs | 52 +++++++++++++ .../SegmentPayloadJsonConverter.cs | 35 +++++++++ src/Kevsoft.WLED/StateRequest.cs | 2 +- src/Kevsoft.WLED/WLedClient.cs | 10 ++- test/Kevsoft.WLED.Tests/IntentMethodTests.cs | 19 +++-- .../Kevsoft.WLED.Tests/SegmentPayloadTests.cs | 73 +++++++++++++++++++ 6 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 src/Kevsoft.WLED/SegmentPayload.cs create mode 100644 src/Kevsoft.WLED/SegmentPayloadJsonConverter.cs create mode 100644 test/Kevsoft.WLED.Tests/SegmentPayloadTests.cs diff --git a/src/Kevsoft.WLED/SegmentPayload.cs b/src/Kevsoft.WLED/SegmentPayload.cs new file mode 100644 index 0000000..6b0a8d2 --- /dev/null +++ b/src/Kevsoft.WLED/SegmentPayload.cs @@ -0,0 +1,52 @@ +namespace Kevsoft.WLED; + +/// +/// The seg field of a state request, which WLED accepts as either a single object or +/// an array of objects. +/// +/// +/// Use the object form () to target the currently selected +/// segments without naming an id. Use the array form () to target +/// specific segments by id. This distinction matters: WLED infers id:0 for an array +/// entry that omits its id, so the object form is the only way to address "the selected +/// segments". +/// +[JsonConverter(typeof(SegmentPayloadJsonConverter))] +public sealed class SegmentPayload +{ + private SegmentPayload(SegmentRequest? single, SegmentRequest[]? many) + { + Single = single; + Many = many; + } + + /// The single-segment (object) form, or null when this is the array form. + internal SegmentRequest? Single { get; } + + /// The multi-segment (array) form, or null when this is the object form. + internal SegmentRequest[]? Many { get; } + + /// + /// Target the currently selected segments using the object form ("seg":{...}). + /// + public static SegmentPayload Selected(SegmentRequest segment) + => new(segment ?? throw new ArgumentNullException(nameof(segment)), null); + + /// + /// Target specific segments by id using the array form ("seg":[{...}]). + /// + public static SegmentPayload List(params SegmentRequest[] segments) + => new(null, segments ?? throw new ArgumentNullException(nameof(segments))); + + /// + /// Implicitly wraps a single segment. A segment with no uses + /// the object form (selected segments); one with an id uses the array form (id-targeted), so + /// the WLED id:0 inference can never silently mis-target an explicitly-id'd segment. + /// + public static implicit operator SegmentPayload(SegmentRequest segment) + => segment is null + ? throw new ArgumentNullException(nameof(segment)) + : segment.Id is null ? Selected(segment) : List(segment); + + public static implicit operator SegmentPayload(SegmentRequest[] segments) => List(segments); +} diff --git a/src/Kevsoft.WLED/SegmentPayloadJsonConverter.cs b/src/Kevsoft.WLED/SegmentPayloadJsonConverter.cs new file mode 100644 index 0000000..b6a16bb --- /dev/null +++ b/src/Kevsoft.WLED/SegmentPayloadJsonConverter.cs @@ -0,0 +1,35 @@ +namespace Kevsoft.WLED; + +/// +/// Serializes as either a single object (selected-segment form) +/// or an array of objects (id-targeted form), matching what WLED accepts for seg. +/// +public sealed class SegmentPayloadJsonConverter : JsonConverter +{ + public override SegmentPayload? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + return null; + case JsonTokenType.StartObject: + return SegmentPayload.Selected(JsonSerializer.Deserialize(ref reader, options)!); + case JsonTokenType.StartArray: + return SegmentPayload.List(JsonSerializer.Deserialize(ref reader, options)!); + default: + throw new JsonException($"Unexpected token '{reader.TokenType}' when reading a segment payload."); + } + } + + public override void Write(Utf8JsonWriter writer, SegmentPayload value, JsonSerializerOptions options) + { + if (value.Single is not null) + { + JsonSerializer.Serialize(writer, value.Single, options); + } + else + { + JsonSerializer.Serialize(writer, value.Many ?? Array.Empty(), options); + } + } +} diff --git a/src/Kevsoft.WLED/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index d16dd67..47ad1c5 100644 --- a/src/Kevsoft.WLED/StateRequest.cs +++ b/src/Kevsoft.WLED/StateRequest.cs @@ -58,7 +58,7 @@ public sealed class StateRequest /// [JsonPropertyName("seg")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public SegmentRequest[]? Segments { get; set; } = null!; + public SegmentPayload? Segments { get; set; } = null!; /// /// Timebase for effects. diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 5ad4173..a764e3c 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -273,7 +273,15 @@ private static StateRequest SingleSegment(int? segmentId, Action { var segment = new SegmentRequest { Id = segmentId }; configure(segment); - return new StateRequest { Segments = new[] { segment } }; + + // No id => target the selected segments via the object form ("seg":{...}). + // An explicit id => target that segment via the array form ("seg":[{"id":N,...}]). + return new StateRequest + { + Segments = segmentId is null + ? SegmentPayload.Selected(segment) + : SegmentPayload.List(segment) + }; } private async Task GetJson(string uri, CancellationToken cancellationToken) diff --git a/test/Kevsoft.WLED.Tests/IntentMethodTests.cs b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs index 5acd717..5d6a4f5 100644 --- a/test/Kevsoft.WLED.Tests/IntentMethodTests.cs +++ b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs @@ -49,7 +49,8 @@ public async Task SetColorRgbPostsSelectedSegmentColor() var (_, body) = await Capture(client => client.SetColor(RgbColor.FromHex("FFAA00"))); var root = JsonDocument.Parse(body!).RootElement; - var segment = root.GetProperty("seg").EnumerateArray().Single(); + var segment = root.GetProperty("seg"); + segment.ValueKind.Should().Be(JsonValueKind.Object); segment.TryGetProperty("id", out _).Should().BeFalse(); var color = segment.GetProperty("col").EnumerateArray().First().EnumerateArray() .Select(x => x.GetInt32()).ToArray(); @@ -61,8 +62,9 @@ public async Task SetColorWithSegmentIdTargetsThatSegment() { var (_, body) = await Capture(client => client.SetColor(RgbColor.FromHex("010203"), segmentId: 2)); - var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); - segment.GetProperty("id").GetInt32().Should().Be(2); + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Array); + seg.EnumerateArray().Single().GetProperty("id").GetInt32().Should().Be(2); } [Fact] @@ -70,7 +72,8 @@ public async Task SetColorRgbwPostsFourChannels() { var (_, body) = await Capture(client => client.SetColor(RgbwColor.FromHex("01020304"))); - var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + segment.ValueKind.Should().Be(JsonValueKind.Object); var color = segment.GetProperty("col").EnumerateArray().First().EnumerateArray() .Select(x => x.GetInt32()).ToArray(); color.Should().Equal(1, 2, 3, 4); @@ -81,7 +84,9 @@ public async Task SetEffectPostsSegmentEffectId() { var (_, body) = await Capture(client => client.SetEffect(42)); - var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + segment.ValueKind.Should().Be(JsonValueKind.Object); + segment.TryGetProperty("id", out _).Should().BeFalse(); segment.GetProperty("fx").GetInt32().Should().Be(42); } @@ -90,7 +95,9 @@ public async Task SetPalettePostsSegmentPaletteId() { var (_, body) = await Capture(client => client.SetPalette(7, segmentId: 1)); - var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Array); + var segment = seg.EnumerateArray().Single(); segment.GetProperty("id").GetInt32().Should().Be(1); segment.GetProperty("pal").GetInt32().Should().Be(7); } diff --git a/test/Kevsoft.WLED.Tests/SegmentPayloadTests.cs b/test/Kevsoft.WLED.Tests/SegmentPayloadTests.cs new file mode 100644 index 0000000..dbf8c4f --- /dev/null +++ b/test/Kevsoft.WLED.Tests/SegmentPayloadTests.cs @@ -0,0 +1,73 @@ +namespace Kevsoft.WLED.Tests; + +public class SegmentPayloadTests +{ + private static readonly JsonSerializerOptions Options = new(); + + [Fact] + public void SelectedSerializesAsObject() + { + var payload = SegmentPayload.Selected(new SegmentRequest { EffectId = 5 }); + + var json = JsonSerializer.Serialize(payload, Options); + + var root = JsonDocument.Parse(json).RootElement; + root.ValueKind.Should().Be(JsonValueKind.Object); + root.GetProperty("fx").GetInt32().Should().Be(5); + root.TryGetProperty("id", out _).Should().BeFalse(); + } + + [Fact] + public void ListSerializesAsArray() + { + var payload = SegmentPayload.List( + new SegmentRequest { Id = 0, EffectId = 1 }, + new SegmentRequest { Id = 1, EffectId = 2 }); + + var json = JsonSerializer.Serialize(payload, Options); + + var root = JsonDocument.Parse(json).RootElement; + root.ValueKind.Should().Be(JsonValueKind.Array); + root.GetArrayLength().Should().Be(2); + root[0].GetProperty("id").GetInt32().Should().Be(0); + } + + [Fact] + public void ImplicitFromSingleSegmentWithoutIdIsObject() + { + SegmentPayload payload = new SegmentRequest { EffectId = 9 }; + + JsonDocument.Parse(JsonSerializer.Serialize(payload, Options)).RootElement + .ValueKind.Should().Be(JsonValueKind.Object); + } + + [Fact] + public void ImplicitFromSingleSegmentWithIdIsArray() + { + SegmentPayload payload = new SegmentRequest { Id = 2, EffectId = 9 }; + + JsonDocument.Parse(JsonSerializer.Serialize(payload, Options)).RootElement + .ValueKind.Should().Be(JsonValueKind.Array); + } + + [Fact] + public void ImplicitFromArrayIsArray() + { + SegmentPayload payload = new[] { new SegmentRequest { Id = 3 } }; + + JsonDocument.Parse(JsonSerializer.Serialize(payload, Options)).RootElement + .ValueKind.Should().Be(JsonValueKind.Array); + } + + [Fact] + public void ReadsBothObjectAndArrayForms() + { + var single = JsonSerializer.Deserialize("{\"fx\":4}", Options)!; + var many = JsonSerializer.Deserialize("[{\"id\":0},{\"id\":1}]", Options)!; + + JsonDocument.Parse(JsonSerializer.Serialize(single, Options)).RootElement + .ValueKind.Should().Be(JsonValueKind.Object); + JsonDocument.Parse(JsonSerializer.Serialize(many, Options)).RootElement + .ValueKind.Should().Be(JsonValueKind.Array); + } +} From 48cc290dc31424e32d3b8886b63f5b0ab8e77594 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:30:09 +0100 Subject: [PATCH 21/32] Add typed config fields for identity mDNS, MQTT and boot defaults Model id.mdns, if.mqtt (en/broker/port/user/cid) and def (on/bri/ps) as typed properties while keeping JsonExtensionData as an escape hatch for everything else. --- src/Kevsoft.WLED/Config/DeviceConfig.cs | 52 +++++++++++++++++++ .../Kevsoft.WLED.Tests/ConfigAndNodesTests.cs | 43 ++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/Kevsoft.WLED/Config/DeviceConfig.cs b/src/Kevsoft.WLED/Config/DeviceConfig.cs index 1524558..f9d3d1b 100644 --- a/src/Kevsoft.WLED/Config/DeviceConfig.cs +++ b/src/Kevsoft.WLED/Config/DeviceConfig.cs @@ -18,6 +18,11 @@ public sealed class IdentityConfig : ConfigSection [JsonPropertyName("name")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Name { get; set; } + + /// The mDNS hostname (the .local address), without the domain suffix. + [JsonPropertyName("mdns")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? MdnsName { get; set; } } /// Network/Wi-Fi client configuration (cfg.nw). Changing this can disconnect the device. @@ -38,6 +43,39 @@ public sealed class HardwareConfig : ConfigSection /// Interface configuration such as sync, MQTT and time (cfg.if). public sealed class InterfacesConfig : ConfigSection { + /// MQTT configuration (mqtt). + [JsonPropertyName("mqtt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public MqttConfig? Mqtt { get; set; } +} + +/// MQTT broker configuration (cfg.if.mqtt). +public sealed class MqttConfig : ConfigSection +{ + /// Whether the MQTT integration is enabled. + [JsonPropertyName("en")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Enabled { get; set; } + + /// The MQTT broker host. + [JsonPropertyName("broker")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Broker { get; set; } + + /// The MQTT broker port. + [JsonPropertyName("port")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Port { get; set; } + + /// The MQTT username. + [JsonPropertyName("user")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? User { get; set; } + + /// The MQTT client id. + [JsonPropertyName("cid")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ClientId { get; set; } } /// Light/behaviour configuration (cfg.light). @@ -48,6 +86,20 @@ public sealed class LightConfig : ConfigSection /// Boot default configuration (cfg.def). public sealed class DefaultsConfig : ConfigSection { + /// The on/off state applied at boot. + [JsonPropertyName("on")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? On { get; set; } + + /// The brightness applied at boot (0–255). + [JsonPropertyName("bri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public byte? Brightness { get; set; } + + /// The preset id applied at boot. + [JsonPropertyName("ps")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? PresetId { get; set; } } /// diff --git a/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs index 518eaae..a07c769 100644 --- a/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs +++ b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs @@ -38,11 +38,49 @@ public async Task GetNodesReturnsEmptyForEmptyPayload() nodes.Should().BeEmpty(); } + [Fact] + public async Task GetConfigParsesTypedSectionsAndPreservesUnknownKeys() + { + var json = @"{ + ""id"":{""name"":""WLED"",""mdns"":""wled-desk"",""inv"":""Light""}, + ""if"":{""mqtt"":{""en"":true,""broker"":""mqtt.local"",""port"":1883,""user"":""u"",""cid"":""WLED-1"",""rtn"":false}}, + ""def"":{""on"":true,""bri"":128,""ps"":5}, + ""custom_firmware_key"":42 + }"; + + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/cfg", json); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var config = await client.GetConfig(); + + config.Identity!.Name.Should().Be("WLED"); + config.Identity.MdnsName.Should().Be("wled-desk"); + config.Identity.Unknown.Should().ContainKey("inv"); + config.Interfaces!.Mqtt!.Enabled.Should().BeTrue(); + config.Interfaces.Mqtt.Broker.Should().Be("mqtt.local"); + config.Interfaces.Mqtt.Port.Should().Be(1883); + config.Interfaces.Mqtt.User.Should().Be("u"); + config.Interfaces.Mqtt.ClientId.Should().Be("WLED-1"); + config.Interfaces.Mqtt.Unknown.Should().ContainKey("rtn"); + config.Defaults!.On.Should().BeTrue(); + config.Defaults.Brightness.Should().Be(128); + config.Defaults.PresetId.Should().Be(5); + + // Round-trip should preserve both typed and unknown keys. + var element = JsonDocument.Parse(JsonSerializer.Serialize(config)).RootElement; + element.GetProperty("id").GetProperty("inv").GetString().Should().Be("Light"); + element.GetProperty("if").GetProperty("mqtt").GetProperty("broker").GetString().Should().Be("mqtt.local"); + element.GetProperty("def").GetProperty("bri").GetInt32().Should().Be(128); + element.GetProperty("custom_firmware_key").GetInt32().Should().Be(42); + } + [Fact] public async Task GetConfigPreservesUnknownKeys() { var json = @"{ - ""id"":{""name"":""WLED"",""mdns"":""wled-desk""}, + ""id"":{""name"":""WLED"",""mdns"":""wled-desk"",""inv"":""Light""}, ""nw"":{""ins"":[{""ssid"":""home""}]}, ""hw"":{""led"":{""total"":30}}, ""custom_firmware_key"":42 @@ -56,13 +94,14 @@ public async Task GetConfigPreservesUnknownKeys() var config = await client.GetConfig(); config.Identity!.Name.Should().Be("WLED"); - config.Identity.Unknown.Should().ContainKey("mdns"); + config.Identity.Unknown.Should().ContainKey("inv"); config.Unknown.Should().ContainKey("custom_firmware_key"); // Round-trip should preserve the unknown keys. var roundTripped = JsonSerializer.Serialize(config); var element = JsonDocument.Parse(roundTripped).RootElement; element.GetProperty("id").GetProperty("mdns").GetString().Should().Be("wled-desk"); + element.GetProperty("id").GetProperty("inv").GetString().Should().Be("Light"); element.GetProperty("custom_firmware_key").GetInt32().Should().Be(42); element.GetProperty("hw").GetProperty("led").GetProperty("total").GetInt32().Should().Be(30); } From 1e0c70e9eaf53c90862405fd954d707f84c9241a Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 14:31:30 +0100 Subject: [PATCH 22/32] Update README, CHANGELOG and sample for post-review API changes --- CHANGELOG.md | 16 ++++++++++++++++ README.md | 13 ++++++++++--- samples/BasicConsole/Program.cs | 5 +++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6dc886..fdceeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,22 @@ rather than deprecated, so consuming code must be updated. request types, preventing accidental round-tripping of read-only fields. - Posting state is now done through intent methods or `UpdateState(...)` rather than mutating and re-posting a response object. +- **`SetColor`/`SetEffect`/`SetPalette` with no `segmentId` now target the *selected* segments** + (the WLED `"seg":{…}` object form) instead of segment 0. The state `seg` field is modelled as + a `SegmentPayload` union that serialises as either an object (selected segments) or an array + (id-targeted). +- **Transition values (`transition`/`tt`) widened from `byte` to `ushort`** to support the + documented `0–65535` range (~109 minutes) instead of clamping at 25.5 s. +- **`ColorTemperature.Kelvin` range widened to `1000–20000 K`** to match the docs' forward- + compatible guidance, with a new `ColorTemperature.KelvinUnchecked(int)` escape hatch for + values outside that range. + +### Added + +- **Ranged-random effect/palette selection** via `Selector.RandomInRange(from, to)` + (the WLED `"from~tor"` token). +- **Typed device-configuration fields** for `id.mdns`, `if.mqtt` (`en`/`broker`/`port`/`user`/`cid`) + and `def` (`on`/`bri`/`ps`), while preserving all other keys through `JsonExtensionData`. ### Removed diff --git a/README.md b/README.md index 6823901..1a8129f 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,19 @@ await client.TurnOff(); await client.Toggle(); await client.SetBrightness(200); -await client.SetColor(RgbColor.FromHex("FFAA00")); -await client.SetEffect(9); // by effect id -await client.SetPalette(11); // by palette id +await client.SetColor(RgbColor.FromHex("FFAA00")); // selected segments +await client.SetColor(RgbColor.FromHex("FFAA00"), segmentId: 1); +await client.SetEffect(9); // by effect id +await client.SetPalette(11); // by palette id +await client.SetEffect(Selector.Random); // random effect +await client.SetPalette(Selector.RandomInRange(5, 10)); // random palette in a range await client.Reboot(); ``` +With no `segmentId`, `SetColor`/`SetEffect`/`SetPalette` target the currently *selected* +segments (the WLED `"seg":{…}` object form); pass a `segmentId` to target one segment. + ### Reading data ```csharp @@ -76,6 +82,7 @@ Build a sparse update that only sends the fields you set: await client.UpdateState(update => update .TurnOn() .Brightness(128) + .Transition(TimeSpan.FromSeconds(2)) // crossfade, up to ~109 minutes .Segment(0, segment => segment .Effect(0) .Color(RgbColor.FromHex("0066FF")) diff --git a/samples/BasicConsole/Program.cs b/samples/BasicConsole/Program.cs index 2ebd001..0ae42d1 100644 --- a/samples/BasicConsole/Program.cs +++ b/samples/BasicConsole/Program.cs @@ -7,9 +7,9 @@ await client.TurnOn(); await client.SetBrightness(200); -await client.SetColor(RgbColor.FromHex("FFAA00")); // warm orange +await client.SetColor(RgbColor.FromHex("FFAA00")); // warm orange, selected segments await client.SetEffect(9); // "Rainbow" -await client.SetPalette(11); // "Rainbow" +await client.SetPalette(Selector.RandomInRange(5, 10)); // random palette in a range // --- Reading state --------------------------------------------------------- @@ -24,6 +24,7 @@ await client.UpdateState(update => update .TurnOn() .Brightness(128) + .Transition(TimeSpan.FromSeconds(2)) .Segment(0, segment => segment .Effect(0) .Color(RgbColor.FromHex("0066FF")))); From 2bb843cb83c14751506780f95223494098179e1d Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:24:33 +0100 Subject: [PATCH 23/32] Add plan 13 for device ergonomics and agent guidance --- ...13-device-ergonomics-and-agent-guidance.md | 332 ++++++++++++++++++ plans/README.md | 1 + 2 files changed, 333 insertions(+) create mode 100644 plans/13-device-ergonomics-and-agent-guidance.md diff --git a/plans/13-device-ergonomics-and-agent-guidance.md b/plans/13-device-ergonomics-and-agent-guidance.md new file mode 100644 index 0000000..e3ec234 --- /dev/null +++ b/plans/13-device-ergonomics-and-agent-guidance.md @@ -0,0 +1,332 @@ +# Plan 13 — Device ergonomics, catalogs & agent guidance + +**Theme:** Usability · Developer experience · Agent readiness + +## Why + +After implementing the API coverage and correctness work in Plans 0–12, the next best +upgrade is not broad endpoint expansion. The remaining usability gap is that consumers +still need to stitch together state, info, effects, palettes, metadata, and config pieces +themselves. + +This plan focuses on making the library feel like a cohesive .NET SDK: + +- model a WLED device as a first-class aggregate, +- make effects and palettes discoverable by id/name rather than raw arrays, +- expose selected-segment updates ergonomically, +- make effect metadata actionable, +- reduce raw primitive ids/ranges where they invite mistakes, and +- add a root `AGENTS.md` so future coding agents have the project rules, commands, and + gotchas in one place. + +New endpoints are only included if they directly improve ergonomics. + +--- + +## 1. Create root `AGENTS.md` + +Add `AGENTS.md` at the repository root with the information agents need before changing +this project. + +It should include: + +- **Project purpose:** WLED.NET is a strongly typed, hard-to-misuse .NET wrapper for the + WLED JSON API. +- **Design principle:** prefer expressive types/builders over primitive DTO bags; make + invalid states hard or impossible to represent. +- **Target frameworks:** library multi-targets `netstandard2.0;net8.0;net9.0;net10.0`; + tests run on net8/net9/net10. Mention netstandard2.0 constraints such as no + `Math.Clamp`, no `System.HashCode`, and older async API shapes. +- **Commands:** `dotnet build WLED.NET.sln -c Release` and + `dotnet test WLED.NET.sln -c Release`. +- **Repository conventions:** response DTOs are total/immutable where practical; request + DTOs are sparse and omit nulls; raw WLED JSON keys stay in `[JsonPropertyName]`; public + happy paths use builders/value types. +- **Breaking-change stance:** no `[Obsolete]` compatibility shims unless explicitly + requested; document breaking changes in `CHANGELOG.md`. +- **Docs/sample expectations:** README feature matrix, CHANGELOG, and + `samples/BasicConsole` stay current with public API changes. +- **Current gotchas:** `seg` object form targets selected segments; `seg` array form + targets explicit segment ids; `transition`/`tt` are `ushort`; `JsonExtensionData` + preserves unknown config fields. + +Definition of Done: + +- `AGENTS.md` exists in the root and is specific enough that a new agent can safely make + changes without reading prior conversation history. +- It references the WLED JSON API docs and this `plans/` folder. +- It does not contain secrets, environment-specific credentials, or private assumptions. + +--- + +## 2. Add a cohesive `WLedDevice` snapshot API + +Today callers can fetch each data source individually (`GetState`, `GetInformation`, +`GetEffects`, `GetPalettes`, `GetEffectMetadata`, etc.), but app code has to combine +those pieces itself. + +Add a high-level read model: + +```csharp +var device = await client.GetDevice(); + +Console.WriteLine(device.Name); +Console.WriteLine(device.ActiveSegments.Count); +Console.WriteLine(device.CurrentPresetId); + +foreach (var segment in device.SelectedSegments) +{ + Console.WriteLine($"{segment.Id}: {segment.Effect.Name}"); +} +``` + +Proposed shape: + +- `WLedDevice` + - `State` + - `Information` + - `Effects` + - `Palettes` + - optional `EffectMetadata` + - derived helpers: `Name`, `Version`, `ActiveSegments`, `SelectedSegments`, + `CurrentPresetId`, `CurrentPlaylistId`, `SupportsWhiteChannel`, etc. +- `WLedDeviceSegment` + - segment id, name, selected/active flags, colors, options + - current `EffectCatalogEntry` + - current `PaletteCatalogEntry` + +Client API: + +```csharp +Task GetDevice(CancellationToken cancellationToken = default); +Task GetDevice(DeviceSnapshotOptions options, CancellationToken cancellationToken = default); +``` + +`DeviceSnapshotOptions` can control optional calls such as effect metadata if we want to +avoid extra network requests by default. + +Definition of Done: + +- One call gives consumers a coherent, easy-to-query snapshot. +- Derived properties are tested with realistic JSON. +- README shows `GetDevice()` as the recommended read path for app/UI code. + +--- + +## 3. Add catalog-aware effect and palette APIs + +The WLED docs note that effect arrays contain reserved entries such as `RSVD` and `-`. +Today callers receive raw `string[]` and need to remember ids manually. + +Add typed catalog entries: + +```csharp +var effect = device.Effects.FindByName("Rainbow"); +await client.SetEffect(effect); + +var palettes = device.Palettes.AvailableOnly(); +await client.SetPalette(palettes.FindByName("Aurora")); +``` + +Proposed types: + +- `EffectCatalog` / `PaletteCatalog` +- `EffectCatalogEntry` / `PaletteCatalogEntry` + - `Id` + - `Name` + - `IsReserved` + - possibly `IsUsable => !IsReserved` +- lookup helpers: + - `FindById` + - `FindByName` + - `TryFindByName` + - `AvailableOnly` + +Client overloads: + +```csharp +Task SetEffect(EffectCatalogEntry effect, int? segmentId = null, CancellationToken cancellationToken = default); +Task SetPalette(PaletteCatalogEntry palette, int? segmentId = null, CancellationToken cancellationToken = default); +``` + +Definition of Done: + +- Reserved entries are filtered consistently. +- Name lookup is explicit about case-sensitivity (recommend ordinal ignore-case). +- Invalid lookups fail with clear exceptions, not silent defaults. +- Existing raw id APIs remain available for advanced callers. + +--- + +## 4. Add selected-segment fluent updates + +Plan 12 fixed no-id intent methods so they use WLED's selected-segment object form. The +fluent builder should expose that same concept directly. + +Add: + +```csharp +await client.UpdateState(update => update + .SelectedSegments(segment => segment + .Color(RgbColor.FromHex("FFAA00")) + .Effect(Selector.Random))); +``` + +Semantics: + +- `SelectedSegments(...)` serializes `seg` as an object (`"seg": { ... }`). +- `Segment(id, ...)` continues to serialize `seg` as an array with explicit ids. +- Mixing selected-segment object form and explicit segment array form in the same update + should either be disallowed with a clear exception or require an explicit advanced API; + do not silently pick one. + +Definition of Done: + +- Tests assert object form for `SelectedSegments(...)`. +- Tests assert explicit id array form for `Segment(id, ...)`. +- Tests cover mixed selected/id-targeted updates and verify clear behavior. +- README uses `SelectedSegments(...)` where it makes examples clearer. + +--- + +## 5. Make effect metadata actionable + +`/json/fxdata` is parsed, but consumers still need to understand how to map metadata to +controls. Use metadata to reduce guesswork in UI builders and state updates. + +Possible APIs: + +```csharp +var metadata = await client.GetEffectMetadata(); +var rainbow = metadata.FindByName("Rainbow"); + +foreach (var control in rainbow.Controls) +{ + Console.WriteLine($"{control.Name}: {control.DefaultValue}"); +} + +await client.UpdateState(update => update + .Segment(0, segment => segment + .Effect(rainbow) + .ApplyEffectDefaults(rainbow))); +``` + +Improvements: + +- Add lookup helpers to effect metadata collections. +- Add stronger control models for sliders, colors, checkboxes, palettes, and options when + the metadata describes them. +- Add `ApplyEffectDefaults(...)` to set speed/intensity/custom sliders/options from + metadata defaults. +- Optionally expose validation helpers so app code can avoid showing unsupported controls. + +Definition of Done: + +- Metadata can be used without hand-parsing labels or raw arrays. +- Tests cover realistic metadata from WLED docs/fixtures. +- Invalid metadata fails explicitly where the parser cannot safely interpret it. + +--- + +## 6. Add stronger id and range value types + +The library already has strong command/value types, but public APIs still expose several +raw ids and ranges as `int`/`byte`. + +Add focused value types where misuse is likely: + +- `SegmentId` +- `EffectId` +- `PaletteId` +- `PresetId` +- `PlaylistId` +- `LedMapId` +- `SegmentBounds` / `MatrixBounds` + +Guidelines: + +- Do not introduce wrappers everywhere at once; start where ids cross API boundaries. +- Keep implicit conversions only where they do not hide validation or semantics. +- Prefer explicit factory methods when the value has a documented range or special + meaning. + +Definition of Done: + +- High-level APIs can accept value types as well as existing simple values where helpful. +- Tests prove invalid ranges throw before serialization. +- Documentation shows the value types in new examples without making simple usage noisy. + +--- + +## 7. Add a config update builder + +Config support is now safer and partially typed, but callers still build nested DTOs +manually. Add a builder that encourages safe partial updates. + +Example: + +```csharp +await client.UpdateConfig(config => config + .Identity(name: "Kitchen", mdnsName: "wled-kitchen") + .Mqtt(enabled: true, broker: "mqtt.local") + .BootDefaults(on: true, brightness: 128)); +``` + +Proposed behavior: + +- Builder only emits touched sections. +- Network-sensitive sections still require `AllowNetworkChanges`. +- Unknown/advanced config remains available through raw `DeviceConfig`. +- Builder methods validate known ranges and string requirements before sending. + +Definition of Done: + +- Partial config updates are easy without manually constructing DTO graphs. +- Tests assert untouched sections are omitted. +- Tests assert network-sensitive updates still require opt-in. +- README shows the builder as the recommended config update path. + +--- + +## 8. Lower-priority follow-ups + +Track these, but do not prioritize them unless a concrete user need appears: + +- `ColorSlot.Fixed(Color)` / `ColorSlot.Random` once random color slot support is stable in + WLED firmware. +- More ergonomic individual LED helpers, such as array/range/image/matrix helpers for + larger updates. +- Flexible read-only `info.sensor` support. +- `/json/palx` custom palette support only if custom palette authoring becomes a real + usability goal. + +--- + +## Files + +Likely files to touch: + +- `AGENTS.md` +- `src/Kevsoft.WLED/IWLedClient.cs` +- `src/Kevsoft.WLED/WLedClient.cs` +- new device snapshot/catalog types under `src/Kevsoft.WLED/` +- `src/Kevsoft.WLED/Fluent/StateUpdate.cs` +- `src/Kevsoft.WLED/Fluent/SegmentUpdate.cs` +- `src/Kevsoft.WLED/EffectMetadata*.cs` +- `src/Kevsoft.WLED/Config/DeviceConfig.cs` and new config builder files +- `test/Kevsoft.WLED.Tests/` +- `README.md` +- `CHANGELOG.md` +- `samples/BasicConsole/Program.cs` + +## Definition of Done + +For this plan: + +1. `AGENTS.md` exists and accurately captures project conventions, commands, and agent + gotchas. +2. New ergonomic APIs are backed by tests on net8/net9/net10. +3. README and sample show the new happy paths rather than raw DTO construction. +4. CHANGELOG records user-facing changes and breaking changes. +5. The raw DTO layer remains available for advanced/escape-hatch scenarios, but the + documented path uses builders, catalogs, snapshots, and value types. diff --git a/plans/README.md b/plans/README.md index a864e12..a847a8d 100644 --- a/plans/README.md +++ b/plans/README.md @@ -65,6 +65,7 @@ These plans cross-reference two mature community libraries (linked by the WLED d | 10 | [Configuration API (`/json/cfg`)](10-config-api.md) | Feature | | 11 | [Client ergonomics & cross-cutting concerns](11-client-ergonomics-and-cross-cutting.md) | Quality | | 12 | [Usability & correctness improvements (post-review)](12-usability-and-correctness-improvements.md) | Correctness | +| 13 | [Device ergonomics, catalogs & agent guidance](13-device-ergonomics-and-agent-guidance.md) | Usability | Plan 0 modernises the toolchain (multi-targeting `netstandard2.0;net8.0;net9.0;net10.0`) and should land first. Plans 1–2 are the ergonomic foundation and unblock everything else. Plans 3–10 add From b1cf203ff8fc2ef55b206e436eec5f796aeadf4a Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:37:39 +0100 Subject: [PATCH 24/32] Add AGENTS.md and typed effect/palette catalogs Add root AGENTS.md with project conventions, commands and gotchas. Add EffectCatalog/PaletteCatalog with id/name lookup, reserved-entry filtering (RSVD/-), and SetEffect/SetPalette catalog-entry overloads. --- AGENTS.md | 101 +++++++++++ src/Kevsoft.WLED/Catalog.cs | 205 +++++++++++++++++++++++ src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 32 ++++ src/Kevsoft.WLED/IWLedClient.cs | 24 +++ src/Kevsoft.WLED/WLedClient.cs | 36 ++++ test/Kevsoft.WLED.Tests/CatalogTests.cs | 164 ++++++++++++++++++ 6 files changed, 562 insertions(+) create mode 100644 AGENTS.md create mode 100644 src/Kevsoft.WLED/Catalog.cs create mode 100644 test/Kevsoft.WLED.Tests/CatalogTests.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b879f6f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# AGENTS.md — WLED.NET + +Guidance for coding agents (and humans) working in this repository. Read this before +making changes; it captures the project's purpose, conventions, commands and gotchas so a +change can be made safely without prior conversation history. + +## Project purpose + +**WLED.NET** is a strongly typed, hard-to-misuse .NET client for the +[WLED JSON API](https://kno.wled.ge/interfaces/json-api/). It turns the device's loosely +typed JSON (`/json`, `/json/state`, `/json/info`, `/json/eff`, `/json/pal`, `/json/cfg`, +`/json/fxdata`, `/json/nodes`, `presets.json`, …) into expressive C# types. + +## Core design principle + +> Model things well so they're easy to use. Make it impossible to send the wrong +> information by how we model the library. + +Concretely: + +- **Types over primitives.** Prefer value types, enums and command types over raw + `int`/`byte`/magic strings (`RgbColor`, `SegmentColors`, `Selector`, `ByteAdjust`, + `Toggleable`, `LightCapability` `[Flags]`, the `*Id`/`*Bounds` value types, …). +- **Validation at construction.** Where WLED documents a range (e.g. `c3` is 0–31, `bri` + is 0–255, ledmap is 0–9), the constructing type guards it so an out-of-range value + throws *before* it hits the wire. +- **Read model ≠ write model.** Responses are immutable/total; requests/builders expose + only what is settable. +- **Builders, not nullable bags.** High-level fluent builders (`StateUpdate`, + `SegmentUpdate`, `PlaylistBuilder`, `ConfigUpdate`) sit on top of the raw DTOs. + +## Repository conventions + +- **Dual DTO layer.** Each JSON object is a pair: an immutable `XResponse` (all fields, + non-null) and a sparse mutable `XRequest` (nullable fields with + `[JsonIgnore(WhenWritingNull)]`), usually with a static `Request.From(Response)` factory + and an implicit operator. The wire format never leaks into the public happy path. +- **`[JsonPropertyName]` always carries the raw WLED key**; the C# member uses a + descriptive .NET name (e.g. `EffectId` → `"fx"`). +- **Every new endpoint** adds a method to `IWLedClient` + `WLedClient`, with GET/POST + tests in `test/Kevsoft.WLED.Tests` (extend `JsonBuilder` and `MockHttpMessageHandler`). +- **Custom `JsonConverter`s** carry the "impossible to misuse" types across the wire and + are unit-tested in both directions against realistic WLED payloads. +- **Unknown keys round-trip.** Config sections use `[JsonExtensionData]` (`Unknown`) so a + read-modify-write cycle never drops firmware-specific fields. + +## Target frameworks & netstandard2.0 constraints + +- The library multi-targets `netstandard2.0;net8.0;net9.0;net10.0` + (see `Directory.Build.props`). Tests run on `net8.0;net9.0;net10.0`. +- Because of `netstandard2.0`, **do not** use: + - `Math.Clamp` — clamp manually. + - `System.HashCode` — implement `GetHashCode()` with `unchecked` arithmetic. + - newer async/Span API shapes that aren't available there. +- Prefer `readonly struct` + `IEquatable` for small value types. + +## Commands + +```pwsh +dotnet build WLED.NET.sln -c Release +dotnet test WLED.NET.sln -c Release +``` + +## Breaking-change stance + +- No `[Obsolete]` compatibility shims unless explicitly requested. +- Record user-facing and breaking changes in `CHANGELOG.md`. +- Keep raw `XResponse`/`XRequest` DTOs available as escape hatches even when adding + ergonomic builders/value types on top. + +## Docs & sample expectations (acceptance criteria) + +A feature isn't done when it compiles and tests pass. Also: + +1. Update the root `README.md` feature matrix and add/refresh a short usage snippet. +2. Keep `samples/BasicConsole` exemplary — demonstrate the *ergonomic* path (builders, + intent methods, catalogs, snapshots), not raw DTOs. +3. Update `CHANGELOG.md`. + +## Current gotchas + +- **`seg` object vs array form.** `"seg": { … }` targets the *selected* segments (no id); + `"seg": [{ "id": N, … }]` targets explicit ids. WLED infers `id:0` for an array entry + that omits its id, so the object form is the only way to address "the selected + segments". Use `SegmentPayload.Selected`/`List`, `StateUpdate.SelectedSegments(...)` for + the object form and `StateUpdate.Segment(id, ...)` for the array form. Mixing the two in + one update throws. +- **`transition`/`tt` are `ushort`** (0–65535, one unit = 100ms). +- **Reserved effects/palettes.** Entries named `RSVD` or `-` are placeholders that fall + back to Solid; filter them out of UI. `EffectCatalog`/`PaletteCatalog` expose + `IsReserved`/`AvailableOnly()` for this. +- **Effect metadata** (`/json/fxdata`) describes which controls each effect uses and their + defaults; `EffectMetadata` is aligned by id with the (reserved-filtered) effects list. +- **`GetDevice()`** is the recommended read path: it issues a single `GET /json` and + exposes state, info and effect/palette catalogs as one coherent snapshot. + +## Reference material + +- WLED JSON API docs: +- This repo's `plans/` folder contains the staged roadmap (Plans 0–13) and the conventions + every plan must uphold. diff --git a/src/Kevsoft.WLED/Catalog.cs b/src/Kevsoft.WLED/Catalog.cs new file mode 100644 index 0000000..a107c68 --- /dev/null +++ b/src/Kevsoft.WLED/Catalog.cs @@ -0,0 +1,205 @@ +using System.Collections; + +namespace Kevsoft.WLED; + +/// +/// A single entry in an effect or palette catalog: its id and display name. +/// +/// The zero-based id, matching the index in the WLED effects/palettes list. +/// The display name. +public abstract record CatalogEntry(int Id, string Name) +{ + /// + /// true if this entry is a reserved placeholder (named RSVD or -). WLED keeps + /// these so ids stay stable across builds; selecting one falls back to the Solid effect. + /// + public bool IsReserved => Name == "RSVD" || Name == "-"; + + /// true if this entry is selectable (i.e. not a reserved placeholder). + public bool IsUsable => !IsReserved; + + public override string ToString() => $"{Id}: {Name}"; +} + +/// A single effect in the . +public sealed record EffectCatalogEntry(int Id, string Name) : CatalogEntry(Id, Name); + +/// A single palette in the . +public sealed record PaletteCatalogEntry(int Id, string Name) : CatalogEntry(Id, Name); + +/// +/// A read-only, id-aligned catalog of effects or palettes that supports lookup by id or name and +/// filtering out reserved placeholder entries. +/// +/// The concrete entry type. +public abstract class Catalog : IReadOnlyList + where TEntry : CatalogEntry +{ + private readonly IReadOnlyList _entries; + + private protected Catalog(IReadOnlyList entries) + => _entries = entries ?? throw new ArgumentNullException(nameof(entries)); + + /// The entry at the given list position (which equals its id). + public TEntry this[int index] => _entries[index]; + + /// The total number of entries, including reserved placeholders. + public int Count => _entries.Count; + + /// Returns only the selectable entries, excluding reserved placeholders. + public IEnumerable AvailableOnly() + { + foreach (var entry in _entries) + { + if (entry.IsUsable) + { + yield return entry; + } + } + } + + /// Finds the entry with the given id, or throws if there is none. + public TEntry FindById(int id) + => TryFindById(id, out var entry) + ? entry + : throw new KeyNotFoundException($"No entry with id {id} exists in the catalog."); + + /// Finds the entry with the given id without throwing. + public bool TryFindById(int id, out TEntry entry) + { + foreach (var candidate in _entries) + { + if (candidate.Id == id) + { + entry = candidate; + return true; + } + } + + entry = null!; + return false; + } + + /// + /// Finds the single entry with the given name (case-insensitive, ordinal). Throws if no entry + /// or more than one entry matches. + /// + public TEntry FindByName(string name) + { + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + TEntry? match = null; + var count = 0; + + foreach (var candidate in _entries) + { + if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)) + { + match = candidate; + count++; + } + } + + return count switch + { + 0 => throw new KeyNotFoundException($"No entry named '{name}' exists in the catalog."), + 1 => match!, + _ => throw new InvalidOperationException($"More than one entry named '{name}' exists in the catalog."), + }; + } + + /// + /// Finds the single entry with the given name (case-insensitive, ordinal) without throwing. + /// Returns false if no entry, or more than one entry, matches. + /// + public bool TryFindByName(string name, out TEntry entry) + { + entry = null!; + if (name is null) + { + return false; + } + + var found = false; + + foreach (var candidate in _entries) + { + if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)) + { + if (found) + { + entry = null!; + return false; + } + + entry = candidate; + found = true; + } + } + + return found; + } + + public IEnumerator GetEnumerator() => _entries.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} + +/// +/// The device's effects, by id and name. Reserved placeholders (RSVD/-) are included so +/// ids stay aligned; use to skip them. +/// +public sealed class EffectCatalog : Catalog +{ + private EffectCatalog(IReadOnlyList entries) : base(entries) + { + } + + /// Builds an effect catalog from the raw /json/eff names. + public static EffectCatalog FromNames(IReadOnlyList names) + { + if (names is null) + { + throw new ArgumentNullException(nameof(names)); + } + + var entries = new EffectCatalogEntry[names.Count]; + for (var id = 0; id < names.Count; id++) + { + entries[id] = new EffectCatalogEntry(id, names[id]); + } + + return new EffectCatalog(entries); + } +} + +/// +/// The device's palettes, by id and name. Reserved placeholders (RSVD/-) are included so +/// ids stay aligned; use to skip them. +/// +public sealed class PaletteCatalog : Catalog +{ + private PaletteCatalog(IReadOnlyList entries) : base(entries) + { + } + + /// Builds a palette catalog from the raw /json/pal names. + public static PaletteCatalog FromNames(IReadOnlyList names) + { + if (names is null) + { + throw new ArgumentNullException(nameof(names)); + } + + var entries = new PaletteCatalogEntry[names.Count]; + for (var id = 0; id < names.Count; id++) + { + entries[id] = new PaletteCatalogEntry(id, names[id]); + } + + return new PaletteCatalog(entries); + } +} diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs index 3b1aa2d..9e2081b 100644 --- a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -43,6 +43,22 @@ public SegmentUpdate Effect(Selector effect) return this; } + /// Select the effect from a catalog entry. Throws if the entry is a reserved placeholder. + public SegmentUpdate Effect(EffectCatalogEntry effect) + { + if (effect is null) + { + throw new ArgumentNullException(nameof(effect)); + } + + if (effect.IsReserved) + { + throw new ArgumentException($"Effect '{effect.Name}' (id {effect.Id}) is a reserved placeholder and cannot be selected.", nameof(effect)); + } + + return Effect(Selector.Id(effect.Id)); + } + /// Select the color palette by id, relative movement or at random. public SegmentUpdate Palette(Selector palette) { @@ -50,6 +66,22 @@ public SegmentUpdate Palette(Selector palette) return this; } + /// Select the palette from a catalog entry. Throws if the entry is a reserved placeholder. + public SegmentUpdate Palette(PaletteCatalogEntry palette) + { + if (palette is null) + { + throw new ArgumentNullException(nameof(palette)); + } + + if (palette.IsReserved) + { + throw new ArgumentException($"Palette '{palette.Name}' (id {palette.Id}) is a reserved placeholder and cannot be selected.", nameof(palette)); + } + + return Palette(Selector.Id(palette.Id)); + } + /// Set the segment's color slots (primary, optional secondary and tertiary). public SegmentUpdate Color(SegmentColors colors) { diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 3a4fc4c..d6faf3c 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -28,6 +28,18 @@ public interface IWLedClient Task GetPalettes(CancellationToken cancellationToken = default); + /// + /// Gets the effects as a typed that supports lookup by id or name and + /// filtering out reserved (RSVD/-) placeholders. + /// + Task GetEffectCatalog(CancellationToken cancellationToken = default); + + /// + /// Gets the palettes as a typed that supports lookup by id or name and + /// filtering out reserved (RSVD/-) placeholders. + /// + Task GetPaletteCatalog(CancellationToken cancellationToken = default); + Task Post(WLedRootRequest request, CancellationToken cancellationToken = default); Task Post(StateRequest request, CancellationToken cancellationToken = default); @@ -61,6 +73,18 @@ public interface IWLedClient /// Sets the palette on a segment, or the selected segments when no id is given. Task SetPalette(Selector palette, int? segmentId = null, CancellationToken cancellationToken = default); + /// + /// Sets the effect from a catalog entry on a segment, or the selected segments when no id is given. + /// Throws if the entry is a reserved placeholder. + /// + Task SetEffect(EffectCatalogEntry effect, int? segmentId = null, CancellationToken cancellationToken = default); + + /// + /// Sets the palette from a catalog entry on a segment, or the selected segments when no id is given. + /// Throws if the entry is a reserved placeholder. + /// + Task SetPalette(PaletteCatalogEntry palette, int? segmentId = null, CancellationToken cancellationToken = default); + /// Reboots the device. Task Reboot(CancellationToken cancellationToken = default); diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index a764e3c..1194ff3 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -81,6 +81,12 @@ public Task GetEffects(CancellationToken cancellationToken = default) public Task GetPalettes(CancellationToken cancellationToken = default) => GetJson("json/pal", cancellationToken); + public async Task GetEffectCatalog(CancellationToken cancellationToken = default) + => EffectCatalog.FromNames(await GetEffects(cancellationToken)); + + public async Task GetPaletteCatalog(CancellationToken cancellationToken = default) + => PaletteCatalog.FromNames(await GetPalettes(cancellationToken)); + public Task Post(WLedRootRequest request, CancellationToken cancellationToken = default) => PostJson("/json", request, cancellationToken); @@ -123,6 +129,36 @@ public Task SetEffect(Selector effect, int? segmentId = null, CancellationToken public Task SetPalette(Selector palette, int? segmentId = null, CancellationToken cancellationToken = default) => Post(SingleSegment(segmentId, segment => segment.ColorPaletteId = palette), cancellationToken); + public Task SetEffect(EffectCatalogEntry effect, int? segmentId = null, CancellationToken cancellationToken = default) + { + if (effect is null) + { + throw new ArgumentNullException(nameof(effect)); + } + + if (effect.IsReserved) + { + throw new ArgumentException($"Effect '{effect.Name}' (id {effect.Id}) is a reserved placeholder and cannot be selected.", nameof(effect)); + } + + return SetEffect(Selector.Id(effect.Id), segmentId, cancellationToken); + } + + public Task SetPalette(PaletteCatalogEntry palette, int? segmentId = null, CancellationToken cancellationToken = default) + { + if (palette is null) + { + throw new ArgumentNullException(nameof(palette)); + } + + if (palette.IsReserved) + { + throw new ArgumentException($"Palette '{palette.Name}' (id {palette.Id}) is a reserved placeholder and cannot be selected.", nameof(palette)); + } + + return SetPalette(Selector.Id(palette.Id), segmentId, cancellationToken); + } + public Task Reboot(CancellationToken cancellationToken = default) => Post(new StateRequest { Reboot = true }, cancellationToken); diff --git a/test/Kevsoft.WLED.Tests/CatalogTests.cs b/test/Kevsoft.WLED.Tests/CatalogTests.cs new file mode 100644 index 0000000..2af0387 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/CatalogTests.cs @@ -0,0 +1,164 @@ +namespace Kevsoft.WLED.Tests; + +public class CatalogTests +{ + private static readonly string[] Effects = + { + "Solid", "Blink", "RSVD", "Rainbow", "-", "Android" + }; + + private static readonly string[] Palettes = + { + "Default", "Random Cycle", "Party", "Aurora" + }; + + [Fact] + public void FromNamesAlignsEntryIdWithIndex() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.Count.Should().Be(6); + catalog[3].Should().Be(new EffectCatalogEntry(3, "Rainbow")); + } + + [Fact] + public void ReservedEntriesAreFlagged() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.FindById(2).IsReserved.Should().BeTrue(); + catalog.FindById(4).IsReserved.Should().BeTrue(); + catalog.FindById(0).IsReserved.Should().BeFalse(); + } + + [Fact] + public void AvailableOnlyExcludesReservedPlaceholders() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.AvailableOnly().Select(x => x.Name) + .Should().Equal("Solid", "Blink", "Rainbow", "Android"); + } + + [Fact] + public void FindByIdThrowsWhenMissing() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.Invoking(c => c.FindById(99)).Should().Throw(); + } + + [Fact] + public void FindByNameIsCaseInsensitive() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.FindByName("rainbow").Id.Should().Be(3); + } + + [Fact] + public void FindByNameThrowsWhenMissing() + { + var catalog = EffectCatalog.FromNames(Effects); + + catalog.Invoking(c => c.FindByName("Nope")).Should().Throw(); + } + + [Fact] + public void FindByNameThrowsWhenAmbiguous() + { + // "RSVD" appears once, "-" once, but both reserved; craft a duplicate to prove ambiguity throws. + var catalog = EffectCatalog.FromNames(new[] { "Glow", "glow" }); + + catalog.Invoking(c => c.FindByName("Glow")).Should().Throw(); + } + + [Fact] + public void TryFindByNameReturnsFalseWhenAmbiguous() + { + var catalog = EffectCatalog.FromNames(new[] { "Glow", "glow" }); + + catalog.TryFindByName("Glow", out _).Should().BeFalse(); + } + + [Fact] + public void TryFindByNameReturnsMatch() + { + var catalog = PaletteCatalog.FromNames(Palettes); + + catalog.TryFindByName("aurora", out var entry).Should().BeTrue(); + entry.Id.Should().Be(3); + } + + [Fact] + public async Task GetEffectCatalogReadsNamedEntries() + { + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var mockHttpMessageHandler = new MockHttpMessageHandler(); + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/eff", "[\"Solid\",\"Blink\",\"Rainbow\"]"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var catalog = await client.GetEffectCatalog(); + + catalog.FindByName("Rainbow").Id.Should().Be(2); + } + + [Fact] + public async Task GetPaletteCatalogReadsNamedEntries() + { + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var mockHttpMessageHandler = new MockHttpMessageHandler(); + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/pal", "[\"Default\",\"Party\",\"Aurora\"]"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + var catalog = await client.GetPaletteCatalog(); + + catalog.FindByName("Aurora").Id.Should().Be(2); + } + + [Fact] + public async Task SetEffectFromCatalogEntryPostsId() + { + var (_, body) = await Capture(client => client.SetEffect(new EffectCatalogEntry(9, "Rainbow"))); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + segment.ValueKind.Should().Be(JsonValueKind.Object); + segment.GetProperty("fx").GetInt32().Should().Be(9); + } + + [Fact] + public async Task SetPaletteFromCatalogEntryWithIdTargetsSegment() + { + var (_, body) = await Capture(client => client.SetPalette(new PaletteCatalogEntry(3, "Aurora"), segmentId: 1)); + + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Array); + var segment = seg.EnumerateArray().Single(); + segment.GetProperty("id").GetInt32().Should().Be(1); + segment.GetProperty("pal").GetInt32().Should().Be(3); + } + + [Fact] + public async Task SetEffectFromReservedEntryThrows() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.Invoking(c => c.SetEffect(new EffectCatalogEntry(2, "RSVD"))) + .Should().ThrowAsync(); + } + + private static async Task<(string Uri, string? Body)> Capture(Func act) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await act(client); + + var (uri, body) = mockHttpMessageHandler.CapturedRequestList.Single(); + return (uri, body); + } +} From 15cc7cf767b2e05925f2f9210484ce008c399415 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:38:50 +0100 Subject: [PATCH 25/32] Add SelectedSegments fluent update for selected-segment object form StateUpdate.SelectedSegments(...) targets the selected segments via the seg object form and accumulates across calls. Mixing it with Segment(id, ...) in one update throws, since WLED's seg field is one form or the other. --- src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 3 + src/Kevsoft.WLED/Fluent/StateUpdate.cs | 58 ++++++++++++++- .../StateUpdateBuilderTests.cs | 70 +++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs index 9e2081b..b1a8404 100644 --- a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -10,6 +10,9 @@ public sealed class SegmentUpdate internal SegmentUpdate(int id) => _request = new SegmentRequest { Id = id }; + /// Creates an id-less segment update targeting the selected segments (object form). + internal SegmentUpdate() => _request = new SegmentRequest(); + /// Turn the segment on. public SegmentUpdate TurnOn() => On(Toggleable.On); diff --git a/src/Kevsoft.WLED/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs index 39e54a1..c7f892c 100644 --- a/src/Kevsoft.WLED/Fluent/StateUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -8,6 +8,15 @@ public sealed class StateUpdate { private readonly StateRequest _request = new(); private readonly List _segments = new(); + private SegmentUpdate? _selectedSegment; + private SegmentMode _segmentMode = SegmentMode.None; + + private enum SegmentMode + { + None, + Selected, + Explicit, + } /// Turn the light on. public StateUpdate TurnOn() => On(Toggleable.On); @@ -108,6 +117,10 @@ public StateUpdate NextPreset() } /// Configure the segment with the given id, patching only the properties you set. + /// + /// This targets the segment by id using the array form ("seg":[{"id":N,...}]). It cannot be + /// combined with in the same update. + /// public StateUpdate Segment(int id, Action configure) { if (configure is null) @@ -115,17 +128,58 @@ public StateUpdate Segment(int id, Action configure) throw new ArgumentNullException(nameof(configure)); } + if (_segmentMode == SegmentMode.Selected) + { + throw new InvalidOperationException( + "Cannot mix Segment(id, ...) and SelectedSegments(...) in the same update: WLED's 'seg' field " + + "is either the explicit-id array form or the selected-segment object form, not both."); + } + + _segmentMode = SegmentMode.Explicit; var segment = new SegmentUpdate(id); configure(segment); _segments.Add(segment.Build()); return this; } + /// + /// Configure the currently selected segments, patching only the properties you set. + /// + /// + /// This targets the selected segments using the object form ("seg":{...}) without naming an id. + /// Calling it more than once configures the same selected-segment update cumulatively. It cannot be + /// combined with in the same update. + /// + public StateUpdate SelectedSegments(Action configure) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + if (_segmentMode == SegmentMode.Explicit) + { + throw new InvalidOperationException( + "Cannot mix SelectedSegments(...) and Segment(id, ...) in the same update: WLED's 'seg' field " + + "is either the selected-segment object form or the explicit-id array form, not both."); + } + + _segmentMode = SegmentMode.Selected; + _selectedSegment ??= new SegmentUpdate(); + configure(_selectedSegment); + return this; + } + internal StateRequest Build() { - if (_segments.Count > 0) + switch (_segmentMode) { - _request.Segments = _segments.ToArray(); + case SegmentMode.Explicit: + _request.Segments = SegmentPayload.List(_segments.ToArray()); + break; + case SegmentMode.Selected: + _request.Segments = SegmentPayload.Selected(_selectedSegment!.Build()); + break; } return _request; diff --git a/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs index 2577d52..270f279 100644 --- a/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs +++ b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs @@ -82,4 +82,74 @@ public async Task UpdateStateClampsTransitionToUshortMax() var root = JsonDocument.Parse(body!).RootElement; root.GetProperty("transition").GetInt32().Should().Be(ushort.MaxValue); } + + [Fact] + public async Task SelectedSegmentsEmitsObjectFormWithoutId() + { + var (_, body) = await Capture(s => s + .SelectedSegments(seg => seg + .Color(RgbColor.FromHex("FFAA00")) + .Effect(Selector.Random))); + + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Object); + seg.TryGetProperty("id", out _).Should().BeFalse(); + seg.GetProperty("fx").GetString().Should().Be("r"); + } + + [Fact] + public async Task SelectedSegmentsAccumulatesAcrossCalls() + { + var (_, body) = await Capture(s => s + .SelectedSegments(seg => seg.Effect(3)) + .SelectedSegments(seg => seg.Brightness(128))); + + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Object); + seg.GetProperty("fx").GetInt32().Should().Be(3); + seg.GetProperty("bri").GetInt32().Should().Be(128); + } + + [Fact] + public async Task SegmentByIdEmitsArrayForm() + { + var (_, body) = await Capture(s => s.Segment(2, seg => seg.Effect(1))); + + var seg = JsonDocument.Parse(body!).RootElement.GetProperty("seg"); + seg.ValueKind.Should().Be(JsonValueKind.Array); + seg.EnumerateArray().Single().GetProperty("id").GetInt32().Should().Be(2); + } + + [Fact] + public void MixingSelectedThenExplicitThrows() + { + var update = new StateUpdate(); + update.SelectedSegments(seg => seg.Effect(1)); + + update.Invoking(u => u.Segment(0, seg => seg.Effect(2))) + .Should().Throw(); + } + + [Fact] + public void MixingExplicitThenSelectedThrows() + { + var update = new StateUpdate(); + update.Segment(0, seg => seg.Effect(1)); + + update.Invoking(u => u.SelectedSegments(seg => seg.Effect(2))) + .Should().Throw(); + } + + private static async Task<(string Uri, string? Body)> Capture(Action configure) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(configure); + + var (uri, body) = mockHttpMessageHandler.CapturedRequestList.Single(); + return (uri, body); + } } From 0c5ee6d18768ec83992a934600014fbca29a5532 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:40:39 +0100 Subject: [PATCH 26/32] Add cohesive WLedDevice snapshot via GetDevice() GetDevice() issues a single GET /json and exposes state, info and typed effect/palette catalogs as one snapshot, with per-segment effect/palette resolution and derived helpers (SelectedSegments, ActiveSegments, SupportsWhiteChannel, etc). Effect metadata is opt-in to avoid extra requests. --- src/Kevsoft.WLED/IWLedClient.cs | 7 + src/Kevsoft.WLED/WLedClient.cs | 18 ++ src/Kevsoft.WLED/WLedDevice.cs | 172 ++++++++++++++++++ .../Kevsoft.WLED.Tests/DeviceSnapshotTests.cs | 117 ++++++++++++ 4 files changed, 314 insertions(+) create mode 100644 src/Kevsoft.WLED/WLedDevice.cs create mode 100644 test/Kevsoft.WLED.Tests/DeviceSnapshotTests.cs diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index d6faf3c..9e87de2 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -4,6 +4,13 @@ public interface IWLedClient { Task Get(CancellationToken cancellationToken = default); + /// + /// Gets a cohesive snapshot from a single GET /json request, + /// combining state, info and the effect/palette catalogs. This is the recommended read path for + /// app/UI code. Pass to also include effect metadata. + /// + Task GetDevice(DeviceSnapshotOptions? options = null, CancellationToken cancellationToken = default); + Task GetState(CancellationToken cancellationToken = default); Task GetInformation(CancellationToken cancellationToken = default); diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 1194ff3..9b5d095 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -41,6 +41,24 @@ private static HttpClient CreateClient(HttpMessageHandler httpMessageHandler, st public Task Get(CancellationToken cancellationToken = default) => GetJson("json", cancellationToken); + public async Task GetDevice(DeviceSnapshotOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= new DeviceSnapshotOptions(); + + var root = await Get(cancellationToken); + var effects = EffectCatalog.FromNames(root.Effects); + var palettes = PaletteCatalog.FromNames(root.Palettes); + + IReadOnlyList? metadata = null; + if (options.IncludeEffectMetadata) + { + var fxdata = await GetJson("json/fxdata", cancellationToken); + metadata = EffectMetadataParser.Parse(fxdata, root.Effects); + } + + return new WLedDevice(root.State, root.Information, effects, palettes, metadata); + } + public Task GetState(CancellationToken cancellationToken = default) => GetJson("json/state", cancellationToken); diff --git a/src/Kevsoft.WLED/WLedDevice.cs b/src/Kevsoft.WLED/WLedDevice.cs new file mode 100644 index 0000000..9e40aa3 --- /dev/null +++ b/src/Kevsoft.WLED/WLedDevice.cs @@ -0,0 +1,172 @@ +namespace Kevsoft.WLED; + +/// +/// Options controlling what includes in the snapshot. +/// +public sealed class DeviceSnapshotOptions +{ + /// + /// When true, the snapshot also fetches and parses effect metadata (/json/fxdata), + /// which costs one extra HTTP request. Defaults to false. + /// + public bool IncludeEffectMetadata { get; set; } +} + +/// +/// A cohesive, read-only snapshot of a WLED device, combining its state, info and effect/palette +/// catalogs (and optionally effect metadata) from a single GET /json request. +/// +public sealed class WLedDevice +{ + internal WLedDevice( + StateResponse state, + InformationResponse information, + EffectCatalog effects, + PaletteCatalog palettes, + IReadOnlyList? effectMetadata) + { + State = state; + Information = information; + Effects = effects; + Palettes = palettes; + EffectMetadata = effectMetadata; + + var segments = new WLedDeviceSegment[state.Segments?.Length ?? 0]; + for (var i = 0; i < segments.Length; i++) + { + segments[i] = new WLedDeviceSegment(state.Segments![i], effects, palettes, effectMetadata); + } + + Segments = segments; + } + + /// The raw device state. + public StateResponse State { get; } + + /// The raw device information. + public InformationResponse Information { get; } + + /// The effects catalog. + public EffectCatalog Effects { get; } + + /// The palettes catalog. + public PaletteCatalog Palettes { get; } + + /// + /// Parsed effect metadata, or null when it was not requested via + /// . + /// + public IReadOnlyList? EffectMetadata { get; } + + /// The device's segments as a queryable read model. + public IReadOnlyList Segments { get; } + + /// The friendly device name. + public string Name => Information.Name; + + /// The firmware version name. + public string Version => Information.VersionName; + + /// Whether the light is currently on. + public bool IsOn => State.On; + + /// The master brightness (0–255). + public byte Brightness => State.Brightness; + + /// The id of the currently active preset, or null when none is active. + public int? CurrentPresetId => State.PresetId; + + /// The id of the currently active playlist, or null when none is active. + public int? CurrentPlaylistId => State.PlaylistId; + + /// true if the device has a dedicated white channel. + public bool SupportsWhiteChannel + => Information.Leds.LightCapabilities.HasFlag(LightCapability.WhiteChannel) || Information.Leds.Rgbw; + + /// true if the device supports colour temperature (CCT) control. + public bool SupportsColorTemperature + => Information.Leds.LightCapabilities.HasFlag(LightCapability.ColorTemperature) || Information.Leds.SupportsColorTemperature; + + /// The segments that are currently selected. + public IEnumerable SelectedSegments => Segments.Where(s => s.IsSelected); + + /// The segments that are valid (non-empty, i.e. stop > start). + public IEnumerable ActiveSegments => Segments.Where(s => s.IsActive); + + /// The main segment (per state.mainseg), or null if it cannot be resolved. + public WLedDeviceSegment? MainSegment => Segments.FirstOrDefault(s => s.Id == State.MainSegment); +} + +/// +/// A single segment within a snapshot, with its current effect and palette +/// resolved against the device catalogs. +/// +public sealed class WLedDeviceSegment +{ + private readonly SegmentResponse _segment; + + internal WLedDeviceSegment( + SegmentResponse segment, + EffectCatalog effects, + PaletteCatalog palettes, + IReadOnlyList? effectMetadata) + { + _segment = segment; + + Effect = effects.TryFindById(segment.EffectId, out var effect) + ? effect + : new EffectCatalogEntry(segment.EffectId, string.Empty); + + Palette = palettes.TryFindById(segment.ColorPaletteId, out var palette) + ? palette + : new PaletteCatalogEntry(segment.ColorPaletteId, string.Empty); + + if (effectMetadata is not null) + { + foreach (var metadata in effectMetadata) + { + if (metadata.EffectId == segment.EffectId) + { + EffectMetadata = metadata; + break; + } + } + } + } + + /// The segment id. + public int Id => _segment.Id; + + /// The segment name, or null if unnamed. + public string? Name => _segment.Name; + + /// true if the segment is selected. + public bool IsSelected => _segment.Selected; + + /// true if the individual segment is powered on. + public bool IsOn => _segment.SegmentState; + + /// true if the segment is valid (non-empty, i.e. stop > start). + public bool IsActive => _segment.Stop > _segment.Start; + + /// The segment brightness (0–255). + public byte Brightness => _segment.Brightness; + + /// The segment colour slots. + public SegmentColors Colors => _segment.Colors; + + /// The segment's current effect, resolved from the catalog. + public EffectCatalogEntry Effect { get; } + + /// The segment's current palette, resolved from the catalog. + public PaletteCatalogEntry Palette { get; } + + /// + /// The metadata for the segment's current effect, or null when metadata was not requested + /// (or the effect is reserved/unknown). + /// + public EffectMetadata? EffectMetadata { get; } + + /// The underlying raw segment response. + public SegmentResponse Raw => _segment; +} diff --git a/test/Kevsoft.WLED.Tests/DeviceSnapshotTests.cs b/test/Kevsoft.WLED.Tests/DeviceSnapshotTests.cs new file mode 100644 index 0000000..f63321c --- /dev/null +++ b/test/Kevsoft.WLED.Tests/DeviceSnapshotTests.cs @@ -0,0 +1,117 @@ +namespace Kevsoft.WLED.Tests; + +public class DeviceSnapshotTests +{ + private readonly IFixture _fixture = new Fixture().Customize(new WledFixtureCustomization()); + + [Fact] + public async Task GetDeviceIssuesSingleRootRequest() + { + var (client, handler, _) = CreateClient(); + + await client.GetDevice(); + + handler.CapturedRequestList.Should().ContainSingle() + .Which.Uri.Should().EndWith("/json"); + } + + [Fact] + public async Task GetDeviceResolvesCatalogsAndDerivedProperties() + { + var (client, _, root) = CreateClient(r => + { + r.Effects = new[] { "Solid", "Blink", "Rainbow" }; + r.Palettes = new[] { "Default", "Party" }; + r.Information.Name = "Kitchen"; + r.Information.VersionName = "0.15.0"; + r.State.Segments[0].EffectId = 2; + r.State.Segments[0].ColorPaletteId = 1; + }); + + var device = await client.GetDevice(); + + device.Name.Should().Be("Kitchen"); + device.Version.Should().Be("0.15.0"); + device.Effects.FindByName("Rainbow").Id.Should().Be(2); + device.Segments[0].Effect.Name.Should().Be("Rainbow"); + device.Segments[0].Palette.Name.Should().Be("Party"); + device.EffectMetadata.Should().BeNull(); + } + + [Fact] + public async Task GetDeviceWithoutMetadataDoesNotRequestFxData() + { + var (client, handler, _) = CreateClient(); + + await client.GetDevice(); + + handler.CapturedRequestList.Should().NotContain(r => r.Uri.EndsWith("/json/fxdata")); + } + + [Fact] + public async Task GetDeviceWithMetadataFetchesAndAlignsFxData() + { + var (client, _, root) = CreateClient( + configure: r => + { + r.Effects = new[] { "Solid", "Blink", "Rainbow" }; + r.State.Segments[0].EffectId = 2; + }, + fxData: "[\"\",\"\",\"Speed,Intensity;!,!,!;!;1\"]"); + + var device = await client.GetDevice(new DeviceSnapshotOptions { IncludeEffectMetadata = true }); + + device.EffectMetadata.Should().NotBeNull(); + device.Segments[0].EffectMetadata.Should().NotBeNull(); + device.Segments[0].EffectMetadata!.Name.Should().Be("Rainbow"); + } + + [Fact] + public async Task GetDeviceFlagsSelectedAndActiveSegments() + { + var (client, _, _) = CreateClient(r => + { + r.State.Segments[0].Selected = true; + r.State.Segments[0].Start = 0; + r.State.Segments[0].Stop = 10; + }); + + var device = await client.GetDevice(); + + device.SelectedSegments.Should().Contain(s => s.Id == device.Segments[0].Id); + device.ActiveSegments.Should().Contain(s => s.Id == device.Segments[0].Id); + } + + [Fact] + public async Task GetDeviceReportsWhiteChannelSupportFromCapabilities() + { + var (client, _, _) = CreateClient(r => + { + r.Information.Leds.LightCapabilities = LightCapability.Rgb | LightCapability.WhiteChannel; + }); + + var device = await client.GetDevice(); + + device.SupportsWhiteChannel.Should().BeTrue(); + } + + private (WLedClient Client, MockHttpMessageHandler Handler, WLedRootResponse Root) CreateClient( + Action? configure = null, + string? fxData = null) + { + var root = _fixture.Create(); + configure?.Invoke(root); + + var baseUri = $"http://{Guid.NewGuid():N}.com"; + var handler = new MockHttpMessageHandler(); + handler.AppendResponse($"{baseUri}/json", JsonBuilder.CreateRootResponse(root)); + if (fxData is not null) + { + handler.AppendResponse($"{baseUri}/json/fxdata", fxData); + handler.AppendResponse($"{baseUri}/json/eff", + "[" + string.Join(",", root.Effects.Select(e => $"\"{e}\"")) + "]"); + } + + return (new WLedClient(handler, baseUri), handler, root); + } +} From 8e7f8bbb21f375b2418c6e1b3f6c4fbd9fbf4b48 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:42:42 +0100 Subject: [PATCH 27/32] Make effect metadata actionable Add lookup helpers (FindById/FindByName/Try*) over effect metadata collections, plus SegmentUpdate.Effect(EffectMetadata) and ApplyEffectDefaults(EffectMetadata) to set speed/intensity/custom sliders from metadata defaults (c3 clamped to 0-31). --- src/Kevsoft.WLED/EffectMetadataExtensions.cs | 103 ++++++++++++++++++ src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 58 ++++++++++ .../EffectMetadataActionableTests.cs | 74 +++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 src/Kevsoft.WLED/EffectMetadataExtensions.cs create mode 100644 test/Kevsoft.WLED.Tests/EffectMetadataActionableTests.cs diff --git a/src/Kevsoft.WLED/EffectMetadataExtensions.cs b/src/Kevsoft.WLED/EffectMetadataExtensions.cs new file mode 100644 index 0000000..c62db34 --- /dev/null +++ b/src/Kevsoft.WLED/EffectMetadataExtensions.cs @@ -0,0 +1,103 @@ +namespace Kevsoft.WLED; + +/// +/// Lookup helpers over a collection of , such as the result of +/// . +/// +public static class EffectMetadataExtensions +{ + /// Finds the metadata for the effect with the given id, or throws if there is none. + public static EffectMetadata FindById(this IReadOnlyList metadata, int effectId) + => metadata.TryFindById(effectId, out var found) + ? found + : throw new KeyNotFoundException($"No effect metadata with id {effectId} exists."); + + /// Finds the metadata for the effect with the given id without throwing. + public static bool TryFindById(this IReadOnlyList metadata, int effectId, out EffectMetadata found) + { + if (metadata is null) + { + throw new ArgumentNullException(nameof(metadata)); + } + + foreach (var candidate in metadata) + { + if (candidate.EffectId == effectId) + { + found = candidate; + return true; + } + } + + found = null!; + return false; + } + + /// + /// Finds the single metadata entry with the given effect name (case-insensitive, ordinal). Throws + /// if no entry, or more than one entry, matches. + /// + public static EffectMetadata FindByName(this IReadOnlyList metadata, string name) + { + if (metadata is null) + { + throw new ArgumentNullException(nameof(metadata)); + } + + if (name is null) + { + throw new ArgumentNullException(nameof(name)); + } + + EffectMetadata? match = null; + var count = 0; + + foreach (var candidate in metadata) + { + if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)) + { + match = candidate; + count++; + } + } + + return count switch + { + 0 => throw new KeyNotFoundException($"No effect metadata named '{name}' exists."), + 1 => match!, + _ => throw new InvalidOperationException($"More than one effect metadata named '{name}' exists."), + }; + } + + /// + /// Finds the single metadata entry with the given effect name (case-insensitive, ordinal) without + /// throwing. Returns false if no entry, or more than one entry, matches. + /// + public static bool TryFindByName(this IReadOnlyList metadata, string name, out EffectMetadata found) + { + found = null!; + if (metadata is null || name is null) + { + return false; + } + + var matched = false; + + foreach (var candidate in metadata) + { + if (string.Equals(candidate.Name, name, StringComparison.OrdinalIgnoreCase)) + { + if (matched) + { + found = null!; + return false; + } + + found = candidate; + matched = true; + } + } + + return matched; + } +} diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs index b1a8404..3561b54 100644 --- a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -62,6 +62,64 @@ public SegmentUpdate Effect(EffectCatalogEntry effect) return Effect(Selector.Id(effect.Id)); } + /// Select the effect described by the given metadata. + public SegmentUpdate Effect(EffectMetadata effect) + { + if (effect is null) + { + throw new ArgumentNullException(nameof(effect)); + } + + return Effect(Selector.Id(effect.EffectId)); + } + + /// + /// Apply the effect's recommended default values (speed, intensity and custom sliders) from its + /// metadata. Only the controls the effect actually defines defaults for are set. + /// + public SegmentUpdate ApplyEffectDefaults(EffectMetadata effect) + { + if (effect is null) + { + throw new ArgumentNullException(nameof(effect)); + } + + foreach (var slider in effect.Defaults) + { + var value = slider.Value; + switch (slider.Key) + { + case "sx": + Speed(ToByte(value)); + break; + case "ix": + Intensity(ToByte(value)); + break; + case "c1": + CustomSlider1(ToByte(value)); + break; + case "c2": + CustomSlider2(ToByte(value)); + break; + case "c3": + _request.CustomSlider3 = value < 0 ? (byte)0 : value > 31 ? (byte)31 : (byte)value; + break; + } + } + + return this; + } + + private static byte ToByte(int value) + { + if (value < 0) + { + return 0; + } + + return value > 255 ? (byte)255 : (byte)value; + } + /// Select the color palette by id, relative movement or at random. public SegmentUpdate Palette(Selector palette) { diff --git a/test/Kevsoft.WLED.Tests/EffectMetadataActionableTests.cs b/test/Kevsoft.WLED.Tests/EffectMetadataActionableTests.cs new file mode 100644 index 0000000..92897bb --- /dev/null +++ b/test/Kevsoft.WLED.Tests/EffectMetadataActionableTests.cs @@ -0,0 +1,74 @@ +namespace Kevsoft.WLED.Tests; + +public class EffectMetadataActionableTests +{ + private static EffectMetadata Meta(int id, string name, Dictionary? defaults = null) + => new( + id, + name, + Array.Empty(), + Array.Empty(), + Array.Empty(), + UsesPalette: true, + EffectDimensionality.OneDimensional, + ReactsToVolume: false, + ReactsToFrequency: false, + defaults ?? new Dictionary()); + + [Fact] + public void FindByNameIsCaseInsensitive() + { + var list = new[] { Meta(0, "Solid"), Meta(3, "Rainbow") }; + + list.FindByName("rainbow").EffectId.Should().Be(3); + } + + [Fact] + public void FindByIdThrowsWhenMissing() + { + var list = new[] { Meta(0, "Solid") }; + + list.Invoking(l => l.FindById(9)).Should().Throw(); + } + + [Fact] + public void TryFindByNameReturnsFalseWhenMissing() + { + var list = new[] { Meta(0, "Solid") }; + + list.TryFindByName("Nope", out _).Should().BeFalse(); + } + + [Fact] + public async Task ApplyEffectDefaultsSetsSlidersFromMetadata() + { + var meta = Meta(3, "Rainbow", new Dictionary + { + ["sx"] = 200, + ["ix"] = 128, + ["c1"] = 10, + ["c3"] = 40, + }); + + var (_, body) = await Capture(s => s.Segment(0, seg => seg.Effect(meta).ApplyEffectDefaults(meta))); + + var segment = JsonDocument.Parse(body!).RootElement.GetProperty("seg").EnumerateArray().Single(); + segment.GetProperty("fx").GetInt32().Should().Be(3); + segment.GetProperty("sx").GetInt32().Should().Be(200); + segment.GetProperty("ix").GetInt32().Should().Be(128); + segment.GetProperty("c1").GetInt32().Should().Be(10); + segment.GetProperty("c3").GetInt32().Should().Be(31); // clamped to the 0-31 range + } + + private static async Task<(string Uri, string? Body)> Capture(Action configure) + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(configure); + + return mockHttpMessageHandler.CapturedRequestList.Single(); + } +} From cbd4df03897a0fd0883a139f79a4109352616da7 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:46:03 +0100 Subject: [PATCH 28/32] Add strong id and range value types Introduce SegmentId, EffectId, PaletteId, PresetId, PlaylistId, LedMapId, SegmentBounds and MatrixBounds readonly structs with range validation, equality and netstandard2.0-safe hashing. Wire SegmentBounds and MatrixBounds into SegmentUpdate.Range/Range2D and LedMapId into StateUpdate.LoadLedMap as non-ambiguous overloads. --- src/Kevsoft.WLED/EffectId.cs | 38 ++++++ src/Kevsoft.WLED/Fluent/SegmentUpdate.cs | 7 + src/Kevsoft.WLED/Fluent/StateUpdate.cs | 7 + src/Kevsoft.WLED/LedMapId.cs | 42 ++++++ src/Kevsoft.WLED/MatrixBounds.cs | 72 ++++++++++ src/Kevsoft.WLED/PaletteId.cs | 38 ++++++ src/Kevsoft.WLED/PlaylistId.cs | 42 ++++++ src/Kevsoft.WLED/PresetId.cs | 42 ++++++ src/Kevsoft.WLED/SegmentBounds.cs | 52 ++++++++ src/Kevsoft.WLED/SegmentId.cs | 38 ++++++ test/Kevsoft.WLED.Tests/SegmentObjectTests.cs | 22 +++ test/Kevsoft.WLED.Tests/StateObjectTests.cs | 16 +++ test/Kevsoft.WLED.Tests/ValueTypeTests.cs | 125 ++++++++++++++++++ 13 files changed, 541 insertions(+) create mode 100644 src/Kevsoft.WLED/EffectId.cs create mode 100644 src/Kevsoft.WLED/LedMapId.cs create mode 100644 src/Kevsoft.WLED/MatrixBounds.cs create mode 100644 src/Kevsoft.WLED/PaletteId.cs create mode 100644 src/Kevsoft.WLED/PlaylistId.cs create mode 100644 src/Kevsoft.WLED/PresetId.cs create mode 100644 src/Kevsoft.WLED/SegmentBounds.cs create mode 100644 src/Kevsoft.WLED/SegmentId.cs create mode 100644 test/Kevsoft.WLED.Tests/ValueTypeTests.cs diff --git a/src/Kevsoft.WLED/EffectId.cs b/src/Kevsoft.WLED/EffectId.cs new file mode 100644 index 0000000..6b62e7a --- /dev/null +++ b/src/Kevsoft.WLED/EffectId.cs @@ -0,0 +1,38 @@ +namespace Kevsoft.WLED; + +/// A WLED effect id (zero or greater). +public readonly struct EffectId : IEquatable +{ + /// Creates an effect id, validating that it is zero or greater. + public EffectId(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Effect id must be zero or greater."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates an effect id from an . + public static EffectId From(int value) => new(value); + + public static implicit operator EffectId(int value) => new(value); + + public static implicit operator int(EffectId id) => id.Value; + + public bool Equals(EffectId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is EffectId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(EffectId left, EffectId right) => left.Equals(right); + + public static bool operator !=(EffectId left, EffectId right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs index 3561b54..0ad942e 100644 --- a/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -273,6 +273,9 @@ public SegmentUpdate Range(int start, int stop) return this; } + /// Set the segment bounds on a 1D strip. + public SegmentUpdate Range(SegmentBounds bounds) => Range(bounds.Start, bounds.Stop); + /// Set the 2D matrix bounds of the segment. public SegmentUpdate Range2D(int startX, int stopX, int startY, int stopY) { @@ -283,6 +286,10 @@ public SegmentUpdate Range2D(int startX, int stopX, int startY, int stopY) return this; } + /// Set the 2D matrix bounds of the segment. + public SegmentUpdate Range2D(MatrixBounds bounds) => + Range2D(bounds.StartX, bounds.StopX, bounds.StartY, bounds.StopY); + /// Reverse the segment (flips animation direction). public SegmentUpdate Reverse(bool value = true) { diff --git a/src/Kevsoft.WLED/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs index c7f892c..4eda017 100644 --- a/src/Kevsoft.WLED/Fluent/StateUpdate.cs +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -102,6 +102,13 @@ public StateUpdate LoadLedMap(byte id) return this; } + /// Load the ledmap with the given id (0–9). + public StateUpdate LoadLedMap(LedMapId id) + { + _request.LedMap = (byte)id.Value; + return this; + } + /// Remove the last custom palette. public StateUpdate RemoveLastCustomPalette() { diff --git a/src/Kevsoft.WLED/LedMapId.cs b/src/Kevsoft.WLED/LedMapId.cs new file mode 100644 index 0000000..c6dcb86 --- /dev/null +++ b/src/Kevsoft.WLED/LedMapId.cs @@ -0,0 +1,42 @@ +namespace Kevsoft.WLED; + +/// A WLED ledmap id (0 to 9), selecting ledmap.json..ledmap9.json. +public readonly struct LedMapId : IEquatable +{ + /// The smallest valid ledmap id. + public const int MinValue = 0; + + /// The largest valid ledmap id. + public const int MaxValue = 9; + + /// Creates a ledmap id, validating that it is between 0 and 9. + public LedMapId(int value) + { + if (value < MinValue || value > MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), value, $"Ledmap id must be between {MinValue} and {MaxValue}."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates a ledmap id from an . + public static LedMapId From(int value) => new(value); + + public static implicit operator int(LedMapId id) => id.Value; + + public bool Equals(LedMapId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is LedMapId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(LedMapId left, LedMapId right) => left.Equals(right); + + public static bool operator !=(LedMapId left, LedMapId right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/MatrixBounds.cs b/src/Kevsoft.WLED/MatrixBounds.cs new file mode 100644 index 0000000..879e8df --- /dev/null +++ b/src/Kevsoft.WLED/MatrixBounds.cs @@ -0,0 +1,72 @@ +namespace Kevsoft.WLED; + +/// The bounds of a segment on a 2D matrix (X and Y, each start inclusive, stop exclusive). +public readonly struct MatrixBounds : IEquatable +{ + /// Creates matrix bounds, validating that every value is zero or greater. + public MatrixBounds(int startX, int stopX, int startY, int stopY) + { + if (startX < 0) + { + throw new ArgumentOutOfRangeException(nameof(startX), startX, "Matrix startX must be zero or greater."); + } + + if (stopX < 0) + { + throw new ArgumentOutOfRangeException(nameof(stopX), stopX, "Matrix stopX must be zero or greater."); + } + + if (startY < 0) + { + throw new ArgumentOutOfRangeException(nameof(startY), startY, "Matrix startY must be zero or greater."); + } + + if (stopY < 0) + { + throw new ArgumentOutOfRangeException(nameof(stopY), stopY, "Matrix stopY must be zero or greater."); + } + + StartX = startX; + StopX = stopX; + StartY = startY; + StopY = stopY; + } + + /// The first column in the segment (inclusive). + public int StartX { get; } + + /// The column after the last column in the segment (exclusive). + public int StopX { get; } + + /// The first row in the segment (inclusive). + public int StartY { get; } + + /// The row after the last row in the segment (exclusive). + public int StopY { get; } + + /// Creates matrix bounds from X and Y start/stop values. + public static MatrixBounds From(int startX, int stopX, int startY, int stopY) => new(startX, stopX, startY, stopY); + + public bool Equals(MatrixBounds other) => + StartX == other.StartX && StopX == other.StopX && StartY == other.StartY && StopY == other.StopY; + + public override bool Equals(object? obj) => obj is MatrixBounds other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + var hash = StartX; + hash = (hash * 397) ^ StopX; + hash = (hash * 397) ^ StartY; + hash = (hash * 397) ^ StopY; + return hash; + } + } + + public override string ToString() => $"X[{StartX}, {StopX}) Y[{StartY}, {StopY})"; + + public static bool operator ==(MatrixBounds left, MatrixBounds right) => left.Equals(right); + + public static bool operator !=(MatrixBounds left, MatrixBounds right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/PaletteId.cs b/src/Kevsoft.WLED/PaletteId.cs new file mode 100644 index 0000000..27b6517 --- /dev/null +++ b/src/Kevsoft.WLED/PaletteId.cs @@ -0,0 +1,38 @@ +namespace Kevsoft.WLED; + +/// A WLED palette id (zero or greater). +public readonly struct PaletteId : IEquatable +{ + /// Creates a palette id, validating that it is zero or greater. + public PaletteId(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Palette id must be zero or greater."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates a palette id from an . + public static PaletteId From(int value) => new(value); + + public static implicit operator PaletteId(int value) => new(value); + + public static implicit operator int(PaletteId id) => id.Value; + + public bool Equals(PaletteId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is PaletteId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(PaletteId left, PaletteId right) => left.Equals(right); + + public static bool operator !=(PaletteId left, PaletteId right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/PlaylistId.cs b/src/Kevsoft.WLED/PlaylistId.cs new file mode 100644 index 0000000..e448046 --- /dev/null +++ b/src/Kevsoft.WLED/PlaylistId.cs @@ -0,0 +1,42 @@ +namespace Kevsoft.WLED; + +/// A WLED playlist id (1 to 250). +public readonly struct PlaylistId : IEquatable +{ + /// The smallest valid playlist id. + public const int MinValue = 1; + + /// The largest valid playlist id. + public const int MaxValue = 250; + + /// Creates a playlist id, validating that it is between 1 and 250. + public PlaylistId(int value) + { + if (value < MinValue || value > MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), value, $"Playlist id must be between {MinValue} and {MaxValue}."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates a playlist id from an . + public static PlaylistId From(int value) => new(value); + + public static implicit operator int(PlaylistId id) => id.Value; + + public bool Equals(PlaylistId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is PlaylistId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(PlaylistId left, PlaylistId right) => left.Equals(right); + + public static bool operator !=(PlaylistId left, PlaylistId right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/PresetId.cs b/src/Kevsoft.WLED/PresetId.cs new file mode 100644 index 0000000..9e8d19d --- /dev/null +++ b/src/Kevsoft.WLED/PresetId.cs @@ -0,0 +1,42 @@ +namespace Kevsoft.WLED; + +/// A savable WLED preset id (1 to 250). +public readonly struct PresetId : IEquatable +{ + /// The smallest valid preset id. + public const int MinValue = 1; + + /// The largest valid preset id. + public const int MaxValue = 250; + + /// Creates a preset id, validating that it is between 1 and 250. + public PresetId(int value) + { + if (value < MinValue || value > MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(value), value, $"Preset id must be between {MinValue} and {MaxValue}."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates a preset id from an . + public static PresetId From(int value) => new(value); + + public static implicit operator int(PresetId id) => id.Value; + + public bool Equals(PresetId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is PresetId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(PresetId left, PresetId right) => left.Equals(right); + + public static bool operator !=(PresetId left, PresetId right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/SegmentBounds.cs b/src/Kevsoft.WLED/SegmentBounds.cs new file mode 100644 index 0000000..ac3fe65 --- /dev/null +++ b/src/Kevsoft.WLED/SegmentBounds.cs @@ -0,0 +1,52 @@ +namespace Kevsoft.WLED; + +/// The bounds of a segment on a 1D strip (start inclusive, stop exclusive). +public readonly struct SegmentBounds : IEquatable +{ + /// Creates segment bounds, validating that both values are zero or greater. + public SegmentBounds(int start, int stop) + { + if (start < 0) + { + throw new ArgumentOutOfRangeException(nameof(start), start, "Segment start must be zero or greater."); + } + + if (stop < 0) + { + throw new ArgumentOutOfRangeException(nameof(stop), stop, "Segment stop must be zero or greater."); + } + + Start = start; + Stop = stop; + } + + /// The first LED in the segment (inclusive). + public int Start { get; } + + /// The LED after the last LED in the segment (exclusive). + public int Stop { get; } + + /// The number of LEDs covered by these bounds. + public int Length => Stop > Start ? Stop - Start : 0; + + /// Creates segment bounds from a start and stop. + public static SegmentBounds From(int start, int stop) => new(start, stop); + + public bool Equals(SegmentBounds other) => Start == other.Start && Stop == other.Stop; + + public override bool Equals(object? obj) => obj is SegmentBounds other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return (Start * 397) ^ Stop; + } + } + + public override string ToString() => $"[{Start}, {Stop})"; + + public static bool operator ==(SegmentBounds left, SegmentBounds right) => left.Equals(right); + + public static bool operator !=(SegmentBounds left, SegmentBounds right) => !left.Equals(right); +} diff --git a/src/Kevsoft.WLED/SegmentId.cs b/src/Kevsoft.WLED/SegmentId.cs new file mode 100644 index 0000000..a239a35 --- /dev/null +++ b/src/Kevsoft.WLED/SegmentId.cs @@ -0,0 +1,38 @@ +namespace Kevsoft.WLED; + +/// A WLED segment id (zero or greater). +public readonly struct SegmentId : IEquatable +{ + /// Creates a segment id, validating that it is zero or greater. + public SegmentId(int value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException(nameof(value), value, "Segment id must be zero or greater."); + } + + Value = value; + } + + /// The underlying id. + public int Value { get; } + + /// Creates a segment id from an . + public static SegmentId From(int value) => new(value); + + public static implicit operator SegmentId(int value) => new(value); + + public static implicit operator int(SegmentId id) => id.Value; + + public bool Equals(SegmentId other) => Value == other.Value; + + public override bool Equals(object? obj) => obj is SegmentId other && Equals(other); + + public override int GetHashCode() => Value; + + public override string ToString() => Value.ToString(System.Globalization.CultureInfo.InvariantCulture); + + public static bool operator ==(SegmentId left, SegmentId right) => left.Equals(right); + + public static bool operator !=(SegmentId left, SegmentId right) => !left.Equals(right); +} diff --git a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs index e9a01ea..c2c9986 100644 --- a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs +++ b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs @@ -157,6 +157,28 @@ public void SetRejectsOutOfRange() act.Should().Throw(); } + [Fact] + public async Task SegmentBuilderAcceptsSegmentBounds() + { + var (_, segment) = await CaptureSegment(seg => seg + .Range(SegmentBounds.From(5, 25))); + + segment.GetProperty("start").GetInt32().Should().Be(5); + segment.GetProperty("stop").GetInt32().Should().Be(25); + } + + [Fact] + public async Task SegmentBuilderAcceptsMatrixBounds() + { + var (_, segment) = await CaptureSegment(seg => seg + .Range2D(MatrixBounds.From(0, 16, 0, 8))); + + segment.GetProperty("start").GetInt32().Should().Be(0); + segment.GetProperty("stop").GetInt32().Should().Be(16); + segment.GetProperty("startY").GetInt32().Should().Be(0); + segment.GetProperty("stopY").GetInt32().Should().Be(8); + } + private static async Task<(string Uri, JsonElement Segment)> CaptureSegment(Action configure) { var mockHttpMessageHandler = new MockHttpMessageHandler(); diff --git a/test/Kevsoft.WLED.Tests/StateObjectTests.cs b/test/Kevsoft.WLED.Tests/StateObjectTests.cs index 6ccd21a..9661142 100644 --- a/test/Kevsoft.WLED.Tests/StateObjectTests.cs +++ b/test/Kevsoft.WLED.Tests/StateObjectTests.cs @@ -75,4 +75,20 @@ public void LoadLedMapRejectsOutOfRange() act.Should().Throw(); } + + [Fact] + public async Task LoadLedMapAcceptsLedMapId() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/state"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + await client.UpdateState(s => s.LoadLedMap(LedMapId.From(7))); + + var (_, body) = mockHttpMessageHandler.CapturedRequests.Single(); + var root = JsonDocument.Parse(body!).RootElement; + + root.GetProperty("ledmap").GetByte().Should().Be(7); + } } diff --git a/test/Kevsoft.WLED.Tests/ValueTypeTests.cs b/test/Kevsoft.WLED.Tests/ValueTypeTests.cs new file mode 100644 index 0000000..d9996ca --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ValueTypeTests.cs @@ -0,0 +1,125 @@ +using System; + +namespace Kevsoft.WLED.Tests; + +public class ValueTypeTests +{ + [Theory] + [InlineData(0)] + [InlineData(5)] + [InlineData(1024)] + public void SegmentIdAcceptsZeroOrGreater(int value) + { + SegmentId id = value; + + ((int)id).Should().Be(value); + id.Value.Should().Be(value); + } + + [Fact] + public void SegmentIdRejectsNegative() + { + Action act = () => SegmentId.From(-1); + + act.Should().Throw(); + } + + [Fact] + public void EffectIdRejectsNegative() + { + Action act = () => _ = new EffectId(-1); + + act.Should().Throw(); + } + + [Fact] + public void PaletteIdRejectsNegative() + { + Action act = () => _ = new PaletteId(-1); + + act.Should().Throw(); + } + + [Theory] + [InlineData(1)] + [InlineData(250)] + public void PresetIdAcceptsValidRange(int value) + { + ((int)PresetId.From(value)).Should().Be(value); + } + + [Theory] + [InlineData(0)] + [InlineData(251)] + [InlineData(-1)] + public void PresetIdRejectsOutOfRange(int value) + { + Action act = () => PresetId.From(value); + + act.Should().Throw(); + } + + [Theory] + [InlineData(0)] + [InlineData(251)] + public void PlaylistIdRejectsOutOfRange(int value) + { + Action act = () => PlaylistId.From(value); + + act.Should().Throw(); + } + + [Theory] + [InlineData(0)] + [InlineData(9)] + public void LedMapIdAcceptsValidRange(int value) + { + ((int)LedMapId.From(value)).Should().Be(value); + } + + [Theory] + [InlineData(-1)] + [InlineData(10)] + public void LedMapIdRejectsOutOfRange(int value) + { + Action act = () => LedMapId.From(value); + + act.Should().Throw(); + } + + [Fact] + public void SegmentBoundsExposesLength() + { + var bounds = SegmentBounds.From(10, 30); + + bounds.Start.Should().Be(10); + bounds.Stop.Should().Be(30); + bounds.Length.Should().Be(20); + } + + [Fact] + public void SegmentBoundsRejectsNegative() + { + Action act = () => SegmentBounds.From(-1, 10); + + act.Should().Throw(); + } + + [Fact] + public void MatrixBoundsRejectsNegative() + { + Action act = () => MatrixBounds.From(0, 8, -1, 8); + + act.Should().Throw(); + } + + [Fact] + public void IdsAreValueEqual() + { + SegmentId.From(3).Should().Be(SegmentId.From(3)); + (PresetId.From(1) == PresetId.From(1)).Should().BeTrue(); + (LedMapId.From(1) != LedMapId.From(2)).Should().BeTrue(); + SegmentBounds.From(0, 5).Should().Be(SegmentBounds.From(0, 5)); + MatrixBounds.From(0, 8, 0, 8).Should().Be(MatrixBounds.From(0, 8, 0, 8)); + } +} From 6ce18d38c940bebfeb517ff54ad5689af0cdc7e6 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:47:30 +0100 Subject: [PATCH 29/32] Add fluent config update builder Introduce ConfigUpdate and an UpdateConfig(Action) client overload exposing Identity, Mqtt and BootDefaults helpers. Only touched sections and fields are serialised, reusing the existing network opt-in guard. --- src/Kevsoft.WLED/Config/ConfigUpdate.cs | 93 +++++++++++++++++++ src/Kevsoft.WLED/IWLedClient.cs | 7 ++ src/Kevsoft.WLED/WLedClient.cs | 13 +++ .../Kevsoft.WLED.Tests/ConfigAndNodesTests.cs | 47 ++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/Kevsoft.WLED/Config/ConfigUpdate.cs diff --git a/src/Kevsoft.WLED/Config/ConfigUpdate.cs b/src/Kevsoft.WLED/Config/ConfigUpdate.cs new file mode 100644 index 0000000..0a52169 --- /dev/null +++ b/src/Kevsoft.WLED/Config/ConfigUpdate.cs @@ -0,0 +1,93 @@ +namespace Kevsoft.WLED; + +/// +/// A fluent builder for partial device configuration updates. Only the sections you touch are +/// emitted, so an update never blanks configuration you did not set. +/// +public sealed class ConfigUpdate +{ + private readonly DeviceConfig _config = new(); + + /// Set device identity values (cfg.id). + public ConfigUpdate Identity(string? name = null, string? mdnsName = null) + { + var identity = _config.Identity ??= new IdentityConfig(); + + if (name is not null) + { + identity.Name = name; + } + + if (mdnsName is not null) + { + identity.MdnsName = mdnsName; + } + + return this; + } + + /// Set MQTT integration values (cfg.if.mqtt). + public ConfigUpdate Mqtt( + bool? enabled = null, + string? broker = null, + int? port = null, + string? user = null, + string? clientId = null) + { + var interfaces = _config.Interfaces ??= new InterfacesConfig(); + var mqtt = interfaces.Mqtt ??= new MqttConfig(); + + if (enabled is not null) + { + mqtt.Enabled = enabled; + } + + if (broker is not null) + { + mqtt.Broker = broker; + } + + if (port is not null) + { + mqtt.Port = port; + } + + if (user is not null) + { + mqtt.User = user; + } + + if (clientId is not null) + { + mqtt.ClientId = clientId; + } + + return this; + } + + /// Set boot-time defaults (cfg.def). + public ConfigUpdate BootDefaults(bool? on = null, byte? brightness = null, int? presetId = null) + { + var defaults = _config.Defaults ??= new DefaultsConfig(); + + if (on is not null) + { + defaults.On = on; + } + + if (brightness is not null) + { + defaults.Brightness = brightness; + } + + if (presetId is not null) + { + defaults.PresetId = presetId; + } + + return this; + } + + /// Builds the represented by this update. + public DeviceConfig Build() => _config; +} diff --git a/src/Kevsoft.WLED/IWLedClient.cs b/src/Kevsoft.WLED/IWLedClient.cs index 9e87de2..bfe869b 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -167,4 +167,11 @@ public interface IWLedClient /// opting in via . /// Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// Applies a partial device configuration update to /json/cfg using a fluent builder. Only the + /// sections you touch on are sent. Updating the network or access-point + /// sections requires opting in via . + /// + Task UpdateConfig(Action configure, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default); } diff --git a/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 9b5d095..9dc51f7 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -301,6 +301,19 @@ public Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = nu return PostJson("/json/cfg", partial, cancellationToken); } + public Task UpdateConfig(Action configure, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default) + { + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var builder = new ConfigUpdate(); + configure(builder); + + return UpdateConfig(builder.Build(), options, cancellationToken); + } + public async Task SetIndividualLeds(int segmentId, Action build, int maxColorsPerRequest = 256, CancellationToken cancellationToken = default) { if (build is null) diff --git a/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs index a07c769..65c3a7e 100644 --- a/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs +++ b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs @@ -145,6 +145,53 @@ public async Task UpdateConfigAllowsNetworkChangeWhenOptedIn() JsonDocument.Parse(body!).RootElement.TryGetProperty("nw", out _).Should().BeTrue(); } + [Fact] + public async Task UpdateConfigBuilderEmitsOnlyTouchedSections() + { + var (_, body) = await Capture(client => client.UpdateConfig(cfg => cfg + .Identity(name: "Kitchen", mdnsName: "wled-kitchen") + .Mqtt(enabled: true, broker: "mqtt.local", port: 1883) + .BootDefaults(on: true, brightness: 128, presetId: PresetId.From(5)))); + + var element = JsonDocument.Parse(body!).RootElement; + element.GetProperty("id").GetProperty("name").GetString().Should().Be("Kitchen"); + element.GetProperty("id").GetProperty("mdns").GetString().Should().Be("wled-kitchen"); + element.GetProperty("if").GetProperty("mqtt").GetProperty("en").GetBoolean().Should().BeTrue(); + element.GetProperty("if").GetProperty("mqtt").GetProperty("broker").GetString().Should().Be("mqtt.local"); + element.GetProperty("if").GetProperty("mqtt").GetProperty("port").GetInt32().Should().Be(1883); + element.GetProperty("def").GetProperty("on").GetBoolean().Should().BeTrue(); + element.GetProperty("def").GetProperty("bri").GetInt32().Should().Be(128); + element.GetProperty("def").GetProperty("ps").GetInt32().Should().Be(5); + element.TryGetProperty("nw", out _).Should().BeFalse(); + element.TryGetProperty("hw", out _).Should().BeFalse(); + } + + [Fact] + public async Task UpdateConfigBuilderOmitsUntouchedMqttFields() + { + var (_, body) = await Capture(client => client.UpdateConfig(cfg => cfg + .Mqtt(broker: "mqtt.local"))); + + var mqtt = JsonDocument.Parse(body!).RootElement.GetProperty("if").GetProperty("mqtt"); + mqtt.GetProperty("broker").GetString().Should().Be("mqtt.local"); + mqtt.TryGetProperty("en", out _).Should().BeFalse(); + mqtt.TryGetProperty("port", out _).Should().BeFalse(); + mqtt.TryGetProperty("user", out _).Should().BeFalse(); + } + + [Fact] + public async Task UpdateConfigBuilderRespectsNetworkOptIn() + { + var mockHttpMessageHandler = new MockHttpMessageHandler(); + var baseUri = $"http://{Guid.NewGuid():N}.com"; + mockHttpMessageHandler.AppendResponse($"{baseUri}/json/cfg"); + var client = new WLedClient(mockHttpMessageHandler, baseUri); + + Func act = () => client.UpdateConfig(cfg => cfg.Identity(name: "x")); + + await act.Should().NotThrowAsync(); + } + private static async Task<(string Uri, string? Body)> Capture(Func act) { var mockHttpMessageHandler = new MockHttpMessageHandler(); From 60823be3201e405ef6b89060d6d6e1113733538d Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:49:38 +0100 Subject: [PATCH 30/32] Document Plan 13 ergonomics features Add README sections and feature-matrix rows for the device snapshot, catalogs, selected-segment updates, strong value types and the fluent config builder; expand the CHANGELOG and BasicConsole sample to match. --- CHANGELOG.md | 16 +++++++ README.md | 80 +++++++++++++++++++++++++++++++++ samples/BasicConsole/Program.cs | 25 ++++++++++- 3 files changed, 120 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdceeda..b004ba9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,22 @@ rather than deprecated, so consuming code must be updated. (the WLED `"from~tor"` token). - **Typed device-configuration fields** for `id.mdns`, `if.mqtt` (`en`/`broker`/`port`/`user`/`cid`) and `def` (`on`/`bri`/`ps`), while preserving all other keys through `JsonExtensionData`. +- **Device snapshot read model** (`GetDevice`) returning a queryable `WLedDevice`/`WLedDeviceSegment` + graph from a single `GET /json`, resolving each segment's effect and palette and optionally + fetching effect metadata via `DeviceSnapshotOptions.IncludeEffectMetadata`. +- **Effect & palette catalogs** (`GetEffectCatalog`, `GetPaletteCatalog`) with `FindById`/`FindByName` + (and `Try*`) lookups, an `AvailableOnly` view that hides reserved `RSVD`/`-` slots, and + `SetEffect`/`SetPalette` overloads that accept catalog entries. +- **Selected-segment fluent updates** via `StateUpdate.SelectedSegments(...)` (the WLED `"seg":{…}` + object form); mixing selected and id-targeted segments in one update throws. +- **Actionable effect metadata**: collection lookups over `IReadOnlyList`, plus + `SegmentUpdate.Effect(EffectMetadata)` and `ApplyEffectDefaults(EffectMetadata)` to seed + speed/intensity/custom sliders from metadata defaults. +- **Strong id and range value types**: `SegmentId`, `EffectId`, `PaletteId`, `PresetId`, + `PlaylistId`, `LedMapId`, `SegmentBounds` and `MatrixBounds`, with range validation and + overloads on `SegmentUpdate.Range`/`Range2D` and `StateUpdate.LoadLedMap`. +- **Fluent configuration updates** via `UpdateConfig(Action)` with `Identity`, + `Mqtt` and `BootDefaults` helpers that emit only the sections and fields you touch. ### Removed diff --git a/README.md b/README.md index 1a8129f..eec2ee2 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,41 @@ var info = await client.GetInformation(); // /json/info Console.WriteLine($"{info.Name} is running WLED {info.VersionName}."); ``` +### Device snapshot + +`GetDevice()` reads `/json` once and returns a queryable `WLedDevice` read model that +resolves each segment's effect and palette against the device catalogs: + +```csharp +var device = await client.GetDevice(); + +Console.WriteLine($"{device.Name} — {(device.IsOn ? "on" : "off")} @ {device.Brightness}"); + +foreach (var segment in device.SelectedSegments) +{ + Console.WriteLine($"Segment {segment.Id}: {segment.Effect.Name} / {segment.Palette.Name}"); +} + +// Opt in to effect metadata (an extra GET /json/fxdata) when you need it: +var detailed = await client.GetDevice(new DeviceSnapshotOptions { IncludeEffectMetadata = true }); +``` + +### Effect & palette catalogs + +Look effects and palettes up by id or name, and apply them type-safely: + +```csharp +var effects = await client.GetEffectCatalog(); + +var rainbow = effects.FindByName("Rainbow"); // throws if missing/ambiguous +await client.SetEffect(rainbow); + +foreach (var entry in effects.AvailableOnly) // skips reserved RSVD/"-" slots +{ + Console.WriteLine($"{entry.Id}: {entry.Name}"); +} +``` + ### Fluent state updates Build a sparse update that only sends the fields you set: @@ -89,6 +124,31 @@ await client.UpdateState(update => update .Speed(200))); ``` +Target the currently *selected* segments (the WLED `"seg":{…}` object form) with +`SelectedSegments(...)` instead of an explicit id: + +```csharp +await client.UpdateState(update => update + .SelectedSegments(segment => segment + .Effect(9) + .Palette(11))); +``` + +### Strong ids & ranges + +Range-checked value types catch invalid ids before a request is sent, and flow into the +existing `int`-based APIs: + +```csharp +var preset = PresetId.From(5); // throws unless 1–250 +var ledmap = LedMapId.From(3); // throws unless 0–9 + +await client.UpdateState(update => update + .LoadLedMap(ledmap) + .SelectedSegments(segment => segment + .Range(SegmentBounds.From(0, 30)))); +``` + ### Individual LED control ```csharp @@ -112,6 +172,23 @@ await client.StartPlaylist(playlist => playlist .Repeat(3)); ``` +### Device configuration + +Read configuration and apply safe partial writes with a fluent builder — only the +sections and fields you touch are sent: + +```csharp +var config = await client.GetConfig(); + +await client.UpdateConfig(cfg => cfg + .Identity(name: "Kitchen", mdnsName: "wled-kitchen") + .Mqtt(enabled: true, broker: "mqtt.local", port: 1883) + .BootDefaults(on: true, brightness: 128, presetId: PresetId.From(5))); +``` + +Network and access-point changes can disconnect the device, so they require explicit +opt-in via `UpdateConfigOptions.AllowNetworkChanges`. + ### Error handling All calls throw a typed exception hierarchy: @@ -132,6 +209,8 @@ Every method also accepts an optional `CancellationToken`. | Area | Endpoint(s) | Supported | | --- | --- | --- | | Full state/info/effects/palettes | `GET /json` | ✅ | +| Device snapshot read model (`GetDevice`) | `GET /json` (+ `fxdata` opt-in) | ✅ | +| Effect & palette catalogs (lookup by id/name) | `GET /json/eff`, `GET /json/pal` | ✅ | | Live state + info | `GET /json/si` | ✅ | | State | `GET`/`POST /json/state` | ✅ | | Device information | `GET /json/info` | ✅ | @@ -146,6 +225,7 @@ Every method also accepts an optional `CancellationToken`. | Effect metadata | `GET /json/fxdata` | ✅ | | Node discovery | `GET /json/nodes` | ✅ | | Device configuration (read / safe partial write) | `GET`/`POST /json/cfg` | ✅ | +| Strong id/range value types (`PresetId`, `LedMapId`, `SegmentBounds`…) | — | ✅ | | Typed exceptions & cancellation | — | ✅ | | DI / `IHttpClientFactory` integration | — | ✅ | diff --git a/samples/BasicConsole/Program.cs b/samples/BasicConsole/Program.cs index 0ae42d1..6bffcea 100644 --- a/samples/BasicConsole/Program.cs +++ b/samples/BasicConsole/Program.cs @@ -19,13 +19,30 @@ var state = await client.GetState(); Console.WriteLine($"Power: {(state.On ? "on" : "off")}, brightness: {state.Brightness}"); +// --- Device snapshot ------------------------------------------------------- + +var device = await client.GetDevice(); +Console.WriteLine($"{device.Name} has {device.Segments.Count} segments."); +foreach (var segment in device.SelectedSegments) +{ + Console.WriteLine($" Segment {segment.Id}: {segment.Effect.Name} / {segment.Palette.Name}"); +} + +// --- Catalogs -------------------------------------------------------------- + +var effects = await client.GetEffectCatalog(); +if (effects.TryFindByName("Rainbow", out var rainbow)) +{ + await client.SetEffect(rainbow); +} + // --- Sparse, fluent state updates ----------------------------------------- await client.UpdateState(update => update .TurnOn() .Brightness(128) .Transition(TimeSpan.FromSeconds(2)) - .Segment(0, segment => segment + .SelectedSegments(segment => segment .Effect(0) .Color(RgbColor.FromHex("0066FF")))); @@ -56,4 +73,10 @@ await client.StartPlaylist(playlist => playlist Console.WriteLine($"Found node {node.Name} at {node.IpAddress}"); } +// --- Safe partial configuration updates ------------------------------------ + +await client.UpdateConfig(cfg => cfg + .Identity(name: "Office") + .BootDefaults(on: true, brightness: 128, presetId: PresetId.From(1))); + await client.TurnOff(); From 3ddbdb9dd859dfeac4aaf536edce2da3d8594813 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 17:54:40 +0100 Subject: [PATCH 31/32] Remove outdated gotchas from AGENTS.md for clarity and conciseness --- AGENTS.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b879f6f..bfd5a14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,23 +77,6 @@ A feature isn't done when it compiles and tests pass. Also: intent methods, catalogs, snapshots), not raw DTOs. 3. Update `CHANGELOG.md`. -## Current gotchas - -- **`seg` object vs array form.** `"seg": { … }` targets the *selected* segments (no id); - `"seg": [{ "id": N, … }]` targets explicit ids. WLED infers `id:0` for an array entry - that omits its id, so the object form is the only way to address "the selected - segments". Use `SegmentPayload.Selected`/`List`, `StateUpdate.SelectedSegments(...)` for - the object form and `StateUpdate.Segment(id, ...)` for the array form. Mixing the two in - one update throws. -- **`transition`/`tt` are `ushort`** (0–65535, one unit = 100ms). -- **Reserved effects/palettes.** Entries named `RSVD` or `-` are placeholders that fall - back to Solid; filter them out of UI. `EffectCatalog`/`PaletteCatalog` expose - `IsReserved`/`AvailableOnly()` for this. -- **Effect metadata** (`/json/fxdata`) describes which controls each effect uses and their - defaults; `EffectMetadata` is aligned by id with the (reserved-filtered) effects list. -- **`GetDevice()`** is the recommended read path: it issues a single `GET /json` and - exposes state, info and effect/palette catalogs as one coherent snapshot. - ## Reference material - WLED JSON API docs: From b7466d7b818c9fc7005dcee7fffa4375e8719fb6 Mon Sep 17 00:00:00 2001 From: Kevin Smith Date: Sat, 30 May 2026 18:01:08 +0100 Subject: [PATCH 32/32] Fix Docker build for DependencyInjection project Copy the Kevsoft.WLED.DependencyInjection csproj and sources so dotnet restore (which reads the solution) and the build/pack stages succeed. Build the DI project (transitively building the main library) instead of globbing multiple csproj into a single dotnet build, and pack each packable library explicitly. Also align FROM..AS keyword casing. --- Dockerfile | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 835cdd8..d45d497 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,30 +6,33 @@ COPY ./nuget.config . COPY ./*.sln . COPY ./Directory.Build.props . COPY ./src/Kevsoft.WLED/*.csproj ./src/Kevsoft.WLED/ +COPY ./src/Kevsoft.WLED.DependencyInjection/*.csproj ./src/Kevsoft.WLED.DependencyInjection/ COPY ./test/Kevsoft.WLED.Tests/*.csproj ./test/Kevsoft.WLED.Tests/ COPY ./samples/BasicConsole/*.csproj ./samples/BasicConsole/ RUN dotnet restore -FROM restore as build +FROM restore AS build ARG VERSION COPY ./icon.png . COPY ./src/Kevsoft.WLED/ ./src/Kevsoft.WLED/ -RUN dotnet build ./src/**/*.csproj --configuration Release -p:Version=${VERSION} --no-restore +COPY ./src/Kevsoft.WLED.DependencyInjection/ ./src/Kevsoft.WLED.DependencyInjection/ +RUN dotnet build ./src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj --configuration Release -p:Version=${VERSION} --no-restore -FROM build as build-tests +FROM build AS build-tests ARG VERSION COPY ./test/Kevsoft.WLED.Tests/ ./test/Kevsoft.WLED.Tests/ RUN dotnet build ./test/**/*.csproj --configuration Release -p:Version=${VERSION} --no-restore -FROM build-tests as test +FROM build-tests AS test ENTRYPOINT ["dotnet", "test", "./test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj", "--configuration", "Release", "--no-restore", "--no-build"] CMD ["--logger" , "trx", "--results-directory", "./TestResults"] -FROM build as pack +FROM build AS pack ARG VERSION -RUN dotnet pack --configuration Release -p:Version=${VERSION} --no-build +RUN dotnet pack ./src/Kevsoft.WLED/Kevsoft.WLED.csproj --configuration Release -p:Version=${VERSION} --no-build +RUN dotnet pack ./src/Kevsoft.WLED.DependencyInjection/Kevsoft.WLED.DependencyInjection.csproj --configuration Release -p:Version=${VERSION} --no-build -FROM pack as push +FROM pack AS push RUN env ENTRYPOINT ["dotnet", "nuget", "push", "./src/Kevsoft.WLED/bin/Release/*.nupkg", "--source", "NuGet.org"] \ No newline at end of file