459 lines
14 KiB
C
459 lines
14 KiB
C
|
|
/**
|
||
|
|
* DreamStack Panel IR Runtime — Implementation
|
||
|
|
*
|
||
|
|
* Parses Panel IR JSON (using cJSON) and creates LVGL 9 widgets.
|
||
|
|
* This is the C port of the browser-based panel previewer logic.
|
||
|
|
*
|
||
|
|
* Key features:
|
||
|
|
* - Signal table with text template expansion ({0} → value)
|
||
|
|
* - Reactive updates: signal change → refresh bound labels
|
||
|
|
* - Event dispatch: button click → action opcode → ESP-NOW
|
||
|
|
* - Timer execution from IR timers[] array
|
||
|
|
*/
|
||
|
|
|
||
|
|
#include <string.h>
|
||
|
|
#include <stdio.h>
|
||
|
|
#include <stdlib.h>
|
||
|
|
#include "esp_log.h"
|
||
|
|
#include "cJSON.h"
|
||
|
|
#include "lvgl.h"
|
||
|
|
|
||
|
|
#include "ds_runtime.h"
|
||
|
|
|
||
|
|
static const char *TAG = "ds-runtime";
|
||
|
|
|
||
|
|
// ─── Signal Table ───
|
||
|
|
static ds_signal_t s_signals[DS_MAX_SIGNALS];
|
||
|
|
static uint16_t s_signal_count = 0;
|
||
|
|
|
||
|
|
// ─── Timer Table ───
|
||
|
|
static ds_timer_t s_timers[DS_MAX_TIMERS];
|
||
|
|
static uint8_t s_timer_count = 0;
|
||
|
|
|
||
|
|
// ─── Text Binding Table ───
|
||
|
|
// Maps LVGL label objects to their IR text templates
|
||
|
|
typedef struct {
|
||
|
|
lv_obj_t *label; // LVGL label widget
|
||
|
|
char template[128]; // Text template with {N} placeholders
|
||
|
|
bool used;
|
||
|
|
} ds_binding_t;
|
||
|
|
|
||
|
|
static ds_binding_t s_bindings[DS_MAX_BINDINGS];
|
||
|
|
static uint16_t s_binding_count = 0;
|
||
|
|
|
||
|
|
// ─── Parent and Callback ───
|
||
|
|
static lv_obj_t *s_parent = NULL;
|
||
|
|
static lv_obj_t *s_root = NULL;
|
||
|
|
static ds_action_cb_t s_action_cb = NULL;
|
||
|
|
|
||
|
|
// ─── Forward Declarations ───
|
||
|
|
static lv_obj_t *build_node(cJSON *node, lv_obj_t *parent);
|
||
|
|
static void expand_template(const char *tpl, char *out, size_t out_len);
|
||
|
|
static void execute_action(cJSON *action);
|
||
|
|
static void refresh_bindings(void);
|
||
|
|
|
||
|
|
// ─── Action opcodes (match IR spec) ───
|
||
|
|
#define OP_INC 1
|
||
|
|
#define OP_DEC 2
|
||
|
|
#define OP_ADD 3
|
||
|
|
#define OP_SUB 4
|
||
|
|
#define OP_SET 5
|
||
|
|
#define OP_TOGGLE 6
|
||
|
|
|
||
|
|
static uint8_t parse_op(const char *op_str) {
|
||
|
|
if (strcmp(op_str, "inc") == 0) return OP_INC;
|
||
|
|
if (strcmp(op_str, "dec") == 0) return OP_DEC;
|
||
|
|
if (strcmp(op_str, "add") == 0) return OP_ADD;
|
||
|
|
if (strcmp(op_str, "sub") == 0) return OP_SUB;
|
||
|
|
if (strcmp(op_str, "set") == 0) return OP_SET;
|
||
|
|
if (strcmp(op_str, "toggle") == 0) return OP_TOGGLE;
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Timer Callback ───
|
||
|
|
static void timer_cb(lv_timer_t *timer) {
|
||
|
|
ds_timer_t *t = (ds_timer_t *)lv_timer_get_user_data(timer);
|
||
|
|
if (!t) return;
|
||
|
|
|
||
|
|
int32_t val = s_signals[t->action_sig].i;
|
||
|
|
switch (t->action_op) {
|
||
|
|
case OP_INC: val++; break;
|
||
|
|
case OP_DEC: val--; break;
|
||
|
|
case OP_ADD: val += t->action_val; break;
|
||
|
|
case OP_SUB: val -= t->action_val; break;
|
||
|
|
case OP_SET: val = t->action_val; break;
|
||
|
|
case OP_TOGGLE: val = val ? 0 : 1; break;
|
||
|
|
}
|
||
|
|
ds_signal_update(t->action_sig, val);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Button Event Handler ───
|
||
|
|
typedef struct {
|
||
|
|
cJSON *action; // JSON action object (kept alive while UI exists)
|
||
|
|
uint8_t node_id;
|
||
|
|
} btn_user_data_t;
|
||
|
|
|
||
|
|
static void btn_click_cb(lv_event_t *e) {
|
||
|
|
btn_user_data_t *ud = (btn_user_data_t *)lv_event_get_user_data(e);
|
||
|
|
if (ud && ud->action) {
|
||
|
|
execute_action(ud->action);
|
||
|
|
if (s_action_cb) {
|
||
|
|
s_action_cb(ud->node_id, 0); // notify ESP-NOW
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Build Helpers ───
|
||
|
|
|
||
|
|
static lv_obj_t *build_container(cJSON *node, lv_obj_t *parent, bool is_row) {
|
||
|
|
lv_obj_t *cont = lv_obj_create(parent);
|
||
|
|
lv_obj_set_size(cont, LV_PCT(100), LV_SIZE_CONTENT);
|
||
|
|
lv_obj_set_flex_flow(cont, is_row ? LV_FLEX_FLOW_ROW : LV_FLEX_FLOW_COLUMN);
|
||
|
|
lv_obj_set_style_bg_opa(cont, LV_OPA_TRANSP, 0);
|
||
|
|
lv_obj_set_style_border_width(cont, 0, 0);
|
||
|
|
lv_obj_set_style_pad_all(cont, 4, 0);
|
||
|
|
|
||
|
|
cJSON *gap = cJSON_GetObjectItem(node, "gap");
|
||
|
|
if (gap) {
|
||
|
|
lv_obj_set_style_pad_gap(cont, gap->valueint, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
cJSON *children = cJSON_GetObjectItem(node, "c");
|
||
|
|
if (children && cJSON_IsArray(children)) {
|
||
|
|
cJSON *child;
|
||
|
|
cJSON_ArrayForEach(child, children) {
|
||
|
|
build_node(child, cont);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return cont;
|
||
|
|
}
|
||
|
|
|
||
|
|
static lv_obj_t *build_label(cJSON *node, lv_obj_t *parent) {
|
||
|
|
lv_obj_t *lbl = lv_label_create(parent);
|
||
|
|
cJSON *text = cJSON_GetObjectItem(node, "text");
|
||
|
|
if (text && text->valuestring) {
|
||
|
|
char expanded[256];
|
||
|
|
expand_template(text->valuestring, expanded, sizeof(expanded));
|
||
|
|
lv_label_set_text(lbl, expanded);
|
||
|
|
|
||
|
|
// Register binding if template contains {N}
|
||
|
|
if (strchr(text->valuestring, '{') && s_binding_count < DS_MAX_BINDINGS) {
|
||
|
|
ds_binding_t *b = &s_bindings[s_binding_count++];
|
||
|
|
b->label = lbl;
|
||
|
|
strncpy(b->template, text->valuestring, sizeof(b->template) - 1);
|
||
|
|
b->used = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cJSON *size = cJSON_GetObjectItem(node, "size");
|
||
|
|
if (size) {
|
||
|
|
lv_obj_set_style_text_font(lbl,
|
||
|
|
size->valueint >= 24 ? &lv_font_montserrat_24 :
|
||
|
|
size->valueint >= 18 ? &lv_font_montserrat_18 :
|
||
|
|
size->valueint >= 14 ? &lv_font_montserrat_14 :
|
||
|
|
&lv_font_montserrat_12, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
return lbl;
|
||
|
|
}
|
||
|
|
|
||
|
|
static lv_obj_t *build_button(cJSON *node, lv_obj_t *parent) {
|
||
|
|
lv_obj_t *btn = lv_btn_create(parent);
|
||
|
|
lv_obj_t *lbl = lv_label_create(btn);
|
||
|
|
|
||
|
|
cJSON *text = cJSON_GetObjectItem(node, "text");
|
||
|
|
if (text && text->valuestring) {
|
||
|
|
char expanded[256];
|
||
|
|
expand_template(text->valuestring, expanded, sizeof(expanded));
|
||
|
|
lv_label_set_text(lbl, expanded);
|
||
|
|
|
||
|
|
// Register binding for button text too
|
||
|
|
if (strchr(text->valuestring, '{') && s_binding_count < DS_MAX_BINDINGS) {
|
||
|
|
ds_binding_t *b = &s_bindings[s_binding_count++];
|
||
|
|
b->label = lbl;
|
||
|
|
strncpy(b->template, text->valuestring, sizeof(b->template) - 1);
|
||
|
|
b->used = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
cJSON *on = cJSON_GetObjectItem(node, "on");
|
||
|
|
if (on) {
|
||
|
|
cJSON *click = cJSON_GetObjectItem(on, "click");
|
||
|
|
if (click) {
|
||
|
|
btn_user_data_t *ud = malloc(sizeof(btn_user_data_t));
|
||
|
|
ud->action = click; // keep reference (IR JSON stays in memory)
|
||
|
|
cJSON *id_obj = cJSON_GetObjectItem(node, "id");
|
||
|
|
ud->node_id = id_obj ? id_obj->valueint : 0;
|
||
|
|
lv_obj_add_event_cb(btn, btn_click_cb, LV_EVENT_CLICKED, ud);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return btn;
|
||
|
|
}
|
||
|
|
|
||
|
|
static lv_obj_t *build_slider(cJSON *node, lv_obj_t *parent) {
|
||
|
|
lv_obj_t *slider = lv_slider_create(parent);
|
||
|
|
lv_obj_set_width(slider, LV_PCT(80));
|
||
|
|
|
||
|
|
cJSON *min = cJSON_GetObjectItem(node, "min");
|
||
|
|
cJSON *max = cJSON_GetObjectItem(node, "max");
|
||
|
|
if (min) lv_slider_set_range(slider, min->valueint, max ? max->valueint : 100);
|
||
|
|
|
||
|
|
cJSON *bind = cJSON_GetObjectItem(node, "bind");
|
||
|
|
if (bind) {
|
||
|
|
int sid = bind->valueint;
|
||
|
|
lv_slider_set_value(slider, s_signals[sid].i, LV_ANIM_OFF);
|
||
|
|
// TODO: add slider change event → ds_signal_update
|
||
|
|
}
|
||
|
|
|
||
|
|
return slider;
|
||
|
|
}
|
||
|
|
|
||
|
|
static lv_obj_t *build_panel(cJSON *node, lv_obj_t *parent) {
|
||
|
|
lv_obj_t *pnl = lv_obj_create(parent);
|
||
|
|
lv_obj_set_size(pnl, LV_PCT(100), LV_SIZE_CONTENT);
|
||
|
|
lv_obj_set_flex_flow(pnl, LV_FLEX_FLOW_COLUMN);
|
||
|
|
lv_obj_set_style_pad_all(pnl, 12, 0);
|
||
|
|
lv_obj_set_style_radius(pnl, 8, 0);
|
||
|
|
|
||
|
|
cJSON *title = cJSON_GetObjectItem(node, "text");
|
||
|
|
if (title && title->valuestring) {
|
||
|
|
lv_obj_t *t = lv_label_create(pnl);
|
||
|
|
lv_label_set_text(t, title->valuestring);
|
||
|
|
lv_obj_set_style_text_font(t, &lv_font_montserrat_14, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
cJSON *children = cJSON_GetObjectItem(node, "c");
|
||
|
|
if (children && cJSON_IsArray(children)) {
|
||
|
|
cJSON *child;
|
||
|
|
cJSON_ArrayForEach(child, children) {
|
||
|
|
build_node(child, pnl);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return pnl;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Node Dispatcher ───
|
||
|
|
static lv_obj_t *build_node(cJSON *node, lv_obj_t *parent) {
|
||
|
|
if (!node) return NULL;
|
||
|
|
|
||
|
|
cJSON *type = cJSON_GetObjectItem(node, "t");
|
||
|
|
if (!type || !type->valuestring) return NULL;
|
||
|
|
|
||
|
|
const char *t = type->valuestring;
|
||
|
|
|
||
|
|
if (strcmp(t, "col") == 0) return build_container(node, parent, false);
|
||
|
|
if (strcmp(t, "row") == 0) return build_container(node, parent, true);
|
||
|
|
if (strcmp(t, "lbl") == 0) return build_label(node, parent);
|
||
|
|
if (strcmp(t, "btn") == 0) return build_button(node, parent);
|
||
|
|
if (strcmp(t, "sld") == 0) return build_slider(node, parent);
|
||
|
|
if (strcmp(t, "pnl") == 0) return build_panel(node, parent);
|
||
|
|
// stk, inp, sw, bar, img — add as needed
|
||
|
|
|
||
|
|
ESP_LOGW(TAG, "Unknown node type: %s", t);
|
||
|
|
return NULL;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Template Expansion ───
|
||
|
|
// Replace {N} with signal values: "{2}: {0}°F" → "Kitchen: 72°F"
|
||
|
|
static void expand_template(const char *tpl, char *out, size_t out_len) {
|
||
|
|
size_t pos = 0;
|
||
|
|
while (*tpl && pos < out_len - 1) {
|
||
|
|
if (*tpl == '{') {
|
||
|
|
tpl++;
|
||
|
|
int sig_id = 0;
|
||
|
|
while (*tpl >= '0' && *tpl <= '9') {
|
||
|
|
sig_id = sig_id * 10 + (*tpl - '0');
|
||
|
|
tpl++;
|
||
|
|
}
|
||
|
|
if (*tpl == '}') tpl++;
|
||
|
|
|
||
|
|
if (sig_id < DS_MAX_SIGNALS && s_signals[sig_id].used) {
|
||
|
|
int written = snprintf(out + pos, out_len - pos, "%d",
|
||
|
|
(int)s_signals[sig_id].i);
|
||
|
|
if (written > 0) pos += written;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
out[pos++] = *tpl++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
out[pos] = '\0';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Action Executor ───
|
||
|
|
static void execute_action(cJSON *action) {
|
||
|
|
if (!action) return;
|
||
|
|
|
||
|
|
// Handle arrays of actions
|
||
|
|
if (cJSON_IsArray(action)) {
|
||
|
|
cJSON *item;
|
||
|
|
cJSON_ArrayForEach(item, action) {
|
||
|
|
execute_action(item);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
cJSON *op_json = cJSON_GetObjectItem(action, "op");
|
||
|
|
cJSON *s_json = cJSON_GetObjectItem(action, "s");
|
||
|
|
if (!op_json || !s_json) return;
|
||
|
|
|
||
|
|
uint8_t op = parse_op(op_json->valuestring);
|
||
|
|
uint16_t sid = s_json->valueint;
|
||
|
|
if (sid >= DS_MAX_SIGNALS) return;
|
||
|
|
|
||
|
|
int32_t val = s_signals[sid].i;
|
||
|
|
cJSON *v_json = cJSON_GetObjectItem(action, "v");
|
||
|
|
int32_t v = v_json ? v_json->valueint : 0;
|
||
|
|
|
||
|
|
switch (op) {
|
||
|
|
case OP_INC: val++; break;
|
||
|
|
case OP_DEC: val--; break;
|
||
|
|
case OP_ADD: val += v; break;
|
||
|
|
case OP_SUB: val -= v; break;
|
||
|
|
case OP_SET: val = v; break;
|
||
|
|
case OP_TOGGLE: val = val ? 0 : 1; break;
|
||
|
|
}
|
||
|
|
|
||
|
|
ds_signal_update(sid, val);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Refresh Bindings ───
|
||
|
|
// Called after any signal change to update bound labels
|
||
|
|
static void refresh_bindings(void) {
|
||
|
|
for (int i = 0; i < s_binding_count; i++) {
|
||
|
|
if (!s_bindings[i].used) continue;
|
||
|
|
char expanded[256];
|
||
|
|
expand_template(s_bindings[i].template, expanded, sizeof(expanded));
|
||
|
|
lv_label_set_text(s_bindings[i].label, expanded);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ─── Public API ───
|
||
|
|
|
||
|
|
esp_err_t ds_runtime_init(void *parent, ds_action_cb_t action_cb) {
|
||
|
|
s_parent = (lv_obj_t *)parent;
|
||
|
|
s_action_cb = action_cb;
|
||
|
|
s_root = NULL;
|
||
|
|
s_signal_count = 0;
|
||
|
|
s_binding_count = 0;
|
||
|
|
s_timer_count = 0;
|
||
|
|
memset(s_signals, 0, sizeof(s_signals));
|
||
|
|
memset(s_bindings, 0, sizeof(s_bindings));
|
||
|
|
memset(s_timers, 0, sizeof(s_timers));
|
||
|
|
|
||
|
|
ESP_LOGI(TAG, "Runtime initialized");
|
||
|
|
return ESP_OK;
|
||
|
|
}
|
||
|
|
|
||
|
|
esp_err_t ds_ui_build(const char *ir_json, size_t length) {
|
||
|
|
// Destroy previous UI
|
||
|
|
ds_ui_destroy();
|
||
|
|
|
||
|
|
cJSON *ir = cJSON_ParseWithLength(ir_json, length);
|
||
|
|
if (!ir) {
|
||
|
|
ESP_LOGE(TAG, "Failed to parse IR JSON");
|
||
|
|
return ESP_ERR_INVALID_ARG;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load signals
|
||
|
|
cJSON *signals = cJSON_GetObjectItem(ir, "signals");
|
||
|
|
if (signals && cJSON_IsArray(signals)) {
|
||
|
|
cJSON *sig;
|
||
|
|
cJSON_ArrayForEach(sig, signals) {
|
||
|
|
cJSON *id = cJSON_GetObjectItem(sig, "id");
|
||
|
|
cJSON *v = cJSON_GetObjectItem(sig, "v");
|
||
|
|
if (id && id->valueint < DS_MAX_SIGNALS) {
|
||
|
|
int sid = id->valueint;
|
||
|
|
s_signals[sid].i = v ? v->valueint : 0;
|
||
|
|
s_signals[sid].type = DS_SIG_INT;
|
||
|
|
s_signals[sid].used = true;
|
||
|
|
s_signal_count++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build UI tree
|
||
|
|
cJSON *root = cJSON_GetObjectItem(ir, "root");
|
||
|
|
if (root) {
|
||
|
|
s_root = build_node(root, s_parent);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Load timers
|
||
|
|
cJSON *timers = cJSON_GetObjectItem(ir, "timers");
|
||
|
|
if (timers && cJSON_IsArray(timers)) {
|
||
|
|
cJSON *t;
|
||
|
|
cJSON_ArrayForEach(t, timers) {
|
||
|
|
if (s_timer_count >= DS_MAX_TIMERS) break;
|
||
|
|
cJSON *ms = cJSON_GetObjectItem(t, "ms");
|
||
|
|
cJSON *action = cJSON_GetObjectItem(t, "action");
|
||
|
|
if (ms && action) {
|
||
|
|
ds_timer_t *timer = &s_timers[s_timer_count];
|
||
|
|
timer->ms = ms->valueint;
|
||
|
|
|
||
|
|
cJSON *op = cJSON_GetObjectItem(action, "op");
|
||
|
|
cJSON *s = cJSON_GetObjectItem(action, "s");
|
||
|
|
cJSON *v = cJSON_GetObjectItem(action, "v");
|
||
|
|
timer->action_op = op ? parse_op(op->valuestring) : 0;
|
||
|
|
timer->action_sig = s ? s->valueint : 0;
|
||
|
|
timer->action_val = v ? v->valueint : 0;
|
||
|
|
|
||
|
|
timer->timer = lv_timer_create(timer_cb, timer->ms, timer);
|
||
|
|
s_timer_count++;
|
||
|
|
ESP_LOGI(TAG, "Timer: every %dms → op %d on s%d",
|
||
|
|
(int)timer->ms, timer->action_op, timer->action_sig);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ESP_LOGI(TAG, "UI built: %d signals, %d bindings, %d timers",
|
||
|
|
s_signal_count, s_binding_count, s_timer_count);
|
||
|
|
|
||
|
|
// Keep IR JSON alive for button action references
|
||
|
|
// (cJSON_Delete would invalidate action pointers)
|
||
|
|
// TODO: deep-copy actions to avoid this leak
|
||
|
|
|
||
|
|
return ESP_OK;
|
||
|
|
}
|
||
|
|
|
||
|
|
void ds_ui_destroy(void) {
|
||
|
|
// Stop timers
|
||
|
|
for (int i = 0; i < s_timer_count; i++) {
|
||
|
|
if (s_timers[i].timer) {
|
||
|
|
lv_timer_delete((lv_timer_t *)s_timers[i].timer);
|
||
|
|
s_timers[i].timer = NULL;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
s_timer_count = 0;
|
||
|
|
|
||
|
|
// Clear bindings
|
||
|
|
s_binding_count = 0;
|
||
|
|
memset(s_bindings, 0, sizeof(s_bindings));
|
||
|
|
|
||
|
|
// Destroy LVGL tree
|
||
|
|
if (s_root) {
|
||
|
|
lv_obj_del(s_root);
|
||
|
|
s_root = NULL;
|
||
|
|
}
|
||
|
|
|
||
|
|
ESP_LOGI(TAG, "UI destroyed");
|
||
|
|
}
|
||
|
|
|
||
|
|
void ds_signal_update(uint16_t signal_id, int32_t value) {
|
||
|
|
if (signal_id >= DS_MAX_SIGNALS) return;
|
||
|
|
s_signals[signal_id].i = value;
|
||
|
|
s_signals[signal_id].used = true;
|
||
|
|
|
||
|
|
// Refresh all bound labels
|
||
|
|
refresh_bindings();
|
||
|
|
}
|
||
|
|
|
||
|
|
uint16_t ds_signal_count(void) {
|
||
|
|
return s_signal_count;
|
||
|
|
}
|
||
|
|
|
||
|
|
int32_t ds_signal_get(uint16_t signal_id) {
|
||
|
|
if (signal_id >= DS_MAX_SIGNALS) return 0;
|
||
|
|
return s_signals[signal_id].i;
|
||
|
|
}
|