dreamstack/docs/panel-ir-spec.md

235 lines
7.2 KiB
Markdown
Raw Normal View History

# DreamStack Panel IR — Dynamic UI Streaming to ESP32
Stream DreamStack apps to ESP32-P4 touchscreen panels as a compact **Panel IR** over WebSocket. The on-device LVGL runtime renders the UI locally. Touch is instant. New UIs arrive dynamically — no reflashing.
## Architecture
```
HUB (Pi / laptop) ESP32-P4 PANEL (Waveshare 10.1")
────────────────── ─────────────────────────────────
DreamStack compiler Panel Runtime (~500 lines C)
ds compile app.ds --target panel LVGL 9 graphics library
↓ ↓
ir_emitter.rs → Panel IR (JSON) ──→ WiFi 6 ──→ Parse IR → create widgets
Bind signal reactivity
Handle touch locally (< 5ms)
Signal update: { "t":"sig", "s":{"0":75} } ──→ Update bound labels instantly
Touch event: { "t":"evt", "n":3, "e":"click" } ←── LVGL callback fires
New screen: Full IR blob ──→ Destroy old UI, build new
```
## Why not pixel streaming?
| | Pixel streaming | Panel IR (this spec) |
|---|---|---|
| Touch latency | 30-50ms (network round-trip) | **< 5ms (local)** |
| WiFi disconnect | Screen freezes | **App stays interactive** |
| Bandwidth | 20-50 Kbps continuous | ~100 bytes/event |
| Dynamic UIs | ✅ | ✅ |
| Hub CPU load | Renders for all panels | **Near zero** |
| Offline | ❌ | ✅ (for local signals) |
## Message Protocol
Three message types over WebSocket (JSON):
### 1. `ui` — Full UI tree
Sent when app loads, screen changes, or hub pushes a new app.
```json
{
"t": "ui",
"signals": [
{ "id": 0, "v": 72, "type": "int" },
{ "id": 1, "v": true, "type": "bool" },
{ "id": 2, "v": "Kitchen", "type": "str" }
],
"root": {
"t": "col", "gap": 10, "pad": 20,
"c": [
{ "t": "lbl", "id": 0, "text": "{2}: {0}°F", "size": 24 },
{ "t": "btn", "id": 1, "text": "Lights",
"on": { "click": { "op": "toggle", "s": 1 } } },
{ "t": "sld", "id": 2, "min": 60, "max": 90, "bind": 0 }
]
},
"derived": [
{ "id": 3, "expr": "s0 * 2", "deps": [0] }
]
}
```
### 2. `sig` — Signal update (hub → panel)
```json
{ "t": "sig", "s": { "0": 75 } }
```
### 3. `evt` — Event (panel → hub)
```json
{ "t": "evt", "n": 1, "e": "click" }
```
## IR Node Types
| IR type | DreamStack source | LVGL widget | Key props |
|---|---|---|---|
| `col` | `column [...]` | `lv_obj` + `LV_FLEX_FLOW_COLUMN` | gap, pad, bg |
| `row` | `row [...]` | `lv_obj` + `LV_FLEX_FLOW_ROW` | gap, pad |
| `stk` | `stack [...]` | `lv_obj` (absolute pos) | — |
| `lbl` | `text "..."` | `lv_label` | text, color, size |
| `btn` | `button "..." {}` | `lv_btn` + `lv_label` | text, on.click |
| `inp` | `input` | `lv_textarea` | placeholder, bind |
| `sld` | `slider` | `lv_slider` | min, max, bind |
| `img` | `image` | `lv_img` | src |
| `bar` | `progress` | `lv_bar` | min, max, bind |
| `sw` | `toggle` | `lv_switch` | bind |
| `lst` | `list [...]` | `lv_list` | — |
| `pnl` | `panel [...]` | `lv_obj` (styled card) | title, bg, radius |
## Signal Binding
Text fields use `{N}` for signal interpolation:
```
"text": "{2}: {0}°F" → "Kitchen: 72°F"
signal 2 signal 0
```
When signal 0 changes to 75, the runtime re-expands the template and calls `lv_label_set_text()`. Only affected labels update.
Sliders and inputs use `"bind": N` for two-way binding. When the user moves a slider, the runtime updates signal N locally AND sends a `sig` message to the hub.
## Event Actions
Simple operations execute locally on the ESP32 (instant). Complex logic forwards to the hub.
| Action | IR | Runs on |
|---|---|---|
| Set value | `{ "op": "set", "s": 0, "v": 5 }` | ESP32 (local) |
| Toggle bool | `{ "op": "toggle", "s": 1 }` | ESP32 (local) |
| Increment | `{ "op": "inc", "s": 0 }` | ESP32 (local) |
| Decrement | `{ "op": "dec", "s": 0 }` | ESP32 (local) |
| Navigate | `{ "op": "nav", "screen": "settings" }` | Hub → sends new IR |
| Remote call | `{ "op": "remote", "name": "fetch_weather" }` | Hub → result as sig |
## Styling
Each node can have optional style props:
```json
{
"t": "lbl", "id": 0, "text": "Hello",
"style": {
"bg": "#1a1a2e",
"fg": "#e0e0ff",
"size": 18,
"radius": 8,
"pad": 12,
"align": "center",
"font": "bold"
}
}
```
The runtime maps these to `lv_obj_set_style_*()` calls.
## Implementation Plan
### Phase 1: Compiler Backend
**New file:** `compiler/ds-codegen/src/ir_emitter.rs`
```rust
pub fn emit_panel_ir(program: &Program, graph: &SignalGraph) -> String {
// Walk AST → build IR JSON
// Map signal names → integer IDs
// Convert OnHandler → action opcodes
// Convert string interpolations → {N} references
}
```
**Modify:** `compiler/ds-cli/src/main.rs` — add `--target panel` flag
### Phase 2: ESP32 LVGL Runtime
**New files in** `devices/waveshare-p4-panel/main/`:
| File | Lines | Purpose |
|---|---|---|
| `ds_runtime.h` | ~60 | API: build, destroy, signal_update, event_send |
| `ds_runtime.c` | ~400 | JSON parser (cJSON), widget factory, signal table, text formatter, event dispatch |
Core functions:
```c
void ds_ui_build(const char *ir_json); // Parse IR, create LVGL tree
void ds_ui_destroy(void); // Tear down for screen change
void ds_signal_update(uint16_t id, const char *value); // Update + refresh
void ds_event_send(uint16_t node_id, const char *event); // Send to hub
```
### Phase 3: Hub Server
**New file:** `engine/ds-stream/src/panel_server.rs`
WebSocket server that:
1. Compiles `.ds` → IR on startup
2. Sends IR to connecting panels
3. Forwards signal diffs bidirectionally
4. Watches `.ds` file → recompile → push new IR (hot reload)
### Phase 4: Pixel fallback (already built)
The existing `ds_codec.c` + `ds_protocol.h` remain as a fallback for cases where pixel streaming IS needed (streaming non-DreamStack content, camera feeds, etc).
## Hardware
| Component | Role | Price |
|---|---|---|
| Waveshare ESP32-P4-WIFI6 10.1" | Panel display + runtime | ~$85 |
| Raspberry Pi 5 (or any Linux box) | Hub: compile + serve IR | ~$60 |
| **Total POC** | | **~$145** |
## Example: Complete Flow
### 1. Write the app
```
// kitchen.ds
let temperature = 72
let lights_on = true
view main {
text { "Kitchen: {temperature}°F" }
button "Lights" {
on click { lights_on = !lights_on }
}
slider { min: 60, max: 90, bind: temperature }
}
```
### 2. Compile to IR
```bash
ds compile kitchen.ds --target panel > kitchen.ir.json
```
### 3. Hub serves it
```bash
ds serve --panel kitchen.ds --port 9100
# Panel connects → receives IR → renders UI
# Tap button → lights_on toggles locally
# Move slider → temperature updates locally + syncs to hub
# Hub pushes weather API data → { "t":"sig", "s":{"0": 68} }
# Panel label updates: "Kitchen: 68°F"
```
### 4. Push a new app
```bash
ds serve --panel settings.ds --port 9100
# Hub sends new IR → panel destroys kitchen UI → builds settings UI
# No reflash. Instant.
```