commit 6ad76b98a2d7866f74fd6578b5e92b26b6c948e1 Author: Gabriel Kaszewski Date: Thu Jun 18 18:05:19 2026 +0200 add domain glossary, design spec, and ADRs CONTEXT.md: domain model with entities (WidgetConfig, DataSource, LayoutPreset), value objects (Layout, LayoutNode, KeyMapping, WidgetState, Sizing), architecture decisions (CQRS, static dispatch, postcard), client domain model, and design constraints. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..9881573 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,68 @@ +# K-Frame — Domain Glossary + +> Canonical language of the K-Frame domain. No implementation details — just terms and their meanings. + +## Entities + +Entities have identity and a lifecycle. They are created, mutated, and deleted. + +- **WidgetConfig** — the persistent facet of a Widget. Has an ID (`u16`), a DisplayHint, a DataSource binding, and a set of KeyMappings. Write model — mutated via web UI commands. + +- **DataSource** — a configured external data feed. Has an ID, type (weather, media, rss, etc.), connection config, and poll interval. Polls once, produces a raw response. Multiple Widgets can extract data from the same DataSource — the mapping lives in each WidgetConfig, not in the DataSource itself. + +- **LayoutPreset** — a named, saved layout tree stored in config. Has an ID and a name. Created, listed, renamed, deleted from the web UI. Loading a preset replaces the active Layout. + +## Value Objects + +Value objects have no identity. They are defined entirely by their content and are immutable. + +- **Layout** — the single active layout tree. A recursive structure of LayoutNodes. Always singular — clients display exactly one layout at a time. Replaced wholesale when user reconfigures via the web UI. + +- **LayoutNode** — a node in the layout tree. Either a Container (row or column with ordered children) or a Leaf (references a Widget by ID). Each child in a Container has a sizing mode: Fixed(pixels) or Flex(weight). Layout engine sums fixed sizes, distributes remaining space among flex children by integer weight ratio. All integer math, no floats. Containers have an optional `gap: u8` (uniform spacing between children in pixels) and an optional `padding: u8` (uniform inset on all sides, typically used on root container to keep content off screen edges). + +- **KeyMapping** — a rule inside WidgetConfig that extracts a value from a DataSource's raw response using a JSON path expression and maps it to a named key in the WidgetState. E.g. `"$.main.temp" → "value"`. Decouples widget data shape from API response shape. Keeps adapters dumb — they return raw responses, WidgetConfig defines extraction. + +- **WidgetState** — the transient read-model facet of a Widget. A `BTreeMap` — values can be nested (arrays, objects). Projected from DataSource poll results via KeyMappings. Held in memory, never persisted. Client handles nested data for things like RSS headline lists. + +- **Sizing** — how a child occupies space within a Container. Either `Fixed(u16)` (exact pixels) or `Flex(u8)` (weight-based proportional share of remaining space). + +## Architecture + +- **CQRS** — full event-driven command/query separation. Commands mutate config (write model) and emit domain events. Read model (WidgetStates, active Layout) is projected from events and DataSource poll results, held in memory. Domain events drive side effects: restarting poll loops, pushing updates to clients. + +- **Heartbeat** — bidirectional keepalive. Client sends heartbeats so server can detect silent disconnects (e.g. ESP32 powered off). Server sends heartbeats so client can detect server death and show offline indicator. Each side sets a timeout — no heartbeat within N seconds → peer considered dead. Individual client adapters can disable server→client heartbeats if too resource-heavy. + +- **Domain Event** — a fact emitted by the write model when config changes. E.g. WidgetCreated, WidgetUpdated, DataSourceAdded, DataSourceRemoved, LayoutChanged, LayoutPresetLoaded. Events are the bridge between write and read sides. + +## Client Domain + +The client has its own thin domain — hexagonal, chip-agnostic, display-agnostic. No persistence, no CQRS, no events. Pure layout computation and rendering rules. + +- **RenderTree** — the client's "virtual display." A computed tree of positioned, sized nodes (BoundingBoxes + WidgetViews), held in memory. When a DataUpdate arrives, only the affected WidgetView is diffed and repainted within its BoundingBox — no layout recomputation, no full screen redraw. Full recomputation only happens on LayoutUpdate. Inspired by React's virtual DOM: diff against previous state, apply minimal changes to the actual display. + +- **BoundingBox** — a rectangular region on screen (x, y, width, height) computed by the layout engine for each node. + +- **WidgetView** — the rendered projection of a WidgetState. Contains resolved display data (text strings, icon references) within a BoundingBox. The client domain decides overflow behavior, truncation, and marquee. + +- **Layout Engine** — pure domain logic. Given a LayoutNode tree and screen dimensions, computes BoundingBoxes for all nodes. Containers split space among children (row = horizontal, column = vertical). Leaves get their final bounds. + +## Shared Concepts + +- **DisplayHint** — a domain enum on WidgetConfig indicating how the client should render the widget. Closed set: IconValue, TextBlock, KeyValue, etc. Client handles each variant explicitly, with a fallback to plain text for unknown/unsupported hints. Gives type safety and validation on both sides while remaining forward-compatible. Lives in the protocol crate. + +- **Value** — domain-owned value type representing structured data. An enum: String, Number, Bool, Array, Object, Null. Adapters convert their native formats (JSON, XML, etc.) into Value. KeyMapping and WidgetState operate on Value exclusively. Zero coupling to serialization libraries. Lives in the protocol crate. + +- **WidgetError** — an optional error indicator on WidgetState. When a DataSource poll or KeyMapping extraction fails, the last known good data is preserved but an error flag is attached. Client decides how to surface it — stale data silently, warning badge, or error state. Server never suppresses information. + +## Design Constraints + +- **Static dispatch everywhere.** Port traits are used as generic bounds, not trait objects. No `Box`, no dynamic dispatch. Composition root resolves all concrete types. Applies to both server and client. Verbose but zero-cost — critical on ESP32, consistent on server. + +- **Bounded data sizes.** Three tiers of protection against memory exhaustion: + 1. *Per-widget soft limit* — WidgetConfig has a `max_data_size: u16` (bytes, default 2048). Server truncates at extraction time. Configurable per widget for text-heavy use cases (chyron, RSS). + 2. *Frame-level hard limit* — transport layer rejects frames exceeding a fixed cap (e.g. 32KB). Safety net for the wire. + 3. *Client memory budget* — fixed total memory pool for all WidgetStates combined. Compile-time constant in firmware. If accepting a DataUpdate would exceed budget, client drops oldest/lowest-priority data. + +- **Widget IDs are `u16`.** Compact on the wire (2 bytes), fast comparison. WidgetConfig can have a human-readable name for the web UI, but the protocol and client use the integer ID exclusively. + +- **`postcard` + `serde` for wire format.** Protocol crate uses postcard for binary serialization — compact varint encoding, `no_std` compatible, no codegen toolchain. Types are normal Rust structs with `#[derive(Serialize, Deserialize)]`. Replaces FlatBuffers. Protocol crate is `no_std` for maximum portability. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1356cd7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +members = [] +resolver = "2" + +[workspace.dependencies] diff --git a/docs/adr/0001-event-driven-cqrs.md b/docs/adr/0001-event-driven-cqrs.md new file mode 100644 index 0000000..fe3f89d --- /dev/null +++ b/docs/adr/0001-event-driven-cqrs.md @@ -0,0 +1,23 @@ +# 0001 — Event-driven CQRS + +**Status:** accepted +**Date:** 2026-06-18 + +## Context + +K-Frame has a natural write/read split: the write model is config (widgets, data sources, layout) mutated via web UI, the read model is runtime state (current widget data, active layout) pushed to display clients. We need a mechanism to bridge the two — when config changes, runtime behavior must update (restart polling loops, push new layout to clients). + +## Decision + +Full event-driven CQRS. Commands mutate config and emit domain events. The read side projects current state from events and DataSource poll results. Events drive side effects: restarting poll loops, pushing updates to connected clients. + +## Alternatives considered + +**Imperative approach** — commands mutate config, then application layer imperatively calls "restart poll loop" / "push to clients." Simpler, but tangles command handling with side-effect orchestration. Adding new reactions to config changes requires modifying command handlers. + +## Consequences + +- Clean separation: command handlers only mutate config and emit events, never trigger side effects directly. +- New reactions to config changes are just new event listeners — no command handler modification. +- More moving parts than the imperative approach — event bus, event handlers, projections. +- Events are in-memory only (no event store / event sourcing). This is CQRS, not ES. diff --git a/docs/adr/0002-static-dispatch-over-trait-objects.md b/docs/adr/0002-static-dispatch-over-trait-objects.md new file mode 100644 index 0000000..88ada8a --- /dev/null +++ b/docs/adr/0002-static-dispatch-over-trait-objects.md @@ -0,0 +1,23 @@ +# 0002 — Static dispatch over trait objects + +**Status:** accepted +**Date:** 2026-06-18 + +## Context + +Hexagonal architecture uses port traits extensively. Rust offers two ways to consume them: trait objects (`Box`, dynamic dispatch) or generics (`T: Port`, static dispatch / monomorphization). + +## Decision + +Use generics for all port traits. No `Box` anywhere. Composition roots (bootstrap on server, firmware crate on client) are the only places that know concrete types. Generic parameters propagate through application and presentation layers. + +## Alternatives considered + +**Trait objects** — simpler type signatures, less generic noise. But adds vtable indirection, heap allocation per trait object, and prevents compiler optimizations. On ESP32 with limited RAM, every allocation matters. + +## Consequences + +- Zero-cost abstractions — port calls are monomorphized, inlined where possible. +- More verbose type signatures — structs and functions carry generic parameters. +- Compile times may increase due to monomorphization. +- Consistent approach across server and client codebases. diff --git a/docs/adr/0003-postcard-over-flatbuffers.md b/docs/adr/0003-postcard-over-flatbuffers.md new file mode 100644 index 0000000..f6677d4 --- /dev/null +++ b/docs/adr/0003-postcard-over-flatbuffers.md @@ -0,0 +1,26 @@ +# 0003 — postcard over FlatBuffers + +**Status:** accepted +**Date:** 2026-06-18 + +## Context + +Need a binary serialization format for the TCP protocol between server and ESP32 clients. Must be compact (bandwidth-constrained), ideally `no_std` compatible for future bare-metal targets. + +## Decision + +Use `postcard` with `serde` derives instead of FlatBuffers. + +## Alternatives considered + +**FlatBuffers** — zero-copy reads, schema-driven. But requires external codegen toolchain (flatc), `no_std` support is limited, and zero-copy is overkill for our small messages (a handful of widgets). + +**Custom binary protocol** — maximum control, zero deps. But significant implementation effort for serialize/deserialize code, error-prone, and postcard already solves this. + +## Consequences + +- `no_std` protocol crate out of the box — future-proof for bare-metal targets. +- No codegen step — types are normal Rust structs with serde derives. +- Compact wire format via varint encoding. +- No zero-copy reads — messages are deserialized into owned types. Acceptable for our message sizes. +- Adds `serde` and `postcard` as dependencies to the protocol crate. diff --git a/docs/superpowers/specs/2026-06-18-k-frame-design.md b/docs/superpowers/specs/2026-06-18-k-frame-design.md new file mode 100644 index 0000000..4abba4a --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-k-frame-design.md @@ -0,0 +1,173 @@ +# K-Frame — Design Spec + +IoT dashboard system. Server aggregates data from configurable sources, pushes to connected display clients via FlatBuffers/TCP. Clients own all rendering. + +## Hardware + +- **Server**: Intel N100 homeserver +- **Client**: LOLIN Mini Kit ESP32 (ESP-WROOM-32) + 2.4" ILI9341 LCD (240×320, SPI) + +## Domain Model + +- **Widget** — generic display primitive. Has an ID, type hint (text, icon+text, etc.), and key-value data payload. Purely semantic, no visual info. +- **LayoutNode** — recursive tree. Either a Container (row/column with children) or a Leaf (references widget by ID). +- **Screen** — named layout tree + its widget set. The unit sent to clients. +- **DataSource** — configured external data feed. Has a type, config (URL, API key, JSON path, etc.), poll interval. Maps output to one or more widget IDs. +- **Client** — connected consumer (ESP32, future KDE Plasma widget, etc.). Receives screen updates. + +## Server Architecture + +Hexagonal (ports & adapters) with DDD. Same patterns as thoughts/ and movies-diary/ projects. + +### Crate structure + +``` +crates/ + domain/ — models, value objects, port traits. Zero deps. + application/ — use cases: manage config, poll data sources, push to clients + api-types/ — DTOs for web UI API + presentation/ — Axum HTTP (config web UI) + TCP listener (client connections) + bootstrap/ — composition root, wires adapters into ports, starts everything + adapters/ + config-store/ — ConfigRepository impl (TOML or SQLite, behind the port) + weather/ — weather API adapter + media/ — Navidrome/Subsonic adapter + xtb/ — XTB portfolio adapter + rss/ — RSS feed adapter + http-json/ — generic HTTP + JSON path extraction adapter + webhook/ — webhook receiver (incoming HTTP → widget data) +``` + +A shared `protocol` crate lives at workspace root level, used by both server and client: + +``` +crates/ + protocol/ — FlatBuffers .fbs schema + generated Rust code, no_std compatible +``` + +### Dependency rule + +``` +bootstrap ──► presentation ──► application ──► domain ◄── adapters +``` + +- **domain** — zero framework deps, pure business logic, defines all port traits +- **application** — orchestrates use cases, depends only on domain +- **presentation** — HTTP handlers (Axum) + TCP server, depends on domain + application +- **adapters** — implement domain ports, depend inward on domain only +- **bootstrap** — composition root, wires adapters into ports + +### Domain ports + +- **ConfigRepository** — CRUD for screens, widgets, data sources, layouts +- **DataSourcePort** — trait each data source adapter implements: `async fn poll(&self, config) -> WidgetData` +- **ClientBroadcaster** — push layout/data updates to connected clients +- **DataSourceRegistry** — manages polling loops per data source, detects changes, only pushes diffs + +### Data flow + +1. Bootstrap starts polling loops per configured DataSource (each with its own configurable interval) +2. Adapter polls external API → returns new widget data +3. Application compares with previous value → if changed, pushes DataUpdate to all clients via ClientBroadcaster +4. User changes layout in web UI → Application pushes LayoutUpdate to all clients +5. User changes config → Application restarts/adds/removes polling loops as needed + +## Protocol + +FlatBuffers over TCP. Length-prefixed framing: 4 bytes (u32 big-endian) message length, then FlatBuffers payload. + +### Server → Client + +- **ScreenUpdate** — full layout tree + all widget data. Sent on initial connection and when user changes layout. +- **DataUpdate** — one or more (widget ID + new data) pairs. Sent only when polled data actually changes. Only changed widgets included. + +### Client → Server + +- **Heartbeat** — keepalive so server can detect dead clients. + +### Widget data format + +Generic key-value map in FlatBuffers. E.g. weather widget: `{"icon": "cloud_rain", "temperature": "5.4", "unit": "°C"}`. Client interprets keys however it wants. Server doesn't prescribe rendering. + +### Bandwidth + +- Only changed widgets sent (diff against previous state) +- FlatBuffers is zero-copy and compact +- Layout tree not resent unless it actually changes +- Heartbeat is minimal + +### Extensibility + +Protocol should be designed so auth handshake can be added later without breaking existing message types. + +## Client (ESP32 Firmware) + +Rust firmware using `esp-idf-hal` / `esp-idf-svc`. + +### Boot sequence + +1. Show K logo splash (baked into firmware as RGB565 bitmap) +2. Connect WiFi (credentials in firmware config / NVS) +3. Connect TCP to server +4. Receive ScreenUpdate → render layout +5. Listen for DataUpdates, re-render changed widgets only + +### Rendering + +- `embedded-graphics` for drawing primitives (text, rectangles, bitmaps) +- Custom layout engine: walks LayoutNode tree, computes bounding boxes, assigns positions +- Container nodes split available space among children (row = horizontal, column = vertical) +- Leaf nodes render their widget using generic key-value data +- Client decides: fonts, sizes, overflow behavior, marquee, truncation, padding +- Small icon set baked into firmware as bitmaps + +### Composable layouts + +User composes layout tree freely. If it doesn't fit or renders poorly, user's responsibility to fix. Server may provide hints/warnings but does not enforce. + +### Marquee + +If text overflows its computed bounding box, client auto-scrolls horizontally. Smooth pixel-by-pixel animation. + +### Offline behavior + +- Keep last received data in memory, keep rendering +- Show small indicator (dot/icon in corner) when TCP connection lost +- Reconnect loop with backoff + +### Display-agnostic server + +Server sends semantic data only. All rendering decisions (overflow, marquee, truncation, font, color, layout computation) are client-side. Future clients (KDE Plasma widget, web dashboard, etc.) interpret the same protocol differently. + +## Web UI (Config) + +Served by same binary via Axum. Simple HTML (static with forms or lightweight SPA). + +### Features + +- **Manage data sources** — add/edit/remove. Configure type, URL, API keys, poll interval, JSON path. +- **Manage widgets** — create widgets, assign to data source, define key mappings. +- **Compose layouts** — build layout tree: add containers (row/column), nest them, place widgets as leaves. +- **Push changes** — saving config triggers immediate LayoutUpdate to all connected clients. +- **View connected clients** — see which clients are online (nice-to-have). + +No auth for now. Presentation crate can add middleware later. + +## Initial Data Source Adapters + +| Adapter | Direction | Description | +|------------|-----------|--------------------------------------------------| +| weather | poll | OpenWeather or similar, configurable interval | +| media | poll | Navidrome/Subsonic API (now playing, etc.) | +| xtb | poll/ws | XTB portfolio value | +| rss | poll | RSS feed, configurable interval | +| http-json | poll | Generic: poll any URL, extract via JSON path | +| webhook | push | Server exposes endpoint, external services POST | + +## Multi-client Support + +Multiple clients (ESP32 devices, future Plasma widgets) connect to same server. All receive same layout tree and data updates via broadcast. + +## Config Storage + +Abstracted behind ConfigRepository port. Initial adapter can be TOML file or SQLite — implementation detail hidden from domain. Web UI reads/writes through the same port.