/** * 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 #include #include #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; }