display_hint becomes {kind, h_align, v_align} object in API, SQLite
gets alignment columns, SPA widget form gets alignment selects, layout
preview reflects actual alignment instead of hardcoded center
K-Frame
IoT dashboard system. A server aggregates data from external sources (weather APIs, media players, RSS feeds, webhooks) and pushes rendered updates to ESP32 display clients over TCP.
Server polls data sources, projects values through configurable widget mappings, computes layouts, and broadcasts binary frames to connected clients. Configuration via REST API + web UI.
Clients receive structured data + layout trees, compute bounding boxes, format text per display hint rules, and render to hardware. The client domain is chip-agnostic — the same rendering engine runs on ESP32 (ILI9341 TFT) and desktop (terminal).
Web UI (SPA) configures data sources, widgets, layouts, themes, and presets. Includes a live layout preview that mirrors what the physical display shows.
Architecture
Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework dependencies. Static dispatch everywhere (no Box<dyn>, no trait objects).
┌─────────────────── Server ───────────────────┐
│ domain/ entities, value objects, │
│ ports (traits) │
│ application/ use cases, data projection │
│ bootstrap/ composition root, polling │
│ │
│ adapters/ │
│ config-sqlite persistence │
│ http-api REST API (Axum) │
│ tcp-server binary protocol broadcast │
│ http-json external API polling │
│ media, rss source-specific adapters │
│ auth argon2 + JWT │
├─────────────────── Shared ───────────────────┤
│ protocol/ wire types, postcard serde │
│ api-types/ REST DTOs │
├─────────────────── Client ───────────────────┤
│ client-domain/ layout engine, render engine │
│ markup parser, scroll state │
│ client-application/ message handling │
│ client-esp32/ ESP32 firmware (ILI9341) │
│ client-desktop/ terminal debug client │
├──────────────────── SPA ─────────────────────┤
│ spa/ React + TanStack Router │
│ shadcn/ui + Tailwind │
└──────────────────────────────────────────────┘
Key design decisions
- postcard + serde for the wire protocol — compact varint encoding,
no_stdcompatible, no codegen - Static dispatch — port traits used as generic bounds, not trait objects. Composition root resolves all types
- Event-driven CQRS — config mutations emit domain events, read model projected in memory
- Domain-owned rendering — client-domain owns text wrapping, alignment, color markup, scroll. DisplayPort is a thin pixel-pusher (
draw_text_span,fill_rect,flush) - WiFi provisioning — ESP32 boots into AP captive portal if no config in NVS, auto-falls back on WiFi failure
See docs/adr/ for architectural decision records and CONTEXT.md for the domain glossary.
Features
- Data sources: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks
- Layout engine: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
- Theming: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
- Rich text: inline color markup (
{primary}text{/},{#FF0000}hex{/}) - Overflow scroll: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
- Captive portal: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
- Auth: argon2 password hashing, JWT tokens, protected API routes
Prerequisites
- Rust (stable) — server, desktop client, shared crates
- Rust ESP32 toolchain —
espup, xtensa target. See esp-rs book - Node.js / Bun — SPA development
- SQLite — server persistence (created automatically)
Quick start
# 1. Clone and configure
cp .env.example .env
# Edit .env — set JWT_SECRET and KFRAME_ENCRYPTION_KEY
# 2. Start the server
make server
# 3. Start the SPA dev server (separate terminal)
cd spa && bun install && bun run dev
# Open http://localhost:5173
# 4. Register an account (first launch = setup mode)
# 5. Add a data source, create widgets, build a layout
ESP32 client
# Build firmware
make esp-build
# Flash (default port: /dev/ttyACM0)
make esp-flash
# Flash with custom port
make esp-flash ESP_PORT=/dev/ttyUSB0
# Flash and monitor serial output
make esp-run
# On first boot: connect to "KFrame-Setup" WiFi, enter credentials in captive portal
Desktop client
make desktop
# Connects to localhost:2699, prints render commands to terminal
Development
# Full check suite (fmt + clippy + test)
make check
# Auto-fix formatting and clippy warnings
make fix
# Run tests only
make test
# SPA type checking
cd spa && bun run typecheck
Project structure
| Path | Description |
|---|---|
crates/domain/ |
Entities, value objects, port traits |
crates/application/ |
Use cases (ConfigService, DataProjection) |
crates/protocol/ |
Wire types, encode/decode (no_std) |
crates/bootstrap/ |
Server composition root |
crates/adapters/ |
All port implementations |
crates/client-domain/ |
Display-agnostic rendering engine |
crates/client-application/ |
Client message handling |
crates/client-esp32/ |
ESP32 firmware |
crates/client-desktop/ |
Terminal debug client |
crates/api-types/ |
REST API DTOs |
spa/ |
React SPA |
docs/adr/ |
Architecture decision records |
CONTEXT.md |
Domain glossary |
Contributing
- Fork the repo
- Create a feature branch
- Run
make checkbefore pushing — CI runs the same checks - Open a PR with a clear description of what changed and why
The domain glossary in CONTEXT.md defines the canonical language. Use it in code, commits, and PRs. If you're adding a new concept, update the glossary.
Architecture decisions are documented in docs/adr/. If your change involves a hard-to-reverse design choice, write an ADR.