matter_bridge_ui.tc¶
matter_bridge_ui.tc — interactive remote-device bridge for matter_c (Berry-style).
// matter_bridge_ui.tc — interactive remote-device bridge for matter_c (Berry-style).
// A web table on THIS device: type a remote ESP's IP, Discover probes its HTTP
// Status for sensors + actuators, "Add" appends them as Matter endpoints (live
// rebuild, persisted to /bridge.cfg). THIS device (USE_MATTER_C build) is the
// Matter node — pair it on /mt. Remote ESPs keep running plain Tasmota.
//
// Beyond Matter, the bridge's OWN web page also:
// • shows the LIVE sensor values / actuator state, and
// • lets you STEER the actuators directly — a toggle button per relay/lamp and
// a brightness slider per lamp (the first 4 actuators get web controls).
//
// read sensors GET /cm?cmnd=Status 10 (StatusSNS: Temperature/Humidity/Pressure)
// read actuators GET /cm?cmnd=Status 11 (StatusSTS: POWER/POWER1.. + Dimmer)
// control GET /cm?cmnd=Power[n] ON|OFF / Dimmer <0..100>
//
// USAGE (bridge web page, "Matter Remote Bridge" section):
// 1. type the remote IP, click "Discover" — found sensors/relays/lamps listed
// 2. "Add these to Matter" — appended + the Matter model rebuilds in place
// (duplicates of an already-bridged ip+key are skipped)
// 3. toggle/dim the actuators from here, or via your Matter controller
// 4. "Clear all bridged" wipes the config.
//
// SAFETY (project rules): ALL httpGet runs on ONE task (TaskLoop). Web widgets
// only set globals; EverySecond reads them and QUEUES a cmnd (flag set LAST) that
// TaskLoop sends — never httpGet from a web/EverySecond context. The Matter
// MODEL is (re)built by build_model() on the SAME TaskLoop right after a config
// change (do_add / clear) — matterReset/Add/Start run in the VM-task context just
// like main(), so they never race the poller and we avoid a slot self-restart.
// (Self-restart via "TinyCRun 0" is unsafe here: if the restart fires while the
// TaskLoop is blocked inside poll_all()'s multi-second httpGet, the task can't
// stop in time → "Task did not stop in time, abandoned" → TaskLoop dies.)
// ───────────── config (persisted to /bridge.cfg) ─────────────
// kind: 0=temperature 1=humidity 2=pressure 4=relay 5=dimmable light
char cfg_ip[8][16]; // remote IP per bridged item
int cfg_kind[8]; // what it is
char cfg_key[8][16]; // JSON key ("Temperature", "POWER1", …)
char cfg_name[8][24]; // Matter endpoint label
char cfg_src[8][24]; // source remote's friendly DeviceName ("from where")
int cfg_ep[8]; // Matter endpoint id (runtime)
float cfg_val[8]; // last polled value (sensor reading; relay/lamp = 0/1)
int cfg_dim[8]; // last polled dimmer % (lamps)
int ncfg = 0;
// discovery scratch (filled by TaskLoop, shown by WebCall)
char disc_ip[16];
char disc_src[24]; // discovered remote's DeviceName (falls back to IP)
int disc_kind[8];
char disc_key[8][16];
char disc_name[8][24];
int disc_n = 0;
char buf[512]; // shared httpGet response — TaskLoop only
// web → task handshakes
char ip_in[16];
watch int btn_disc = 0; watch int btn_add = 0; watch int btn_clear = 0;
int disc_req = 0; int add_req = 0; int clear_req = 0;
// web actuator controls — first 4 actuators (relay/lamp). webButton/webSlider
// need a distinct scalar global each (can't bind an array element), so 4 slots.
watch int ctl0 = 0; watch int ctl1 = 0; watch int ctl2 = 0; watch int ctl3 = 0; // toggle
watch int dmv0 = 0; watch int dmv1 = 0; watch int dmv2 = 0; watch int dmv3 = 0; // dimmer
int last_dmv0 = 0; int last_dmv1 = 0; int last_dmv2 = 0; int last_dmv3 = 0;
// control queue (MatterInvoke + EverySecond fill, TaskLoop sends; flag LAST)
char pend_ip[16]; char pending[40]; int cmd_ready = 0;
int poll_tick = 0;
// ── tiny JSON helpers (daikin dfield pattern) ──
float jget(char src[], char key[]) { // key incl. colon -> number, else -99999
char v[16]; int p;
p = strFind(src, key); if (p < 0) { return -99999.0; }
strSub(v, src, p + strlen(key), 14); return atof(v);
}
int jbool(char src[], char key[]) { // key e.g. "POWER1" -> 1/0, -1 absent
char v[10]; int p;
p = strFind(src, key); if (p < 0) { return -1; }
strSub(v, src, p + strlen(key), 8);
if (strFind(v, "ON") >= 0) { return 1; } return 0;
}
// fetch a remote's friendly DeviceName into dst (TaskLoop only; falls back to ip).
// Uses the firmware jsonStr() builtin (Tasmota's JsonParser) — robust, unlike the
// earlier hand-rolled string slicing which tripped over TinyC quote/char quirks.
void get_devname(char ip[], char dst[]) {
char url[64];
dst[0] = 0;
sprintf(url, "http://%s/cm?cmnd=DeviceName", ip); buf[0] = 0; // {"DeviceName":"NAME"}
if (httpGet(url, buf) > 0) { jsonStr(buf, "DeviceName", dst); }
if (strlen(dst) < 1) { strcpy(dst, ip); } // fallback: show the IP
}
// queue a Tasmota cmnd to a remote (TaskLoop sends it; skip if one is pending)
void queue_cmd(char ip[], char c[]) {
if (cmd_ready) { return; }
strcpy(pend_ip, ip); strcpy(pending, c); cmd_ready = 1; // flag LAST
}
// build "Power[n]%20ON|OFF" for a relay/lamp item's POWER key into c
void power_cmd(char c[], char key[], int on) {
char suf[6];
strSub(suf, key, 5, 4); // "" for POWER, "1".. for POWERn
if (on) { sprintf(c, "Power%s%%20ON", suf); }
else { sprintf(c, "Power%s%%20OFF", suf); }
}
// ── persistence (flat comma tuples; strToken is 1-indexed; write 1D temp then strcpy) ──
void save_cfg() {
char line[120]; int i; int f;
f = fileOpen("/bridge.cfg", 1);
if (f < 0) { return; }
i = 0;
while (i < ncfg) { // tuple: ip,kind,key,name,src,
sprintf(line, "%s,%d,%s,%s,%s,", cfg_ip[i], cfg_kind[i], cfg_key[i], cfg_name[i], cfg_src[i]);
fileWrite(f, line, strlen(line));
i = i + 1;
}
fileClose(f);
}
void load_cfg() {
char blob[760]; char tip[16]; char tk[16]; char tn[24]; char tf[8]; char ts[24];
int f; int n; int t; int dots; int j;
ncfg = 0;
f = fileOpen("/bridge.cfg", 0);
if (f < 0) { return; }
n = fileRead(f, blob, 759); fileClose(f);
if (n <= 0) { return; }
blob[n] = 0;
t = 1; // strToken is 1-indexed
while (ncfg < 8) {
strToken(tip, blob, ',', t);
if (strlen(tip) < 7) { break; }
strToken(tf, blob, ',', t + 1);
strToken(tk, blob, ',', t + 2);
strToken(tn, blob, ',', t + 3);
strToken(ts, blob, ',', t + 4); // src — OR next-record ip (old 4-field cfg)
strcpy(cfg_ip[ncfg], tip); cfg_kind[ncfg] = (int)atof(tf);
strcpy(cfg_key[ncfg], tk); strcpy(cfg_name[ncfg], tn);
cfg_val[ncfg] = 0.0; cfg_dim[ncfg] = 0;
// an IP-looking 5th field (>=2 dots) means this is the OLD 4-field format
// (ts is actually the next record's ip) → no src here, advance only 4.
dots = 0; j = 0;
while (ts[j] != 0) { if (ts[j] == '.') { dots = dots + 1; } j = j + 1; }
if (dots >= 2) { strcpy(cfg_src[ncfg], cfg_ip[ncfg]); t = t + 4; }
else { strcpy(cfg_src[ncfg], ts); t = t + 5; }
ncfg = ncfg + 1;
}
}
// ── build the Matter model from the config (main task only) ──
void build_model() {
int i; int t;
matterReset();
i = 0;
while (i < ncfg) {
t = cfg_kind[i];
if (t == 0) { cfg_ep[i] = matterAdd(MATTER_TEMP_SENSOR); matterCluster(cfg_ep[i], CLUSTER_TEMP); matterAttr(cfg_ep[i], CLUSTER_TEMP, 0, MTR_S16); }
if (t == 1) { cfg_ep[i] = matterAdd(MATTER_HUM_SENSOR); matterCluster(cfg_ep[i], CLUSTER_HUM); matterAttr(cfg_ep[i], CLUSTER_HUM, 0, MTR_U16); }
if (t == 2) { cfg_ep[i] = matterAdd(MATTER_PRESS_SENSOR); matterCluster(cfg_ep[i], CLUSTER_PRESS); matterAttr(cfg_ep[i], CLUSTER_PRESS, 0, MTR_S16); }
if (t == 4) { cfg_ep[i] = matterAdd(MATTER_PLUG); } // OnOff auto-attached
if (t == 5) {
cfg_ep[i] = matterAdd(MATTER_DIMM_LIGHT);
matterCluster(cfg_ep[i], CLUSTER_ONOFF); matterAttr(cfg_ep[i], CLUSTER_ONOFF, 0, MTR_BOOL);
matterCluster(cfg_ep[i], CLUSTER_LEVEL); matterAttr(cfg_ep[i], CLUSTER_LEVEL, 0, MTR_U8);
matterSet(cfg_ep[i], CLUSTER_LEVEL, 0, 254);
}
matterName(cfg_ep[i], cfg_name[i]);
cfg_val[i] = 0.0; cfg_dim[i] = 0;
i = i + 1;
}
matterStart();
}
// ── discovery: probe known keys against the remote's Status (TaskLoop only) ──
void probe_sensor(char k[], int kind, char nm[]) {
char fk[20];
if (disc_n >= 8) { return; }
sprintf(fk, "\"%s\":", k);
if (strFind(buf, fk) >= 0) {
strcpy(disc_key[disc_n], k); disc_kind[disc_n] = kind; strcpy(disc_name[disc_n], nm);
disc_n = disc_n + 1;
}
}
void probe_act(char k[], int kind, char nm[]) { // relay (4) / lamp (5): key like "POWER1"
char fk[16];
if (disc_n >= 8) { return; }
sprintf(fk, "\"%s\"", k);
if (strFind(buf, fk) >= 0) {
strcpy(disc_key[disc_n], k); disc_kind[disc_n] = kind; strcpy(disc_name[disc_n], nm);
disc_n = disc_n + 1;
}
}
void do_discover() {
char url[64]; char k[16]; char nm[24]; char fk[12];
disc_n = 0;
get_devname(disc_ip, disc_src); // friendly "from where" name (or IP)
// Probe ACTUATORS first so they're stored (and thus rendered) right under the
// device's name+IP header, ahead of the sensor readings.
sprintf(url, "http://%s/cm?cmnd=Status%%2011", disc_ip); buf[0] = 0;
if (httpGet(url, buf) > 0) {
strcpy(fk, "\"Dimmer\":");
if (strFind(buf, fk) >= 0) { // has a Dimmer -> dimmable light on POWER
strcpy(k, "POWER"); strcpy(nm, "Lampe"); probe_act(k, 5, nm);
} else { // else plain relays
strcpy(k, "POWER"); strcpy(nm, "Relais"); probe_act(k, 4, nm);
strcpy(k, "POWER1"); strcpy(nm, "Relais 1"); probe_act(k, 4, nm);
strcpy(k, "POWER2"); strcpy(nm, "Relais 2"); probe_act(k, 4, nm);
}
}
sprintf(url, "http://%s/cm?cmnd=Status%%2010", disc_ip); buf[0] = 0;
if (httpGet(url, buf) > 0) {
strcpy(k, "Temperature"); strcpy(nm, "Temperatur"); probe_sensor(k, 0, nm);
strcpy(k, "Humidity"); strcpy(nm, "Feuchte"); probe_sensor(k, 1, nm);
strcpy(k, "Pressure"); strcpy(nm, "Luftdruck"); probe_sensor(k, 2, nm);
}
}
void do_add() {
int i; int j; int dup;
i = 0;
while (i < disc_n && ncfg < 8) {
dup = 0; j = 0; // skip if this ip+key is already bridged
while (j < ncfg) {
if (strcmp(cfg_ip[j], disc_ip) == 0 && strcmp(cfg_key[j], disc_key[i]) == 0) { dup = 1; }
j = j + 1;
}
if (dup == 0) {
strcpy(cfg_ip[ncfg], disc_ip); cfg_kind[ncfg] = disc_kind[i];
strcpy(cfg_key[ncfg], disc_key[i]); strcpy(cfg_name[ncfg], disc_name[i]);
strcpy(cfg_src[ncfg], disc_src); // remember the source device
cfg_val[ncfg] = 0.0; cfg_dim[ncfg] = 0;
ncfg = ncfg + 1;
}
i = i + 1;
}
disc_n = 0;
save_cfg();
build_model(); // rebuild on THIS task — no slot restart
}
// ── poll every configured remote and mirror state into Matter (TaskLoop) ──
void poll_all() {
char url[64]; char k[20]; float v; int on; int i;
i = 0;
while (i < ncfg) {
if (cfg_kind[i] == 4 || cfg_kind[i] == 5) {
sprintf(url, "http://%s/cm?cmnd=Status%%2011", cfg_ip[i]); buf[0] = 0;
if (httpGet(url, buf) > 0) {
sprintf(k, "\"%s\"", cfg_key[i]); on = jbool(buf, k);
if (on >= 0) { matterSet(cfg_ep[i], CLUSTER_ONOFF, 0, on); cfg_val[i] = (float)on; }
if (cfg_kind[i] == 5) {
strcpy(k, "\"Dimmer\":"); v = jget(buf, k);
if (v >= 0.0) { cfg_dim[i] = (int)v; matterSet(cfg_ep[i], CLUSTER_LEVEL, 0, (int)(v * 254.0 / 100.0)); }
}
}
} else {
sprintf(url, "http://%s/cm?cmnd=Status%%2010", cfg_ip[i]); buf[0] = 0;
if (httpGet(url, buf) > 0) {
sprintf(k, "\"%s\":", cfg_key[i]); v = jget(buf, k);
if (cfg_kind[i] == 0 && v > -90.0) { cfg_val[i] = v; matterSetFloat(cfg_ep[i], CLUSTER_TEMP, 0, v, 100); }
if (cfg_kind[i] == 1 && v >= 0.0) { cfg_val[i] = v; matterSetFloat(cfg_ep[i], CLUSTER_HUM, 0, v, 100); }
if (cfg_kind[i] == 2 && v >= 0.0) { cfg_val[i] = v; matterSetFloat(cfg_ep[i], CLUSTER_PRESS, 0, v, 1); }
}
}
delay(50);
i = i + 1;
}
}
void send_cmd() {
char url[96]; char ip[16]; char c[40];
strcpy(ip, pend_ip); strcpy(c, pending); cmd_ready = 0; // snapshot before httpGet
sprintf(url, "http://%s/cm?cmnd=%s", ip, c);
httpGet(url, buf); addLog(url);
}
// ── a Matter controller changed a bridged relay/lamp → queue the cmnd ──
void MatterInvoke(int ep, int cluster, int cmd) {
int i; int pct; char c[40];
i = 0;
while (i < ncfg) {
if (cfg_ep[i] == ep && (cfg_kind[i] == 4 || cfg_kind[i] == 5)) {
if (cluster == CLUSTER_LEVEL) {
pct = (matterGet(ep, CLUSTER_LEVEL, 0) * 100) / 254;
sprintf(c, "Dimmer%%20%d", pct); queue_cmd(cfg_ip[i], c);
} else {
power_cmd(c, cfg_key[i], matterGet(ep, CLUSTER_ONOFF, 0));
queue_cmd(cfg_ip[i], c);
}
return;
}
i = i + 1;
}
}
// ── the only HTTP task ──
void TaskLoop() {
delay(15000);
while (1) {
if (cmd_ready) { send_cmd(); }
if (disc_req) { do_discover(); disc_req = 0; }
if (add_req) { do_add(); add_req = 0; }
if (clear_req) { ncfg = 0; save_cfg(); build_model(); clear_req = 0; }
if (poll_tick % 17 == 0 && ncfg > 0) { poll_all(); }
poll_tick = poll_tick + 1;
delay(300);
}
}
// ── EverySecond: discovery/add/clear/restart flags + web actuator controls ──
void EverySecond() {
char c[40]; int i; int cc; int tog; int on; int dv;
if (btn_disc != 0) { strcpy(disc_ip, ip_in); disc_req = 1; btn_disc = 0; }
if (btn_add != 0) { add_req = 1; btn_add = 0; }
if (btn_clear != 0) { clear_req = 1; btn_clear = 0; }
// first 4 actuators: toggle button + (lamp) dimmer slider -> queue cmnd
cc = 0; i = 0;
while (i < ncfg && cc < 4) {
if (cfg_kind[i] == 4 || cfg_kind[i] == 5) {
tog = 0;
if (cc == 0 && ctl0 != 0) { ctl0 = 0; tog = 1; }
if (cc == 1 && ctl1 != 0) { ctl1 = 0; tog = 1; }
if (cc == 2 && ctl2 != 0) { ctl2 = 0; tog = 1; }
if (cc == 3 && ctl3 != 0) { ctl3 = 0; tog = 1; }
if (tog != 0) {
on = 0; if (cfg_val[i] < 0.5) { on = 1; } // toggle current
power_cmd(c, cfg_key[i], on);
queue_cmd(cfg_ip[i], c);
cfg_val[i] = (float)on; // optimistic echo
matterSet(cfg_ep[i], CLUSTER_ONOFF, 0, on);
}
if (cfg_kind[i] == 5) { // dimmer slider moved?
dv = -1;
if (cc == 0 && dmv0 != last_dmv0) { last_dmv0 = dmv0; dv = dmv0; }
if (cc == 1 && dmv1 != last_dmv1) { last_dmv1 = dmv1; dv = dmv1; }
if (cc == 2 && dmv2 != last_dmv2) { last_dmv2 = dmv2; dv = dmv2; }
if (cc == 3 && dmv3 != last_dmv3) { last_dmv3 = dmv3; dv = dmv3; }
if (dv >= 0) {
sprintf(c, "Dimmer%%20%d", dv);
queue_cmd(cfg_ip[i], c);
cfg_dim[i] = dv;
matterSet(cfg_ep[i], CLUSTER_LEVEL, 0, (int)((float)dv * 254.0 / 100.0));
}
}
cc = cc + 1;
}
i = i + 1;
}
}
// ── web UI ──
char kind_names[] = "Temp|Feuchte|Druck|?|Relais|Lampe";
void WebCall() {
char b[200]; char kn[12]; char st[4]; char stcol[10]; char prevsrc[24]; int i; int cc; int t;
webSend("{s}<b>Matter Remote Bridge</b>{m}{e}");
webText(ip_in, 16, "Remote IP");
webButton(btn_disc, "Discover|scanning…");
if (ncfg > 0) { webButton(btn_clear, "Clear all bridged|cleared"); } // top-level action, right after Discover
if (disc_n > 0) {
sprintf(b, "{s}<i>found on %s (%s)</i>{m}%d{e}", disc_src, disc_ip, disc_n); webSend(b);
i = 0;
while (i < disc_n) {
strToken(kn, kind_names, '|', disc_kind[i] + 1); // strToken is 1-indexed
sprintf(b, "{s} %s{m}%s (%s){e}", disc_name[i], disc_key[i], kn); webSend(b);
i = i + 1;
}
webButton(btn_add, "Add these to Matter|added");
}
// ── Bridged endpoints as a 3-column table: Name | Value/State | Control ──
// Everything webSend()'d lands inside Tasmota's sensor <table>, so we emit our
// own <tr> rows. The control widget (webButton/webSlider) is dropped into the
// row's 3rd <td> — a <div><button> is valid inside a cell — so each actuator's
// toggle/slider sits on the actuator's own line, directly under its device
// (name+IP) header. Actuators are stored before sensors, so they list first.
webSend("{s}<b>Bridged endpoints</b>{m}{e}");
// styling for the bridge table (one <style>, replaced on every /?m=1 refresh)
webSend("<style>.dh{background:#0e3a42;border-left:3px solid #4db6ac;padding:6px 8px}.rw{background:#e3e3e3;color:#222}.rw td{border-bottom:1px solid #c8c8c8;padding:3px 6px}.nm{padding-left:16px}.va{text-align:right;white-space:nowrap}.un{opacity:.6;font-size:.82em}.ip{opacity:.5;float:right;font-weight:normal}.c3{width:160px}</style>");
cc = 0; i = 0; prevsrc[0] = 0;
while (i < ncfg) {
if (strcmp(cfg_src[i], prevsrc) != 0) { // device group header (spans all 3 cols)
strcpy(prevsrc, cfg_src[i]);
sprintf(b, "<tr><td colspan='3' class='dh'><b style='color:#4db6ac'>●</b> <b>%s</b><span class='ip'>%s</span></td></tr>", cfg_src[i], cfg_ip[i]); webSend(b);
}
t = cfg_kind[i];
if (t == 0) { sprintf(b, "<tr class='rw'><td class='nm'>%s</td><td class='va'>%.1f<span class='un'> C</span></td><td></td></tr>", cfg_name[i], cfg_val[i]); webSend(b); }
if (t == 1) { sprintf(b, "<tr class='rw'><td class='nm'>%s</td><td class='va'>%.0f<span class='un'> %%</span></td><td></td></tr>", cfg_name[i], cfg_val[i]); webSend(b); }
if (t == 2) { sprintf(b, "<tr class='rw'><td class='nm'>%s</td><td class='va'>%.0f<span class='un'> hPa</span></td><td></td></tr>", cfg_name[i], cfg_val[i]); webSend(b); }
if (t == 4 || t == 5) {
if (cfg_val[i] > 0.5) { strcpy(st, "ON"); strcpy(stcol, "#66bb6a"); } else { strcpy(st, "OFF"); strcpy(stcol, "#999"); }
if (t == 5) { sprintf(b, "<tr class='rw'><td class='nm'>%s</td><td class='va' style='color:%s'>%s<span class='un'> %d%%</span></td><td class='c3'>", cfg_name[i], stcol, st, cfg_dim[i]); }
else { sprintf(b, "<tr class='rw'><td class='nm'>%s</td><td class='va' style='color:%s'>%s</td><td class='c3'>", cfg_name[i], stcol, st); }
webSend(b); // open the row + its control cell
if (cc < 4) { // control widget(s) go INSIDE this <td>
if (cc == 0) { webButton(ctl0, "schalten"); }
if (cc == 1) { webButton(ctl1, "schalten"); }
if (cc == 2) { webButton(ctl2, "schalten"); }
if (cc == 3) { webButton(ctl3, "schalten"); }
if (t == 5) {
if (cc == 0) { webSlider(dmv0, 0, 100, "Helligkeit"); }
if (cc == 1) { webSlider(dmv1, 0, 100, "Helligkeit"); }
if (cc == 2) { webSlider(dmv2, 0, 100, "Helligkeit"); }
if (cc == 3) { webSlider(dmv3, 0, 100, "Helligkeit"); }
}
cc = cc + 1;
}
webSend("</td></tr>"); // close control cell + row
}
i = i + 1;
}
sprintf(b, "{s}heap{m}%d kb{e}", tasm_heap / 1000); webSend(b);
}
int main() {
ip_in[0] = 0; disc_n = 0; cmd_ready = 0;
ctl0 = 0; ctl1 = 0; ctl2 = 0; ctl3 = 0;
dmv0 = 0; dmv1 = 0; dmv2 = 0; dmv3 = 0;
last_dmv0 = 0; last_dmv1 = 0; last_dmv2 = 0; last_dmv3 = 0;
load_cfg();
build_model();
return 0;
}