diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bfd5a14 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# 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`. + +## 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b004ba9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,77 @@ +# 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. +- **`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`. +- **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 + +- Legacy members were removed outright (no `[Obsolete]` shims). Update to the new API surface. 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..d45d497 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,35 +1,38 @@ 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 . 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 diff --git a/README.md b/README.md index f106d21..eec2ee2 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,241 @@ # 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: + +```csharp +services.AddWledClient("http://office-computer-wled/"); +// or +services.AddWledClient(client => client.BaseAddress = new Uri("http://office-computer-wled/")); +``` + +### Quick commands -var data = await client.Get(); +Common operations have first-class "intent" methods: + +```csharp +await client.TurnOn(); +await client.TurnOff(); +await client.Toggle(); + +await client.SetBrightness(200); +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(); ``` -### Post data to the WLED device +With no `segmentId`, `SetColor`/`SetEffect`/`SetPalette` target the currently *selected* +segments (the WLED `"seg":{…}` object form); pass a `segmentId` to target one segment. -Turn on the device on +### Reading data ```csharp -var client = new WLedClient("http://office-computer-wled/"); -await client.Post(new StateRequest { On = true }); +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}."); +``` + +### 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: + +```csharp +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")) + .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 +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)); +``` + +### 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: + +```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` | ✅ | +| 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` | ✅ | +| 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` | ✅ | +| Strong id/range value types (`PresetId`, `LedMapId`, `SegmentBounds`…) | — | ✅ | +| 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 +243,3 @@ The [samples](samples/) folder containers examples of how you could use the WLED 1. Fork 1. Hack! 1. Pull Request - 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/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/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/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/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..a847a8d --- /dev/null +++ b/plans/README.md @@ -0,0 +1,105 @@ +# 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 | +| 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 +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. 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. 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/samples/BasicConsole/Program.cs b/samples/BasicConsole/Program.cs index 4257d69..6bffcea 100644 --- a/samples/BasicConsole/Program.cs +++ b/samples/BasicConsole/Program.cs @@ -1,8 +1,82 @@ -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, selected segments +await client.SetEffect(9); // "Rainbow" +await client.SetPalette(Selector.RandomInRange(5, 10)); // random palette in a range + +// --- 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}"); + +// --- 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)) + .SelectedSegments(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}"); +} + +// --- Safe partial configuration updates ------------------------------------ + +await client.UpdateConfig(cfg => cfg + .Identity(name: "Office") + .BootDefaults(on: true, brightness: 128, presetId: PresetId.From(1))); + +await client.TurnOff(); 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/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/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/ColorTemperature.cs b/src/Kevsoft.WLED/ColorTemperature.cs new file mode 100644 index 0000000..6153dfd --- /dev/null +++ b/src/Kevsoft.WLED/ColorTemperature.cs @@ -0,0 +1,73 @@ +namespace Kevsoft.WLED; + +/// +/// A segment colour temperature. WLED accepts either a relative value (0–255) or an +/// 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 = 1000; + internal const int MaxKelvin = 20000; + + 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 (). + 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); + } + + /// + /// 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; + + 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/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..bd813b6 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/Selector.cs @@ -0,0 +1,93 @@ +namespace Kevsoft.WLED; + +/// +/// 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 +{ + internal enum Kind : byte + { + Id, + Next, + Previous, + Random, + RandomInRange, + } + + private Selector(Kind kind, int id) + : this(kind, id, id) + { + } + + private Selector(Kind kind, int from, int to) + { + Type = kind; + From = from; + To = to; + } + + internal Kind Type { 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); + + /// 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); + + /// 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 && From == other.From && To == other.To; + + public override bool Equals(object? obj) => obj is Selector 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() => Type switch + { + Kind.Id => From.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind.Next => "~", + Kind.Previous => "~-", + Kind.Random => "r", + Kind.RandomInRange => $"{From}~{To}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..08a8054 --- /dev/null +++ b/src/Kevsoft.WLED/Commands/SelectorJsonConverter.cs @@ -0,0 +1,62 @@ +namespace Kevsoft.WLED; + +/// Serializes as a number, "~", "~-", "r" or "from~tor". +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) + { + var value = reader.GetString(); + return value switch + { + "~" => Selector.Next, + "~-" => Selector.Previous, + "r" => Selector.Random, + _ => 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) + { + 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/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/Config/DeviceConfig.cs b/src/Kevsoft.WLED/Config/DeviceConfig.cs new file mode 100644 index 0000000..f9d3d1b --- /dev/null +++ b/src/Kevsoft.WLED/Config/DeviceConfig.cs @@ -0,0 +1,152 @@ +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; } + + /// 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. +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 +{ + /// 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). +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; } +} + +/// +/// 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/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/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/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/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/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/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/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/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/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/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/Fluent/SegmentUpdate.cs b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs new file mode 100644 index 0000000..0ad942e --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/SegmentUpdate.cs @@ -0,0 +1,365 @@ +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 }; + + /// 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); + + /// 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 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 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) + { + _request.ColorPaletteId = 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) + { + _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 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) + { + _request.Start = startX; + _request.Stop = stopX; + _request.StartY = startY; + _request.StopY = 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) + { + _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/Fluent/StateUpdate.cs b/src/Kevsoft.WLED/Fluent/StateUpdate.cs new file mode 100644 index 0000000..4eda017 --- /dev/null +++ b/src/Kevsoft.WLED/Fluent/StateUpdate.cs @@ -0,0 +1,209 @@ +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(); + private SegmentUpdate? _selectedSegment; + private SegmentMode _segmentMode = SegmentMode.None; + + private enum SegmentMode + { + None, + Selected, + Explicit, + } + + /// 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 ~109 minutes). + 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; + } + + /// 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; + } + + /// 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() + { + _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. + /// + /// 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) + { + 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() + { + switch (_segmentMode) + { + case SegmentMode.Explicit: + _request.Segments = SegmentPayload.List(_segments.ToArray()); + break; + case SegmentMode.Selected: + _request.Segments = SegmentPayload.Selected(_selectedSegment!.Build()); + break; + } + + return _request; + } + + private static ushort ToTransitionUnits(TimeSpan duration) + { + var units = Math.Round(duration.TotalMilliseconds / 100.0, MidpointRounding.AwayFromZero); + if (units < 0) + { + units = 0; + } + else if (units > ushort.MaxValue) + { + units = ushort.MaxValue; + } + + return (ushort)units; + } +} 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/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 6c27758..bfe869b 100644 --- a/src/Kevsoft.WLED/IWLedClient.cs +++ b/src/Kevsoft.WLED/IWLedClient.cs @@ -2,17 +2,176 @@ public interface IWLedClient { - Task Get(); + Task Get(CancellationToken cancellationToken = default); - Task GetState(); + /// + /// 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 GetInformation(); + Task GetState(CancellationToken cancellationToken = default); - Task GetEffects(); + Task GetInformation(CancellationToken cancellationToken = default); - Task GetPalettes(); + /// + /// Gets the lighter /json/si response, containing only the state and info objects. + /// + Task GetStateInfo(CancellationToken cancellationToken = default); - Task Post(WLedRootRequest request); + /// + /// Gets the nearby Wi-Fi networks reported by /json/net. + /// + Task GetNetworks(CancellationToken cancellationToken = default); - Task Post(StateRequest request); -} \ No newline at end of file + /// + /// Gets the live LED colour stream from /json/live, or null if the firmware + /// was not built with JSON-live support. + /// + Task GetLiveColors(CancellationToken cancellationToken = default); + + Task GetEffects(CancellationToken cancellationToken = default); + + 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); + + /// + /// Builds and posts a sparse state update using a fluent builder. + /// + 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); + + /// + /// 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); + + /// + /// Gets the saved presets, keyed by slot id. Playlists and the scratch slot are excluded. + /// + Task> GetPresets(CancellationToken cancellationToken = default); + + /// + /// Applies a preset by id, or cycles/randomises between presets. + /// + 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, CancellationToken cancellationToken = default); + + /// + /// Deletes the preset in the given slot. + /// + Task DeletePreset(int id, CancellationToken cancellationToken = default); + + /// + /// Gets the saved playlists, keyed by slot id. + /// + Task> GetPlaylists(CancellationToken cancellationToken = default); + + /// + /// Starts the given playlist immediately. + /// + Task StartPlaylist(PlaylistDefinition playlist, CancellationToken cancellationToken = default); + + /// + /// Builds and starts a playlist using a fluent builder. + /// + Task StartPlaylist(Action configure, CancellationToken cancellationToken = default); + + /// + /// Saves a playlist to the given preset slot. + /// + Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null, CancellationToken cancellationToken = default); + + /// + /// 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, 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(CancellationToken cancellationToken = default); + + /// + /// Gets other WLED devices discovered on the local network via /json/nodes. + /// + Task> GetNodes(CancellationToken cancellationToken = default); + + /// + /// Gets the full device configuration from /json/cfg. + /// + 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, 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/SegmentRequest.cs b/src/Kevsoft.WLED/SegmentRequest.cs index d27c186..47cb3c6 100644 --- a/src/Kevsoft.WLED/SegmentRequest.cs +++ b/src/Kevsoft.WLED/SegmentRequest.cs @@ -40,27 +40,27 @@ public sealed class SegmentRequest /// [JsonPropertyName("col")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int[][]? Colors { get; set; } + public SegmentColors? Colors { get; set; } /// [JsonPropertyName("fx")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? EffectId { get; set; } + public Selector? EffectId { get; set; } /// [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")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? ColorPaletteId { get; set; } + public Selector? ColorPaletteId { get; set; } /// [JsonPropertyName("sel")] @@ -75,23 +75,118 @@ 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")] [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; } + + /// 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 @@ -113,7 +208,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/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/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/StateRequest.cs b/src/Kevsoft.WLED/StateRequest.cs index f07d5c7..47ad1c5 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; } + public ushort? Transition { get; set; } + + /// + /// Sets the transition time for the current API call only (the tt field). + /// One unit is 100ms. Range 0–65535. + /// + [JsonPropertyName("tt")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ushort? TransientTransition { get; set; } /// [JsonPropertyName("ps")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public int? PresetId { get; set; } + public PresetSelector? PresetId { get; set; } /// [JsonPropertyName("pl")] @@ -40,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")] @@ -50,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. @@ -59,6 +67,104 @@ 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; } + + /// + /// 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; } + + /// + /// Start a playlist (the playlist field, write-only). + /// + [JsonPropertyName("playlist")] + [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() @@ -66,7 +172,7 @@ public static StateRequest From(StateResponse stateResponse) On = stateResponse.On, Brightness = stateResponse.Brightness, Transition = stateResponse.Transition, - PresetId = 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..da9c803 100644 --- a/src/Kevsoft.WLED/StateResponse.cs +++ b/src/Kevsoft.WLED/StateResponse.cs @@ -15,22 +15,24 @@ 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. + /// 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/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/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/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/src/Kevsoft.WLED/WLedClient.cs b/src/Kevsoft.WLED/WLedClient.cs index 45ed2ec..9dc51f7 100644 --- a/src/Kevsoft.WLED/WLedClient.cs +++ b/src/Kevsoft.WLED/WLedClient.cs @@ -5,81 +5,427 @@ public sealed class WLedClient : IWLedClient private readonly HttpClient _client; public WLedClient(HttpMessageHandler httpMessageHandler, string baseUri) + : this(CreateClient(httpMessageHandler, baseUri)) { - _client = new HttpClient(httpMessageHandler) + } + + public WLedClient(string baseUri) : this(new HttpClientHandler(), baseUri) + { + } + + /// + /// Creates a client over a pre-configured . The client's + /// must be set. Intended for IHttpClientFactory/DI use. + /// + public WLedClient(HttpClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + + if (_client.BaseAddress is null) + { + throw new ArgumentException("The HttpClient must have a BaseAddress set.", nameof(client)); + } + } + + private static HttpClient CreateClient(HttpMessageHandler httpMessageHandler, string baseUri) + { + var client = new HttpClient(httpMessageHandler) { BaseAddress = new Uri(baseUri, UriKind.Absolute) }; - // Add the keep-alive flag to the header - _client.DefaultRequestHeaders.Add("Connection", "keep-alive"); + client.DefaultRequestHeaders.Add("Connection", "keep-alive"); + return client; } - public WLedClient(string baseUri) : this(new HttpClientHandler(), baseUri) + 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 async Task Get() + public Task GetState(CancellationToken cancellationToken = default) + => GetJson("json/state", cancellationToken); + + public Task GetInformation(CancellationToken cancellationToken = default) + => GetJson("json/info", cancellationToken); + + public Task GetStateInfo(CancellationToken cancellationToken = default) + => GetJson("json/si", cancellationToken); + + public async Task GetNetworks(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json"); + var response = await GetJson("json/net", cancellationToken); + return response?.Networks ?? Array.Empty(); + } - message.EnsureSuccessStatusCode(); + public async Task GetLiveColors(CancellationToken cancellationToken = default) + { + var message = await SendGetAsync("json/live", cancellationToken); - return (await message.Content.ReadFromJsonAsync())!; + if (message.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + await EnsureSuccess(message); + + if (message.Content.Headers.ContentLength == 0) + { + return null; + } + + return await message.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); } - public async Task GetState() + public Task GetEffects(CancellationToken cancellationToken = default) + => GetJson("json/eff", cancellationToken); + + 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); + + public Task Post(StateRequest request, CancellationToken cancellationToken = default) + => PostJson("/json/state", request, cancellationToken); + + public Task UpdateState(Action configure, CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/state"); + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } - message.EnsureSuccessStatusCode(); + var update = new StateUpdate(); + configure(update); + return Post(update.Build(), cancellationToken); + } + + 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); + + public Task SetBrightness(ByteAdjust brightness, CancellationToken cancellationToken = default) + => Post(new StateRequest { Brightness = brightness }, cancellationToken); + + 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); - return (await message.Content.ReadFromJsonAsync())!; + 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 async Task GetInformation() + public Task SetPalette(PaletteCatalogEntry palette, int? segmentId = null, CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/info"); + if (palette is null) + { + throw new ArgumentNullException(nameof(palette)); + } - message.EnsureSuccessStatusCode(); + if (palette.IsReserved) + { + throw new ArgumentException($"Palette '{palette.Name}' (id {palette.Id}) is a reserved placeholder and cannot be selected.", nameof(palette)); + } - return (await message.Content.ReadFromJsonAsync())!; + return SetPalette(Selector.Id(palette.Id), segmentId, cancellationToken); } - public async Task GetEffects() + public Task Reboot(CancellationToken cancellationToken = default) + => Post(new StateRequest { Reboot = true }, cancellationToken); + + public async Task> GetPresets(CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/eff"); + var json = await GetString("presets.json", cancellationToken); + return PresetsParser.ParsePresets(json, new JsonSerializerOptions()); + } + + public Task ApplyPreset(PresetSelector preset, CancellationToken cancellationToken = default) + => Post(new StateRequest { PresetId = preset }, cancellationToken); + + public Task SavePreset(int id, SavePresetOptions? options = null, CancellationToken cancellationToken = default) + { + options ??= new SavePresetOptions(); - message.EnsureSuccessStatusCode(); + return Post(new StateRequest + { + SavePresetSlot = id, + PresetName = options.Name, + QuickLabel = options.QuickLabel, + SaveSegmentBounds = options.SaveSegmentBounds, + IncludeBrightness = options.IncludeBrightness, + SaveSelectedSegments = options.SaveSelectedSegments + }, cancellationToken); + } - return (await message.Content.ReadFromJsonAsync())!; + public Task DeletePreset(int id, CancellationToken cancellationToken = default) + => Post(new StateRequest { DeletePresetSlot = id }, cancellationToken); + + public async Task> GetPlaylists(CancellationToken cancellationToken = default) + { + var json = await GetString("presets.json", cancellationToken); + return PlaylistsParser.ParsePlaylists(json); } - public async Task GetPalettes() + public Task StartPlaylist(PlaylistDefinition playlist, CancellationToken cancellationToken = default) { - var message = await _client.GetAsync("json/pal"); + if (playlist is null) + { + throw new ArgumentNullException(nameof(playlist)); + } - message.EnsureSuccessStatusCode(); - - return (await message.Content.ReadFromJsonAsync())!; + return Post(new StateRequest { Playlist = PlaylistRequest.From(playlist) }, cancellationToken); } - public async Task Post(WLedRootRequest request) + public Task StartPlaylist(Action configure, CancellationToken cancellationToken = default) { - var stateString = JsonSerializer.Serialize(request); + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } - using var content = new StringContentWithoutCharset(stateString, "application/json"); - var result = await _client.PostAsync("/json", content); - result.EnsureSuccessStatusCode(); + var builder = new PlaylistBuilder(); + configure(builder); + return StartPlaylist(builder.Build(), cancellationToken); } - - public async Task Post(StateRequest request) + + public Task SavePlaylist(int id, PlaylistDefinition playlist, SavePresetOptions? options = null, CancellationToken cancellationToken = default) { - var stateString = JsonSerializer.Serialize(request); + 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) + }, cancellationToken); + } + + public async Task> GetEffectMetadata(CancellationToken cancellationToken = default) + { + var fxdata = await GetJson("json/fxdata", cancellationToken); + var effects = await GetEffects(cancellationToken); + + return EffectMetadataParser.Parse(fxdata, effects); + } + + public async Task> GetNodes(CancellationToken cancellationToken = default) + { + var message = await SendGetAsync("json/nodes", cancellationToken); + + await EnsureSuccess(message); + + if (message.Content.Headers.ContentLength == 0) + { + return Array.Empty(); + } + + var response = await message.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + return response?.Nodes ?? Array.Empty(); + } + + public Task GetConfig(CancellationToken cancellationToken = default) + => GetJson("json/cfg", cancellationToken); + + public Task UpdateConfig(DeviceConfig partial, UpdateConfigOptions? options = null, CancellationToken cancellationToken = default) + { + 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."); + } + + 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) + { + 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 } + } + }, cancellationToken); + } + } + + private static StateRequest SingleSegment(int? segmentId, Action configure) + { + var segment = new SegmentRequest { Id = segmentId }; + configure(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) + { + 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. + } + } - using var content = new StringContentWithoutCharset(stateString, "application/json"); - var result = await _client.PostAsync("/json/state", content); - result.EnsureSuccessStatusCode(); + throw new WledResponseException((int)message.StatusCode, body); } -} \ No newline at end of file +} 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/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/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/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); + } +} 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/CommandValueTests.cs b/test/Kevsoft.WLED.Tests/CommandValueTests.cs new file mode 100644 index 0000000..56f867a --- /dev/null +++ b/test/Kevsoft.WLED.Tests/CommandValueTests.cs @@ -0,0 +1,102 @@ +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\""); + 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] + 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/ConfigAndNodesTests.cs b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs new file mode 100644 index 0000000..65c3a7e --- /dev/null +++ b/test/Kevsoft.WLED.Tests/ConfigAndNodesTests.cs @@ -0,0 +1,206 @@ +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 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"",""inv"":""Light""}, + ""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("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); + } + + [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(); + } + + [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(); + 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(); + } +} 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/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); + } +} 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(); + } +} 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(); + } +} 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); + } +} 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/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/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/IntentMethodTests.cs b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs new file mode 100644 index 0000000..5d6a4f5 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/IntentMethodTests.cs @@ -0,0 +1,125 @@ +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"); + 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(); + color.Should().Equal(255, 170, 0); + } + + [Fact] + public async Task SetColorWithSegmentIdTargetsThatSegment() + { + var (_, body) = await Capture(client => client.SetColor(RgbColor.FromHex("010203"), segmentId: 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] + public async Task SetColorRgbwPostsFourChannels() + { + var (_, body) = await Capture(client => client.SetColor(RgbwColor.FromHex("01020304"))); + + 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); + } + + [Fact] + public async Task SetEffectPostsSegmentEffectId() + { + var (_, body) = await Capture(client => client.SetEffect(42)); + + 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); + } + + [Fact] + public async Task SetPalettePostsSegmentPaletteId() + { + var (_, body) = await Capture(client => client.SetPalette(7, 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(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/JsonBuilder.cs b/test/Kevsoft.WLED.Tests/JsonBuilder.cs index 79eeb0c..521b3ec 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 => { @@ -33,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}, @@ -44,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} @@ -59,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} @@ -70,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}, diff --git a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj index 2916911..636a10f 100644 --- a/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj +++ b/test/Kevsoft.WLED.Tests/Kevsoft.WLED.Tests.csproj @@ -1,22 +1,23 @@ - net6.0 + $(TestTargetFrameworks) false - - - - - - + + + + + + + diff --git a/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs b/test/Kevsoft.WLED.Tests/MockHttpMessageHandler.cs index 745b145..11f81d8 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)); @@ -20,11 +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) { - 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 (ThrowOnSend is not null) + { + throw ThrowOnSend; + } if (_mockedResponses.TryGetValue(request.RequestUri!.AbsoluteUri, out var value)) { 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); + } +} 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); + } +} diff --git a/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs new file mode 100644 index 0000000..c2c9986 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/SegmentObjectTests.cs @@ -0,0 +1,195 @@ +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(999)] + [InlineData(20001)] + public void KelvinOutOfRangeThrows(int kelvin) + { + var act = () => ColorTemperature.Kelvin(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() + { + 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(); + } + + [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(); + 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/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); + } +} diff --git a/test/Kevsoft.WLED.Tests/StateObjectTests.cs b/test/Kevsoft.WLED.Tests/StateObjectTests.cs new file mode 100644 index 0000000..9661142 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/StateObjectTests.cs @@ -0,0 +1,94 @@ +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(); + } + + [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/StateUpdateBuilderTests.cs b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs new file mode 100644 index 0000000..270f279 --- /dev/null +++ b/test/Kevsoft.WLED.Tests/StateUpdateBuilderTests.cs @@ -0,0 +1,155 @@ +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); + } + + [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); + } + + [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); + } +} 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)); + } +} 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 274bab5..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() @@ -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) 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())); + } +}