235 lines
7.2 KiB
Markdown
235 lines
7.2 KiB
Markdown
|
|
# 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.
|
||
|
|
```
|