From 3fa14ff73239005b1f0b184a3966ec073923701b Mon Sep 17 00:00:00 2001 From: Fritiof Hedman Date: Sun, 7 Jun 2026 13:28:15 +0200 Subject: [PATCH] Initial plan --- CLAUDE.md | 53 ++++++++++ IMPLEMENTATION.md | 255 +++++++++++++++++++++++++++++++++++++++++++++ PLAN.md | 208 +++++++++++++++++++++++++++++++++++++ SPEC.md | 256 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 772 insertions(+) create mode 100644 CLAUDE.md create mode 100644 IMPLEMENTATION.md create mode 100644 PLAN.md create mode 100644 SPEC.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e56807 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# Frame Processor + +Self-hosted ASP.NET Core (.NET 10) service that dithers and color-remaps images for Spectra 6 e-ink frames, persists the result to disk, and notifies the frame via MQTT. + +See `SPEC.md` for the authoritative behavior contract and `PLAN.md` for the implementation design. Update both if requirements change. + +## Status + +Greenfield — only `SPEC.md` and `PLAN.md` exist so far. No source, no tests, no Dockerfile yet. The layout in `PLAN.md` §"Project layout" is the target, not the current state. + +## Stack + +- .NET 10, ASP.NET Core (minimal hosting + controllers) +- SixLabors.ImageSharp — decode, resize, dither, indexed-PNG encode +- MQTTnet v4 — persistent client in an `IHostedService` +- Serilog — console + rolling file +- xUnit — golden-image fixture tests +- Docker + docker-compose for deployment + +## Commands + +Once scaffolded: + +- `dotnet build` / `dotnet test` — from repo root +- `docker compose up --build` — full stack including a `mosquitto` broker +- `dotnet run --project src/FrameProcessor` — local dev (set `Mqtt__Host=localhost` if broker is on host) + +Manual smoke: + +- `curl -H "X-Api-Key: ..." -F "image=@photo.jpg" http://localhost:8080/api/frames/living-room/image` +- `mosquitto_sub -t 'frames/#' -v` to watch publishes + +## Load-bearing conventions + +These are easy to get subtly wrong — re-read `SPEC.md` §3.2 if in doubt. + +- **Dither, then remap.** Dither against the *display* palette (the `color` field — what the eye sees on the panel), then map each pixel to its `deviceColor` (what firmware expects in the input PNG). Reversing this order produces wrong output. +- **MAC normalization at every boundary.** Store, match, and publish using lowercase hex with no separators (`aabbccddeeff`). Normalize on config load, on URL path entry, and before building MQTT topics. +- **Atomic writes only.** Write to `{mac}.png.tmp`, then `File.Move(..., overwrite: true)`. The GET endpoint must never observe a partial file. +- **MQTT failure is non-fatal to the request.** A publish failure does not fail the upload — the PNG is still saved, the response is still `200`, and the response body's `mqttPublished: false` tells the caller. Retry happens in a background loop. +- **Per-frame serialization.** A single `SemaphoreSlim(1,1)` per frame name wraps the entire pipeline (fetch/decode → process → write → publish-attempt) so the latest PNG on disk always corresponds to the latest publish attempt. Different frames are independent. +- **`frames.json` reload asymmetry.** Invalid frame at *startup* → fail fast with a clear error. Invalid frame on *hot-reload* → log a warning and skip that one frame; keep serving the others. A typo must not take the service down. +- **Indexed PNG output.** `PngColorType.Palette` with the smallest bit depth that fits the palette (4-bit for ≤16 colors). Pass `PaletteQuantizer(deviceColorPalette)` to `PngEncoder`. + +## Testing approach + +Pipeline correctness is the load-bearing part — verify it with golden-image fixtures (`tests/FrameProcessor.Tests/ImagePipelineTests.cs`). ImageSharp dither output is deterministic for a fixed input/palette/algorithm, so byte-equality assertions are reliable. + +No controller/integration tests in v1 — HTTP plumbing is thin enough that `curl` smoke-tests are sufficient. + +## Out of scope (don't add without discussion) + +See `SPEC.md` §12. Notably: no auth beyond the shared API key, no SSRF protection on URL fetch, no per-frame history, no web UI, no HTTPS termination in-process. diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md new file mode 100644 index 0000000..622227c --- /dev/null +++ b/IMPLEMENTATION.md @@ -0,0 +1,255 @@ +# Frame Processor — Implementation Increments + +Step-by-step build order for assembling the service described in `SPEC.md` and `PLAN.md`. Each increment is sized to compile, run, and be independently verified before moving to the next. Order is chosen so a minimal end-to-end path exists early; quality, resilience, and tests layer on after. + +When working through this file, mark increments complete by changing `[ ]` to `[x]`. If the design deviates from `PLAN.md`/`SPEC.md` mid-stream, update those documents too. + +--- + +## Phase 0 — Scaffolding + +### [ ] 0.1 Solution + projects +- Create `FrameProcessor.sln` at repo root. +- Create `src/FrameProcessor/FrameProcessor.csproj` targeting `net10.0`, `Microsoft.NET.Sdk.Web`. +- Create `tests/FrameProcessor.Tests/FrameProcessor.Tests.csproj` (xUnit) referencing the main project. +- Add `.gitignore` (dotnet template), `global.json` pinning .NET 10 SDK. +- **DoD:** `dotnet build` and `dotnet test` both succeed (no tests yet, but harness runs). + +### [ ] 0.2 Minimal Program.cs +- Minimal hosting + controllers wired up. +- `GET /health` returning `{ status: "Healthy", mqttConnected: false }` (mqttConnected hardcoded for now). +- **DoD:** `dotnet run --project src/FrameProcessor` then `curl localhost:8080/health` returns 200 with the JSON. + +### [ ] 0.3 Package references +- Add to `FrameProcessor.csproj`: + - `SixLabors.ImageSharp` + - `MQTTnet` + - `Serilog.AspNetCore`, `Serilog.Sinks.Console`, `Serilog.Sinks.File` +- **DoD:** restore + build clean. + +--- + +## Phase 1 — Domain value types + +Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.Tests/`. + +### [ ] 1.1 `MacAddress` +- `record struct` storing 6 bytes. +- `static bool TryParse(string, out MacAddress)` accepting `AA:BB:CC:DD:EE:FF`, `aa-bb-cc-dd-ee-ff`, `AABBCCDDEEFF`, surrounding whitespace. +- `ToString()` → canonical lowercase no-separator (`aabbccddeeff`). +- JSON converter (System.Text.Json) using the permissive parse + canonical write. +- ASP.NET route value binding (e.g., `IParsable`). +- **Tests:** `MacAddressTests` covering round-trip from all four input forms, equality on bytes, rejection of bad input. + +### [ ] 1.2 `FrameName` +- `record struct` wrapping a validated string. +- Constructor / `TryParse` enforces RFC 3986 unreserved (`A-Z a-z 0-9 - . _ ~`), non-empty. +- JSON converter + `IParsable`. +- **Tests:** `FrameNameTests` — accepts URL-safe, rejects whitespace/reserved chars with clear message. + +### [ ] 1.3 `Orientation` +- `enum { Landscape, Portrait }`. +- JSON converter reading/writing kebab-case lowercase. + +### [ ] 1.4 `Resolution` +- `record(int Width, int Height)`. +- `Resolution ForOrientation(Orientation o)` — swaps when portrait. + +### [ ] 1.5 `PaletteEntry` +- `record(string Name, Color DisplayColor, Color DeviceColor)` using `SixLabors.ImageSharp.Color`. +- JSON converter parses hex strings into `Color` at deserialize time (don't store hex strings on the type). + +### [ ] 1.6 `DitherAlgorithm` +- `enum { FloydSteinberg, Atkinson, Stucki, Jarvis }`. +- JSON converter reading kebab-case (`floyd-steinberg`, etc.). + +### [ ] 1.7 `ApiKey` +- `record struct` wrapping a string. +- `bool Matches(string candidate)` using `CryptographicOperations.FixedTimeEquals` over UTF-8 bytes. + +--- + +## Phase 2 — Configuration binding + +### [ ] 2.1 `MqttOptions`, `StorageOptions`, `UrlFetchOptions`, `ApiKeyOptions` +- POCOs in `src/FrameProcessor/Configuration/`. +- Bound from `appsettings.json` via `builder.Services.Configure(...)`. +- Validate on startup (`ValidateOnStart` + `IValidateOptions` or DataAnnotations). + +### [ ] 2.2 `FramesOptions` (from `frames.json`) +- Top-level `{ Frames: Frame[] }` POCO. +- Register `frames.json` as an additional config source with `reloadOnChange: true`. +- Bind via `IOptionsMonitor`. +- Custom validator enforcing rules from `SPEC.md` §6.2 (URL-safe name, MAC parseable, palette ≥2, hex parseable, dithering known). + +### [ ] 2.3 Startup vs reload asymmetry +- On startup: throw on any invalid frame (fail-fast). +- On `OnChange`: log warning, skip invalid frame, keep valid ones serving (`PLAN.md` line 130, `CLAUDE.md` "frames.json reload asymmetry"). +- Add a `FramesRegistry` service that exposes `TryGetByName(FrameName)` / `TryGetByMac(MacAddress)` over the current valid set. + +### [ ] 2.4 Drop sample configs in repo root +- `appsettings.json` from `PLAN.md` §"Settings schemas". +- `frames.json` with one example frame (Spectra 6 palette). +- **DoD:** Service starts and logs the loaded frames; an invalid `frames.json` fails startup with a clear error. + +--- + +## Phase 3 — Image pipeline + +### [ ] 3.1 `PaletteFactory` +- `static ReadOnlyMemory BuildDisplay(IReadOnlyList)` and `BuildDevice(...)`. +- **Tests:** `PaletteFactoryTests` — count, ordering preserved. + +### [ ] 3.2 `DitheringRegistry` +- `IReadOnlyDictionary` mapping each enum to the corresponding ImageSharp `KnownDitherings.*`. + +### [ ] 3.3 `IImagePipeline` + `ImagePipeline` +- Single method: `byte[] Process(Stream input, Frame frame)`. +- Steps (`SPEC.md` §3.2): decode → resize (cover/center-crop to oriented resolution) → dither against display palette → remap pixels display→device → rotate 90° CW if portrait → encode indexed PNG (`PngColorType.Palette`, smallest bit depth fitting the palette, `PaletteQuantizer(deviceColors)`). +- Output dimensions always match the frame's *declared* `width × height` regardless of orientation. + +### [ ] 3.4 `ImagePipelineTests` golden fixtures +- Check in `tests/FrameProcessor.Tests/Fixtures/inputs/` (landscape.jpg, portrait.jpg, tiny-1x1.png, non-divisible.jpg). +- Generate expected outputs once, commit under `Fixtures/expected//.png`. +- Assert byte-equality of produced PNG vs golden. +- Run for all four dithering algorithms against the canonical Spectra 6 palette. +- **DoD:** `dotnet test` green; deleting a fixture output and rerunning fails loud. + +--- + +## Phase 4 — Storage + +### [ ] 4.1 `ImageStore` +- Constructor takes `IOptions`; ensures `ImageDirectory` exists on startup. +- `Task WriteAsync(MacAddress, ReadOnlyMemory, CancellationToken)` — writes to `{mac}.png.tmp`, then `File.Move(tmp, final, overwrite: true)`. +- `bool TryGetPath(MacAddress, out string path)` returning the on-disk path if present. +- **Manual check:** call from a unit test or scratch endpoint, confirm atomic rename behavior. + +--- + +## Phase 5 — First end-to-end happy path (no MQTT yet) + +### [ ] 5.1 `FramesController.UploadImage` (multipart) +- Route: `POST /api/frames/{name}/image`. +- Resolve frame by name → 404 if unknown. +- Read multipart file part `image` (return 400 if missing). +- Call `ImagePipeline.Process`, then `ImageStore.WriteAsync`. +- Return `200 { frame, mac, url, processedAt, mqttPublished: false }` (MQTT stubbed). + +### [ ] 5.2 `ImageController.GetImage` +- Route: `GET /i/{mac}.png`. +- Normalize `{mac}` via `MacAddress.TryParse` → 404 on bad form. +- Look up frame by MAC → 404 if unknown or file absent. +- Return `FileStreamResult` with `Content-Type: image/png`, `Cache-Control: no-store`, `ETag` derived from file mtime. + +### [ ] 5.3 Manual end-to-end smoke +- Start service, `curl -F image=@photo.jpg .../api/frames/living-room/image` → 200. +- `curl .../i/aabbccddeeff.png > out.png` → image opens and looks dithered + remapped. +- **DoD:** above two commands work. + +--- + +## Phase 6 — MQTT + +### [ ] 6.1 `MqttPublisher` hosted service +- Singleton `IHostedService` wrapping `IMqttClient` (MQTTnet v4). +- `WithReconnectDelay`, `WithCleanSession(false)`, credentials/TLS from `MqttOptions`. +- Exposes `bool IsConnected` for `/health`. +- On `StartAsync`: connect; on failure, log and continue (background reconnect handles it). + +### [ ] 6.2 `PublishAsync(MacAddress, CancellationToken) → Result` +- Topic: `{BaseTopic}/{mac}`, payload UTF-8 `"update"`, QoS 1, retained false. +- Returns success/failure (no throw). + +### [ ] 6.3 Wire into `FramesController` +- After successful save, call `PublishAsync`; set `mqttPublished` accordingly in the response. + +### [ ] 6.4 `/health` reports MQTT status +- Replace hardcoded `mqttConnected` with `MqttPublisher.IsConnected`. + +### [ ] 6.5 Background retry queue +- In-memory `Channel` (one slot per frame; newer publish supersedes older — per `SPEC.md` §5.1 "Multiple queued publishes for the same frame collapse to the most recent one"). +- Background loop drains with backoff sequence from `MqttOptions.RetryBackoffSeconds`. +- On reconnect, drain immediately. +- **Manual check:** stop broker, upload → 200 with `mqttPublished: false`; restart broker → `mosquitto_sub` sees the message within backoff window. + +--- + +## Phase 7 — URL-fetch ingestion + +### [ ] 7.1 `IImageUrlFetcher` +- Constructor takes `HttpClient` (via `IHttpClientFactory`) configured with `UrlFetch.TimeoutSeconds` and `MaxRedirects`. +- `Task FetchAsync(Uri, CancellationToken)` — streams response, aborts if content exceeds `MaxBytes`. +- Throws a typed exception (`ImageFetchException`) for timeout / non-2xx / too-large / redirect-loop. + +### [ ] 7.2 `FramesController.UploadImageUrl` +- Route: `POST /api/frames/{name}/image-url`, body `{ "url": "..." }`. +- Fetch → pipeline → store → publish. Same response shape as 5.1. +- Map `ImageFetchException` to `502 Bad Gateway`. + +### [ ] 7.3 Manual check +- `curl -H "Content-Type: application/json" -d '{"url":"https://..."}' .../image-url` works end-to-end. + +--- + +## Phase 8 — Auth + concurrency + robustness + +### [ ] 8.1 `ApiKeyMiddleware` +- Matches request path `/api/*`; reads `X-Api-Key` header; constant-time compare against `ApiKeyOptions`. +- 401 on mismatch. `/i/{mac}.png` and `/health` unaffected. + +### [ ] 8.2 `FrameLockProvider` +- `ConcurrentDictionary` (each `SemaphoreSlim(1, 1)`). +- `Task AcquireAsync(FrameName, CancellationToken)` returning a disposable that releases on dispose. + +### [ ] 8.3 Wrap full pipeline in lock +- In `FramesController`, acquire the frame's lock before fetch/decode and release after publish-attempt completes (`CLAUDE.md` "Per-frame serialization"). +- **Manual check:** fire two concurrent uploads to the same frame → both return 200, only one PNG on disk reflects whichever finished last, two MQTT publishes (or one if collapsed by retry queue). + +--- + +## Phase 9 — Observability + +### [ ] 9.1 Serilog wiring +- `UseSerilog` with console + rolling file sinks (`logs/frame-processor-.log`, daily). +- Structured fields: `FrameName`, `MacAddress`, `InputSource` (file/url), `InputBytes`, `OutputBytes`, `ElapsedMs`, `MqttPublished`. +- Log one info line per upload at completion. + +--- + +## Phase 10 — Packaging + +### [ ] 10.1 `Dockerfile` +- Multi-stage: SDK build → runtime (`mcr.microsoft.com/dotnet/aspnet:10.0`). +- Expose 8080; create `/data/images`. +- Non-root user. + +### [ ] 10.2 `docker-compose.yml` +- `frame-processor` service (build from Dockerfile, mount `frames.json` and `/data/images`). +- `mosquitto` broker with anonymous access on the local network. +- Single network so the service can reach `mosquitto:1883`. + +### [ ] 10.3 End-to-end via compose +- `docker compose up --build` → `/health` returns `mqttConnected: true`. +- Run the full `PLAN.md` §Verification checklist (steps 2–7). + +--- + +## Phase 11 — Final verification + +### [ ] 11.1 Run `PLAN.md` verification checklist top to bottom +- All 8 steps pass (step 9, real frame, is optional from this codebase's perspective). + +### [ ] 11.2 README +- Short `README.md` pointing at `SPEC.md`, `PLAN.md`, `CLAUDE.md` and showing the `docker compose up` + smoke-test commands. + +--- + +## Notes for future-me + +- **Don't reorder dither/remap.** Dither against `DisplayColor`; remap to `DeviceColor` after. Reversing produces visibly wrong output. +- **Normalize MAC at every boundary.** Config load, route binding, MQTT topic, file path — all lowercase no-separator. +- **Atomic writes are non-negotiable.** Never write directly to `{mac}.png`; always tmp + rename. +- **MQTT failure ≠ upload failure.** A broker outage produces a 200 with `mqttPublished: false`, not a 5xx. +- **Hot-reload asymmetry.** Startup is strict; reload is lenient and skips bad frames. +- **No premature abstractions.** If a step says "single class," resist the urge to introduce interfaces beyond what `PLAN.md` lists. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..8a7e192 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,208 @@ +# Frame Processor — Plan + +## Context + +A self-hosted ASP.NET Core service that turns arbitrary images (uploaded as JPG/PNG or pulled from a URL) into something a 6-color e-ink frame (Spectra 6) can render. The frame's accepted colors don't match what's *actually displayed* — `#FF0000` in the PNG renders as a muted `#62201E` red on the device — so the pipeline has to dither against the *displayed* palette and then remap each pixel to the device-input color. After the PNG is ready, the service publishes an MQTT signal; the frame wakes and pulls its image from `/i/{mac}.png`. + +The firmware is already written, so we get to design both sides. The service must support multiple frames (each with its own MAC, resolution, orientation, palette) so it can grow with the home setup. + +--- + +## Architecture + +Single ASP.NET Core (.NET 10) service, sync request handling, packaged as a Docker image with docker-compose. + +``` +[client] --POST image--> [Service] --dither+remap+save PNG--> [disk] --GET /i/{mac}.png--> [frame] + | + +--MQTT publish "update"--> [broker] -----> [frame] +``` + +Key non-functional choices: +- **Sync** request model (1600×1200 dither finishes well under 1s; no queue needed). +- **Per-frame lock** to serialize concurrent uploads to the same frame. +- **Latest-only storage** on local disk with atomic temp-file rename. +- **MQTT non-blocking on failure**: PNG saved regardless, background retry loop publishes the `update` when broker recovers. + +--- + +## Project layout + +``` +src/FrameProcessor/ + Program.cs // host + DI + middleware wiring + Domain/ // value types — own their JSON + route binders + MacAddress.cs // record struct; permissive parse, canonical lowercase-no-separator ToString + FrameName.cs // record struct; validates URL-safe charset (RFC 3986 unreserved) + Orientation.cs // enum Landscape | Portrait with JSON converter + Resolution.cs // record; ForOrientation(Orientation) returns rotated pair + PaletteEntry.cs // record with parsed ImageSharp Colors (no hex strings) + DitherAlgorithm.cs // enum (floyd-steinberg | atkinson | stucki | jarvis) with JSON converter + ApiKey.cs // record struct with constant-time comparison + Configuration/ + FramesOptions.cs // bound from frames.json (IOptionsMonitor) + MqttOptions.cs // bound from appsettings.json + ApiKeyOptions.cs // wraps an ApiKey + Controllers/ + FramesController.cs // POST /api/frames/{name}/image, /image-url + ImageController.cs // GET /i/{mac}.png + HealthController.cs // GET /health + ImagePipeline/ + IImagePipeline.cs + ImagePipeline.cs // scale → dither → remap → encode (indexed PNG) + PaletteFactory.cs // builds ImageSharp ReadOnlyMemory from PaletteEntry list + DitheringRegistry.cs // maps DitherAlgorithm → IDither + Mqtt/ + MqttPublisher.cs // persistent MQTTnet client, QoS 1, background retry queue + Storage/ + ImageStore.cs // atomic write, path resolution from MacAddress + Middleware/ + ApiKeyMiddleware.cs // enforces API key on /api/* only + Concurrency/ + FrameLockProvider.cs // keyed SemaphoreSlim per FrameName +tests/FrameProcessor.Tests/ + ImagePipelineTests.cs // golden-image fixtures + PaletteFactoryTests.cs + MacAddressTests.cs // round-trips colons/hyphens/uppercase/whitespace → canonical + FrameNameTests.cs // URL-safety validation +Dockerfile +docker-compose.yml +appsettings.json +frames.json // mounted; not in image +``` + +--- + +## Settings schemas + +### `appsettings.json` (in image, overridable by env) + +```jsonc +{ + "Mqtt": { + "Host": "mosquitto", + "Port": 1883, + "ClientId": "frame-processor", + "Username": null, + "Password": null, + "UseTls": false, + "BaseTopic": "frames", + "PublishQos": 1, + "RetryBackoffSeconds": [1, 2, 5, 15, 60] + }, + "ApiKey": "change-me", + "Storage": { + "ImageDirectory": "/data/images" + }, + "UrlFetch": { + "MaxBytes": 52428800, + "TimeoutSeconds": 30, + "MaxRedirects": 3 + }, + "Logging": { "LogLevel": { "Default": "Information" } } +} +``` + +### `frames.json` (mounted volume, hot-reloaded via `IOptionsMonitor`) + +```jsonc +{ + "Frames": [ + { + "name": "living-room", + "mac": "AA:BB:CC:DD:EE:FF", // normalized to "aabbccddeeff" at load + "resolution": { "width": 1600, "height": 1200 }, + "orientation": "landscape", // landscape | portrait + "dithering": "floyd-steinberg", // floyd-steinberg | atkinson | stucki | jarvis + "palette": [ + { "name": "black", "color": "#1F2226", "deviceColor": "#000000" }, + { "name": "white", "color": "#B9C7C9", "deviceColor": "#FFFFFF" }, + { "name": "blue", "color": "#233F8E", "deviceColor": "#0000FF" }, + { "name": "green", "color": "#35563A", "deviceColor": "#00FF00" }, + { "name": "red", "color": "#62201E", "deviceColor": "#FF0000" }, + { "name": "yellow", "color": "#C1BB1E", "deviceColor": "#FFFF00" } + ] + } + ] +} +``` + +Validation on load: name URL-safe, MAC parses to 6 bytes, palette has ≥2 entries, all hex colors parse, dithering key is known. Fail startup with a clear message if any frame is invalid; log a warning and skip the bad frame on hot-reload (so a typo doesn't crash a live service). + +--- + +## HTTP API + +| Method | Path | Auth | Body | Response | +|--------|-------------------------------------|----------|-------------------------------|----------------------------------------------------------------| +| POST | `/api/frames/{name}/image` | API key | `multipart/form-data` (file) | `200 { frame, mac, url, processedAt, mqttPublished: bool }` | +| POST | `/api/frames/{name}/image-url` | API key | `application/json { url }` | same | +| GET | `/i/{mac}.png` | none | — | `200` PNG with `Cache-Control: no-store`, ETag from mtime; `404` if no image yet | +| GET | `/health` | none | — | `200 { status, mqttConnected }` | + +`{name}` resolves to a frame via `FramesOptions` lookup; 404 if unknown. + +`{mac}` in `/i/{mac}.png` is normalized on entry (lowercase, separators stripped) and matched against normalized stored MACs. + +--- + +## Image pipeline (the meat) + +For each upload: + +1. **Load** the input via ImageSharp (`Image.Load(stream)`). +2. **Resize** with `ResizeMode.Crop` (center-crop cover) to the frame's resolution — swap W/H if `orientation == portrait`. +3. **Dither** against a palette built from the already-parsed `DisplayColor` of each `PaletteEntry`, using the configured dither algorithm: + ```csharp + var displayPalette = frame.Palette.Select(p => p.DisplayColor).ToArray(); + img.Mutate(x => x.Dither(ditherings[frame.Dithering], displayPalette)); + ``` + `frame.Dithering` is a `DitherAlgorithm` enum; `ditherings` is `IReadOnlyDictionary`. +4. **Remap** each pixel from `DisplayColor` → `DeviceColor`. Build a `Dictionary` once per frame from the palette and do a single pixel-buffer pass. Since the dither in step 3 produces only palette colors, every pixel finds a mapping. +5. **Rotate** 90° if `orientation == portrait` so the output PNG is always `width × height` as declared (e.g., 1600×1200), with content rotated for portrait mounting. +6. **Encode** as **indexed PNG** (`PngColorType.Palette`, `PngBitDepth.Bit4` — fits 6 colors in a nibble). ImageSharp's `PngEncoder` with `Quantizer = new PaletteQuantizer(deviceColorPalette)` produces this in one call. +7. **Atomic write**: `{tmpPath} → {finalPath}` via `File.Move(..., overwrite: true)` so the GET endpoint never sees a half-written file. + +`IImagePipeline` is a single injected service. Encapsulates 1–6 and returns the bytes; `ImageStore` does step 7. + +--- + +## MQTT + +- `MQTTnet` v4 with `IMqttClient` wrapped in a hosted service (`MqttPublisher : IHostedService`). +- Single persistent connection with `WithReconnectDelay` and `WithCleanSession(false)`. Connection state surfaced to `/health`. +- Publish: `topic = {BaseTopic}/{normalized-mac}`, payload bytes `"update"`, QoS 1, not retained. +- **Failure handling**: `MqttPublisher.PublishAsync` returns a `Result` (success/failure). If failure, the publisher enqueues the (topic, payload) into an in-memory `Channel` and a background loop drains it with the configured backoff sequence. The upload response includes `mqttPublished: false` so callers know. + +--- + +## Concurrency + +`FrameLockProvider` exposes `Task AcquireAsync(FrameName frame, CancellationToken ct)` and hands out a per-frame `SemaphoreSlim(1,1)` keyed by `FrameName`. `FramesController` wraps the whole pipeline (download → process → save → publish-attempt) in the lock for that frame so the latest-written PNG always corresponds to the latest publish attempt. + +--- + +## Tests + +`tests/FrameProcessor.Tests` (xUnit + ImageSharp): + +- **`ImagePipelineTests`** — golden-image fixtures. Check in 3-4 small test inputs (`landscape.jpg`, `portrait.jpg`, `tiny-1x1.png`, `non-divisible.jpg`) and expected outputs for one canonical Spectra 6 palette. Assert byte-equality of the indexed PNG output. ImageSharp dither output is deterministic for a given input/palette/algorithm, so this is a strong correctness anchor. +- **`PaletteFactoryTests`** — hex parsing, count, error messages on bad hex. +- **`MacAddressTests`** — round-trip: `"AA:BB:CC:DD:EE:FF"`, `"aa-bb-cc-dd-ee-ff"`, `"AABBCCDDEEFF"`, and surrounding whitespace all parse to the same value; `ToString()` always emits the canonical lowercase-no-separator form; equality compares on the 6 bytes. +- **`FrameNameTests`** — accepts URL-safe names, rejects names with reserved or whitespace characters with a clear error. + +No controller/integration tests in v1 — pipeline correctness is the load-bearing part; HTTP plumbing is thin enough to verify manually with curl. + +--- + +## Verification (end-to-end) + +1. `docker compose up --build` — service comes up, connects to broker, `/health` returns `{ status: "Healthy", mqttConnected: true }`. +2. `curl -H "X-Api-Key: ..." -F "image=@photo.jpg" http://localhost:8080/api/frames/living-room/image` — returns 200 with a `url`. +3. Open `http://localhost:8080/i/aabbccddeeff.png` in a browser → confirm the dithered, color-remapped PNG looks reasonable visually. +4. Subscribe with `mosquitto_sub -t 'frames/#' -v` → confirm `frames/aabbccddeeff update` appears within ~1s of the upload. +5. Repeat with `/image-url` and a public image URL. +6. Concurrency check: fire two `curl` uploads to the same frame in parallel → both return 200, only one final PNG on disk, two MQTT publishes (in serial order). +7. Stop the broker, upload an image → response is 200 with `mqttPublished: false`, PNG saved, log shows retry attempts. Start the broker → background retry publishes successfully. +8. Run `dotnet test` → golden-image fixtures pass. +9. Power on the actual Spectra 6 frame mounted with the new firmware → it fetches the PNG and renders it correctly with the expected color mapping. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..e2dae8e --- /dev/null +++ b/SPEC.md @@ -0,0 +1,256 @@ +# Frame Processor — Specification + +## 1. Purpose + +Frame Processor is a self-hosted HTTP service that converts arbitrary images into PNGs suitable for color e-ink frames (e.g., Spectra 6). It performs dithering against the frame's actually-displayed color palette, remaps the result to the device's input-color encoding, stores the output, and notifies the frame via MQTT to fetch it. + +The service supports multiple frames, each with its own MAC address, resolution, orientation, and color palette. + +## 2. Concepts + +| Term | Definition | +|-----------------|---------------------------------------------------------------------------------------------------------| +| **Frame** | A physical e-ink device, identified in the service by a URL-friendly `name` and uniquely by its `mac`. | +| **Palette** | The set of colors a frame can render. Each entry has a `color` (what the device displays for that swatch) and a `deviceColor` (the RGB value the device expects in the input PNG to produce that swatch). | +| **Display color** | The hex color visible on the e-ink panel — what the human eye sees. | +| **Device color** | The hex color the firmware expects in the input PNG to render the corresponding display color. | +| **Update** | The MQTT signal published after a new image is ready, prompting the frame to fetch it. | + +## 3. Functional requirements + +### 3.1 Image ingestion + +The service accepts an image for a named frame in one of two forms: + +- **Direct file upload** as `multipart/form-data` (`image/jpeg`, `image/png`, or any format ImageSharp can decode). +- **URL reference** as a JSON body `{ "url": "https://..." }`; the service fetches it over HTTPS or HTTP. + +Both forms produce identical downstream behavior. + +### 3.2 Processing pipeline + +For each accepted image, the service performs the following stages in order: + +1. **Decode** the input into an in-memory RGBA bitmap. +2. **Resize** to the frame's resolution using `cover` semantics — scale the input so it fully covers the target rectangle, then center-crop overflow. Aspect ratio is preserved; no stretching. +3. **Dither** the bitmap against a palette built from the frame's `color` (display) values, using the dithering algorithm configured for that frame. Output of this stage contains only colors from the display palette. +4. **Remap** each pixel from its display color to its corresponding `deviceColor` per the palette. Output of this stage contains only colors from the device-color palette. +5. **Rotate** the bitmap 90° clockwise if the frame's orientation is `portrait`. The output dimensions always match the frame's declared resolution (e.g., 1600×1200) regardless of orientation. +6. **Encode** as an **indexed PNG** with a palette of the frame's device colors, using the smallest bit depth that fits the palette size. +7. **Persist** the resulting PNG to storage, replacing any previous image for the same frame **atomically** (no half-written file is ever observable via the HTTP fetch endpoint). +8. **Notify** by publishing an MQTT message announcing that a new image is available. + +### 3.3 Output delivery + +The service exposes the most recent processed PNG for each frame at a stable URL keyed by the frame's MAC address. The frame is expected to fetch this URL on receipt of an MQTT notification. + +### 3.4 Concurrency + +For any single frame, ingestion requests are serialized: if a second request arrives while the first is still processing, it waits for the first to complete before starting. Requests against *different* frames may proceed concurrently. The last successfully-processed image is the one served from the fetch URL. + +### 3.5 MQTT resiliency + +If MQTT publish fails (broker unreachable, network error), the image is **still saved** and the upload request still returns successfully (with a flag indicating MQTT failed). The service retries the publish in the background using an exponential backoff schedule until it succeeds. This ensures one transient broker outage does not lose the image. + +## 4. HTTP API + +All API responses are JSON unless otherwise noted. All `/api/*` endpoints require an `X-Api-Key` header matching the configured value; otherwise `401 Unauthorized`. + +### 4.1 `POST /api/frames/{name}/image` + +Upload an image file for a named frame. + +- **Path:** `{name}` — the URL-friendly frame identifier from configuration. +- **Body:** `multipart/form-data` with a single file part named `image`. +- **Success (`200 OK`):** + ```json + { + "frame": "living-room", + "mac": "aabbccddeeff", + "url": "/i/aabbccddeeff.png", + "processedAt": "2026-06-07T10:15:30Z", + "mqttPublished": true + } + ``` +- **Errors:** + - `404 Not Found` — unknown frame name. + - `400 Bad Request` — missing/invalid file part, unsupported image format. + - `413 Payload Too Large` — file exceeds configured maximum. + +### 4.2 `POST /api/frames/{name}/image-url` + +Process an image fetched from a URL. + +- **Path:** `{name}` — the URL-friendly frame identifier. +- **Body:** `application/json` + ```json + { "url": "https://example.com/photo.jpg" } + ``` +- **Success:** identical to §4.1. +- **Errors:** as §4.1, plus: + - `502 Bad Gateway` — remote URL did not return a usable image (timeout, non-2xx, too large, redirect loop). + +### 4.3 `GET /i/{mac}.png` + +Fetch the most recent processed PNG for a frame. + +- **Path:** `{mac}` — the frame's MAC address; normalized server-side (lowercase, separators stripped). Match is performed against normalized configured MACs. +- **Auth:** none. +- **Success (`200 OK`):** + - `Content-Type: image/png` + - `Cache-Control: no-store` + - `ETag` derived from file mtime +- **Errors:** + - `404 Not Found` — no image has been processed yet for this frame, or unknown MAC. + +### 4.4 `GET /health` + +- **Auth:** none. +- **Success (`200 OK`):** + ```json + { "status": "Healthy", "mqttConnected": true } + ``` + +## 5. MQTT contract + +- **Broker:** configured in `appsettings.json` (host, port, credentials, TLS). +- **Client:** persistent connection with automatic reconnect; clean session = false. +- **Publish:** + - **Topic:** `{baseTopic}/{normalized-mac}` — `baseTopic` is configurable (default `frames`); `normalized-mac` is lowercase hex with no separators (e.g., `aabbccddeeff`). + - **Payload:** the UTF-8 bytes of the literal string `update`. + - **QoS:** 1 (at-least-once). + - **Retained:** false. + +The service is a publisher only. It does not subscribe to any topics. + +### 5.1 Background retry + +If a publish attempt fails, the (topic, payload) is enqueued for retry. Retries occur on a configurable backoff schedule (default `[1, 2, 5, 15, 60]` seconds). A successful publish drains the corresponding queue entry. Multiple queued publishes for the same frame collapse to the most recent one. + +## 6. Configuration + +### 6.1 Application configuration — `appsettings.json` + +```jsonc +{ + "Mqtt": { + "Host": "mosquitto", + "Port": 1883, + "ClientId": "frame-processor", + "Username": null, + "Password": null, + "UseTls": false, + "BaseTopic": "frames", + "PublishQos": 1, + "RetryBackoffSeconds": [1, 2, 5, 15, 60] + }, + "ApiKey": "change-me", + "Storage": { + "ImageDirectory": "/data/images" + }, + "UrlFetch": { + "MaxBytes": 52428800, + "TimeoutSeconds": 30, + "MaxRedirects": 3 + } +} +``` + +All values overridable by environment variables using ASP.NET's `__` separator (e.g., `Mqtt__Host`). + +### 6.2 Frame configuration — `frames.json` + +```jsonc +{ + "Frames": [ + { + "name": "living-room", + "mac": "AA:BB:CC:DD:EE:FF", + "resolution": { "width": 1600, "height": 1200 }, + "orientation": "landscape", + "dithering": "floyd-steinberg", + "palette": [ + { "name": "black", "color": "#1F2226", "deviceColor": "#000000" }, + { "name": "white", "color": "#B9C7C9", "deviceColor": "#FFFFFF" }, + { "name": "blue", "color": "#233F8E", "deviceColor": "#0000FF" }, + { "name": "green", "color": "#35563A", "deviceColor": "#00FF00" }, + { "name": "red", "color": "#62201E", "deviceColor": "#FF0000" }, + { "name": "yellow", "color": "#C1BB1E", "deviceColor": "#FFFF00" } + ] + } + ] +} +``` + +**Field semantics:** + +| Field | Type | Constraints | +|-----------------|---------|----------------------------------------------------------------------------| +| `name` | string | Required. URL-friendly (RFC 3986 unreserved characters). Unique. Bound internally to a `FrameName` value type. | +| `mac` | string | Required. Accepts colon-, hyphen-, or unseparated hex; case-insensitive. Bound internally to a `MacAddress` value type whose canonical form is lowercase hex with no separators (used for URL matching, MQTT topics, and file paths). | +| `resolution.width`, `.height` | int | Required. Positive. The device's *native* resolution. | +| `orientation` | enum | Required. `landscape` or `portrait`. Bound to an `Orientation` enum. | +| `dithering` | enum | Required. One of `floyd-steinberg`, `atkinson`, `stucki`, `jarvis`. Bound to a `DitherAlgorithm` enum. | +| `palette` | array | Required. ≥2 entries. Each entry has `name`, `color`, `deviceColor`. | +| `palette[].color` / `.deviceColor` | string | Required. Hex `#RRGGBB`. Parsed once into ImageSharp `Color` values. | + +`frames.json` is **hot-reloaded** — changes apply without restart. A reload that contains an invalid frame logs a warning and skips that frame; valid frames continue to function. (Initial startup, by contrast, fails fast on any invalid frame.) + +## 7. Storage + +### 7.1 Location + +PNGs are written to `{Storage.ImageDirectory}/{normalized-mac}.png`. The directory is created on startup if absent. + +### 7.2 Atomicity + +Writes occur via a temp file (`{normalized-mac}.png.tmp`) followed by an atomic rename. The fetch endpoint never returns a partially-written file. + +### 7.3 Retention + +Latest-only: each new write replaces the previous PNG for that frame. No history is kept. + +## 8. URL-fetch guardrails + +When fetching an image from a remote URL (§4.2), the service enforces: + +- **Maximum response size:** `UrlFetch.MaxBytes` (default 50 MB). Exceeding aborts the download and returns `502`. +- **Request timeout:** `UrlFetch.TimeoutSeconds` (default 30 s). +- **Maximum redirect hops:** `UrlFetch.MaxRedirects` (default 3). + +The service does **not** restrict by Content-Type or destination network — it trusts that callers will not deliberately point at malicious or internal URLs. Decoding failures from non-image responses naturally surface as `400` errors. + +## 9. Security model + +The service is intended for **trusted-network (LAN) deployment**. + +- All `POST /api/*` endpoints require a shared `X-Api-Key` header. The key is configured in `appsettings.json` (or via environment variable). +- `GET /i/{mac}.png` is **unauthenticated**. The MAC address in the URL is treated as a sufficiently unguessable identifier for hobby-scale use. +- `GET /health` is unauthenticated. +- The service does **not** implement HTTPS itself; deploy behind a reverse proxy if TLS is needed. + +## 10. Observability + +- **Logging:** structured logging via Serilog with console + rolling file sinks. Each upload logs the frame name, input source (file/url), input size, output size, total processing time, and MQTT publish status (success/queued-for-retry). +- **Metrics:** none in v1. +- **Tracing:** none in v1. + +## 11. Non-functional requirements + +| Property | Target | +|-----------------------|--------------------------------------------------------------| +| Latency (1600×1200) | < 1 s end-to-end (upload → MQTT publish), excluding URL fetch| +| Concurrency | ≥ 1 in-flight request per frame, unbounded across frames | +| Memory | Bounded by max input file size (50 MB) × concurrent requests | +| Restart behavior | Persisted PNGs survive restart; MQTT retry queue is in-memory and resets | +| Platform | .NET 10 on Linux/x64 and Linux/ARM64 (Docker) | + +## 12. Out of scope (v1) + +- Multi-user authentication / user accounts. +- Per-frame image history or rollback. +- Image scheduling / playlists. +- Frame status reporting back to the service (battery, last-seen, etc.). +- A web UI. +- HTTPS termination inside the service. +- Internet-facing deployment hardening (SSRF protection, rate limiting).