Skip to content

moritz_dash.tc

moritz_dash.tc — LVGL dashboard + integrated house alarm for the MORITZ (CC1101 MAX!) system

Source on GitHub

// moritz_dash.tc — LVGL dashboard + integrated house alarm for the MORITZ (CC1101 MAX!) system
// on the RA8876 panel (.102, 1024x600). Consolidates the old moritz_dash + eqmax_alarm slots.
//
//   LEFT  = thermostats : big SETPOINT (Soll, amber) + -/+ buttons (MaxCmd), small measured (Ist).
//   RIGHT = window contacts (compact zu/OFFEN cards) + an "Alarmanlage scharf" toggle + alarm state.
//
// Event-driven (main() returns): Every100ms polls the LVGL buttons/switch (responsive); EverySecond
// drives the 5 s refresh + alarm state machine. Because callbacks let the VM halt, tasmDefer fires —
// so TTS / email / siren defer cleanly (NOT a while(1) loop). The arm switch + web checkbox both
// drive arm_on; the alarm logic is the proven eqmax_alarm state machine.
//
// Data via pluginQuery(126,...): type 18 thermostat = label,en,rfe,bat,rssi,valve,tmode,dtmp,mtmp
//                                type 1  window      = label,...,is_open (6=is_open)
// LVGL fonts have no umlauts -> umlaut() rewrites ue/oe/ae. -/+ send `MaxCmd <id> m <whole-deg>`.

#define ST_RADIUS 120
#define ST_BORDER  56
#define ST_BCOLOR  57
#define ST_BOPA    58
#define AL_CENTER   9
#define EV_CLICKED 10
#define EV_VALCHG  35

#define C_BG     0x0E1116
#define C_CARD   0x1B2530
#define C_CARDB  0x2C3A48
#define C_TITLE  0xE8EAED
#define C_SUB    0x9AA0A6
#define C_AMBER  0xFBBC04
#define C_GREEN  0x34A853
#define C_RED    0xEA4335
#define C_OPENBG 0x3A1414
#define C_ZUBG   0x12281A

#define TX     8
#define WX   520
#define GY    56
#define CW   158
#define CH   168
#define COFF 167
#define ROFF 180
#define WCH   94
#define WROFF 100
#define MAXWC 30
#define NEAR_MM 500       // VL53L0X: person nearby if distance (mm) < this — TUNE to your mounting
                          // (.102 background reads ~850 mm; 500 = only someone right at the display)

char IDS[] = "19879D|0E8BD2|0E8D48|0E7BC5|0E8D4D|0E8BEC|0E8C31|0E8B84|1300E8";

char qbuf[120];
char nm[28];
char f6[12]; char f8[12]; char f9[12];
char b[48];  char idbuf[10];
int th_set[9]; int th_meas[9]; int th_val[9];
int btn_dn[9]; int btn_up[9];
int btn_mode[9]; int mlbl[9]; int th_mode[9];   // mode toggle button, its label, last tmode (0=auto 1=man 2=vac 3=boost)
int wn_state[9]; int wn_card[9];
int bat_th[9]; int bat_wn[9];    // per-device "BAT" low-battery indicator labels
int nth; int nwn;

// ---- alarm state ----
int sw; int alarmMsg;            // LVGL: arm switch handle + state label
int arm_on   = 0;                // user wants armed (LVGL switch OR web checkbox)
int prev_arm = 0;
int arequest = 0;                // 0 disarmed, 1 arming, 2 armed
int email_sent = 0;
int wopn     = 0;                // 1-based index of an open window (0 = all closed)
int last_warned = 0;             // throttles the "noch offen" re-warn
int tick = 0;
global float galarm;             // door alarm switch (UDP-shared global) — mirrors the app "scharf" switch
int prev_g = 0;                  // last galarm level seen (tells door-side changes from app-side changes)
int idle_s = 0;                  // VL53L0X presence: seconds since a person was last near
int dim_now = 0;                 // last DisplayDimmer % we set (0 = not yet set)
char open_name[48];              // name of the open window (TTS + alarm text)
char cmd[200];                   // deferred-command scratch (TTS / email)

// ---- SMTP creds for the alarm email — read at boot from /alarm.cfg (device-only, NOT in git) ----
// /alarm.cfg (one KEY=value per line, // comments ok):  SMTP=  PORT=  USER=  PASS=  FROM=  TO=
#define ALARM_CFG "/alarm.cfg"
char cfgbuf[512]; char cfgline[80];   // 512 (was 256): comments + creds can exceed 254 -> last key (TO=) got truncated -> mail_ok=0
char m_smtp[40]; char m_port[8]; char m_user[40]; char m_pass[48]; char m_from[48]; char m_to[48];
int mail_ok = 0;                 // 1 once /alarm.cfg supplied creds (else TTS+siren only, no email)

void umlaut(char s[]) {
    strReplace(s, "ü", "ue"); strReplace(s, "ö", "oe"); strReplace(s, "ä", "ae");
    strReplace(s, "Ü", "Ue"); strReplace(s, "Ö", "Oe"); strReplace(s, "Ä", "Ae");
    strReplace(s, "ß", "ss");
}

int makeCard(int x, int y, int h, char name[]) {
    int c = lvglObj(0);
    lvglSetPos(c, x, y); lvglSetSize(c, CW, h);
    lvglSetBgColor(c, C_CARD);
    lvglSetStyleInt(c, ST_RADIUS, 10);
    lvglSetStyleInt(c, ST_BORDER, 1);
    lvglSetStyleInt(c, ST_BOPA, 255);
    lvglSetStyleInt(c, ST_BCOLOR, C_CARDB);
    int t = lvglLabel(0);
    lvglSetText(t, name); lvglSetTextColor(t, C_TITLE); lvglSetFont(t, 13);
    lvglSetPos(t, x + 8, y + 6);
    return c;
}

int mkBtn(int x, int y, char lab[]) {
    int bt = lvglButton(0);
    lvglSetPos(bt, x, y); lvglSetSize(bt, 40, 46);
    lvglEventEnable(bt, EV_CLICKED);
    int c = lvglLabel(bt);
    lvglSetText(c, lab); lvglSetFont(c, 24); lvglAlign(c, AL_CENTER, 0, 0);
    return bt;
}

// mode label text + colour: AUTO green (normal); MAN/vacation/boost amber (someone switched it)
void setModeLabel(int j) {
    if (th_mode[j] == 0)      { lvglSetText(mlbl[j], "AUTO");   lvglSetTextColor(mlbl[j], C_GREEN); }
    else if (th_mode[j] == 1) { lvglSetText(mlbl[j], "MAN");    lvglSetTextColor(mlbl[j], C_AMBER); }
    else if (th_mode[j] == 2) { lvglSetText(mlbl[j], "URLAUB"); lvglSetTextColor(mlbl[j], C_AMBER); }
    else if (th_mode[j] == 3) { lvglSetText(mlbl[j], "BOOST");  lvglSetTextColor(mlbl[j], C_AMBER); }
    else                      { lvglSetText(mlbl[j], "?");      lvglSetTextColor(mlbl[j], C_SUB); }
}

void sendSet(int j) {
    strToken(idbuf, IDS, '|', j + 1);
    sprintf(cmd, "MaxCmd %s m %d", idbuf, th_val[j]);
    tasmDefer(cmd);                       // event-driven slot: deferred fires from the 50 ms tick
    sprintf(b, "%d", th_val[j]);
    lvglSetText(th_set[j], b);
    th_mode[j] = 1; setModeLabel(j);      // a manual setpoint forces MANUAL mode — reflect it now
}

// toggle AUTO <-> MANUAL from the card button (label shows the current mode)
void toggleMode(int j) {
    strToken(idbuf, IDS, '|', j + 1);
    if (th_mode[j] == 0) {
        sprintf(cmd, "MaxCmd %s m %d", idbuf, th_val[j]);   // AUTO -> MANUAL, hold the current setpoint
        th_mode[j] = 1;
    } else {
        sprintf(cmd, "MaxCmd %s a %d", idbuf, th_val[j]);   // MANUAL/vac/boost -> AUTO (weekly program resumes)
        th_mode[j] = 0;
    }
    tasmDefer(cmd);
    setModeLabel(j);                      // optimistic; the 5 s refresh confirms the real tmode
}

void refreshThermostats() {
    int i = 1;
    while (i <= nth) {
        pluginQuery(qbuf, 126, 18, i);
        webParse(qbuf, ",", 9, f9);
        sprintf(b, "Ist %s", f9);
        lvglSetText(th_meas[i - 1], b);
        webParse(qbuf, ",", 4, f8);                        // field 4 = battery_low
        if (strToInt(f8) > 0) { lvglSetText(bat_th[i - 1], "BAT"); } else { lvglSetText(bat_th[i - 1], ""); }
        webParse(qbuf, ",", 7, f6);                        // field 7 = tmode (0=auto 1=man 2=vac 3=boost)
        th_mode[i - 1] = strToInt(f6); setModeLabel(i - 1);
        i = i + 1;
    }
}

// read window contacts -> update cards + set wopn / open_name
void refreshWindows() {
    wopn = 0;
    int i = 1;
    while (i <= nwn) {
        pluginQuery(qbuf, 126, 1, i);
        webParse(qbuf, ",", 6, f6);
        if (strToInt(f6) > 0) {
            lvglSetText(wn_state[i - 1], "OFFEN"); lvglSetTextColor(wn_state[i - 1], C_RED); lvglSetBgColor(wn_card[i - 1], C_OPENBG);
            wopn = i;
            webParse(qbuf, ",", 1, open_name);
            strReplace(open_name, "AZ_", "Arbeitszimmer ");
            strReplace(open_name, "_", " "); umlaut(open_name);
        } else {
            lvglSetText(wn_state[i - 1], "zu"); lvglSetTextColor(wn_state[i - 1], C_GREEN); lvglSetBgColor(wn_card[i - 1], C_ZUBG);
        }
        webParse(qbuf, ",", 4, f8);                        // field 4 = battery_low
        if (strToInt(f8) > 0) { lvglSetText(bat_wn[i - 1], "BAT"); } else { lvglSetText(bat_wn[i - 1], ""); }
        i = i + 1;
    }
}

void updateAlarmMsg() {
    if (arequest == 2) {
        if (wopn > 0) { sprintf(cmd, "ALARM\n%s offen", open_name); lvglSetText(alarmMsg, cmd); lvglSetTextColor(alarmMsg, C_RED); }
        else { lvglSetText(alarmMsg, "scharf\nalles zu"); lvglSetTextColor(alarmMsg, C_GREEN); }
    } else {
        if (arequest == 1) {
            if (wopn > 0) { sprintf(cmd, "noch offen\n%s", open_name); lvglSetText(alarmMsg, cmd); lvglSetTextColor(alarmMsg, C_AMBER); }
            else { lvglSetText(alarmMsg, "aktiviere..."); lvglSetTextColor(alarmMsg, C_AMBER); }
        } else {
            lvglSetText(alarmMsg, "Anlage aus"); lvglSetTextColor(alarmMsg, C_SUB);
        }
    }
}

// load SMTP creds from /alarm.cfg at boot (weather.tc idiom: line-by-line, skip // comments)
void read_cfg() {
    mail_ok = 0; m_smtp[0] = 0; m_pass[0] = 0; m_to[0] = 0;
    if (fileExists(ALARM_CFG) == 0) { return; }
    int h = fileOpen(ALARM_CFG, 0);
    if (h < 0) { return; }
    int n = fileRead(h, cfgbuf, 510);
    fileClose(h);
    if (n <= 0) { return; }
    cfgbuf[n] = 0;
    int li = 1;
    while (strToken(cfgline, cfgbuf, '\n', li) > 0) {
        strTrim(cfgline);
        if (cfgline[0] != '/' || cfgline[1] != '/') {
            int p = strFind(cfgline, "SMTP="); if (p >= 0) { strSub(m_smtp, cfgline, p + 5, 39); }
            p = strFind(cfgline, "PORT="); if (p >= 0) { strSub(m_port, cfgline, p + 5, 7); }
            p = strFind(cfgline, "USER="); if (p >= 0) { strSub(m_user, cfgline, p + 5, 39); }
            p = strFind(cfgline, "PASS="); if (p >= 0) { strSub(m_pass, cfgline, p + 5, 47); }
            p = strFind(cfgline, "FROM="); if (p >= 0) { strSub(m_from, cfgline, p + 5, 47); }
            p = strFind(cfgline, "TO=");   if (p >= 0) { strSub(m_to,   cfgline, p + 3, 47); }
        }
        li = li + 1;
    }
    if (m_smtp[0] != 0 && m_pass[0] != 0 && m_to[0] != 0) { mail_ok = 1; }
    addLog("alarm: SMTP cfg loaded=%d host=%s", mail_ok, m_smtp);   // host only — never logs user/pass/recipient
}

// proven eqmax_alarm state machine — arm -> "noch offen" -> scharf -> ALARM (TTS/email/siren)
void alarm_tick() {
    if (arm_on != prev_arm) {
        if (arm_on > 0) { arequest = 1; last_warned = 0; }
        else            { arequest = 0; email_sent = 0; last_warned = 0; }
        prev_arm = arm_on;
    }
    if (arequest == 1) {
        if (wopn > 0) {
            if (wopn != last_warned) {                     // announce ONCE per open window
                sprintf(cmd, "I2STTS Fenster %s ist noch offen", open_name);
                tasmDefer(cmd);
                last_warned = wopn;
            }
        } else {
            arequest = 2;                                  // all closed -> armed
            tasmDefer("I2STTS Alarmanlage ist scharf");
        }
    }
    if (arequest == 2) {
        if (wopn > 0) {
            if (mail_ok && email_sent == 0) {              // creds from /alarm.cfg (none in source)
                sprintf(cmd, "sendmail [%s:%s:%s:%s:%s:%s:ALARM]Fenster offen: %s", m_smtp, m_port, m_user, m_pass, m_from, m_to, open_name);
                tasmDefer(cmd);                            // deferred: sendmail must not block
                email_sent = 1;
            }
            audioPlay("/SND/alarm.mp3");                   // siren — short ~5s clip, re-fired every 5s tick = continuous
        }
    }
}

int main() {
    lvglInit();
    lvglClean(0);
    lvglSetBgColor(0, C_BG);
    read_cfg();                      // SMTP creds from /alarm.cfg — no credentials in this source
    char gr[16]; tasmCmd("I2SGain 50", gr);   // set audio gain at boot (else 0=muted -> silent siren/TTS); tune 0-100
    addCommand("MZ");                // console/web test: "MZ" or "MZ m" = send test email, "MZ s" = play siren once

    int title = lvglLabel(0);
    lvglSetText(title, "Heizung & Fenster"); lvglSetTextColor(title, C_TITLE); lvglSetFont(title, 20); lvglSetPos(title, 10, 6);
    int lt = lvglLabel(0); lvglSetText(lt, "Thermostate"); lvglSetTextColor(lt, C_SUB); lvglSetFont(lt, 16); lvglSetPos(lt, TX, 34);
    int lw = lvglLabel(0); lvglSetText(lw, "Fenster");     lvglSetTextColor(lw, C_SUB); lvglSetFont(lw, 16); lvglSetPos(lw, WX, 34);

    pluginQuery(qbuf, 126, 18, 0); nth = strToInt(qbuf); if (nth > 9) { nth = 9; }
    int i = 1;
    while (i <= nth) {
        pluginQuery(qbuf, 126, 18, i);
        webParse(qbuf, ",", 1, nm);
        webParse(qbuf, ",", 8, f8); th_val[i - 1] = strToInt(f8);
        webParse(qbuf, ",", 7, f9); th_mode[i - 1] = strToInt(f9);   // field 7 = tmode
        strReplace(nm, "_", " "); umlaut(nm);
        int x = TX + ((i - 1) % 3) * COFF;
        int y = GY + ((i - 1) / 3) * ROFF;
        makeCard(x, y, CH, nm);
        btn_dn[i - 1] = mkBtn(x + 6,   y + 44, "-");
        btn_up[i - 1] = mkBtn(x + 112, y + 44, "+");
        th_set[i - 1] = lvglLabel(0); lvglSetFont(th_set[i - 1], 34); lvglSetTextColor(th_set[i - 1], C_AMBER); lvglSetPos(th_set[i - 1], x + 58, y + 50);
        sprintf(b, "%d", th_val[i - 1]); lvglSetText(th_set[i - 1], b);
        th_meas[i - 1] = lvglLabel(0); lvglSetFont(th_meas[i - 1], 12); lvglSetTextColor(th_meas[i - 1], C_SUB); lvglSetPos(th_meas[i - 1], x + 8, y + 132);
        bat_th[i - 1] = lvglLabel(0); lvglSetFont(bat_th[i - 1], 14); lvglSetTextColor(bat_th[i - 1], C_RED); lvglSetPos(bat_th[i - 1], x + 120, y + 132);
        btn_mode[i - 1] = lvglButton(0); lvglSetPos(btn_mode[i - 1], x + 37, y + 96); lvglSetSize(btn_mode[i - 1], 84, 26); lvglEventEnable(btn_mode[i - 1], EV_CLICKED);
        mlbl[i - 1] = lvglLabel(btn_mode[i - 1]); lvglSetFont(mlbl[i - 1], 15); lvglAlign(mlbl[i - 1], AL_CENTER, 0, 0);
        setModeLabel(i - 1);
        i = i + 1;
    }

    pluginQuery(qbuf, 126, 1, 0); nwn = strToInt(qbuf); if (nwn > 9) { nwn = 9; }
    if (nwn > MAXWC) { nwn = MAXWC; }
    i = 1;
    while (i <= nwn) {
        pluginQuery(qbuf, 126, 1, i);
        webParse(qbuf, ",", 1, nm); strReplace(nm, "_", " "); umlaut(nm);
        int x = WX + ((i - 1) % 3) * COFF;
        int y = GY + ((i - 1) / 3) * WROFF;
        wn_card[i - 1] = makeCard(x, y, WCH, nm);
        wn_state[i - 1] = lvglLabel(0); lvglSetFont(wn_state[i - 1], 22); lvglSetPos(wn_state[i - 1], x + 18, y + 42);
        bat_wn[i - 1] = lvglLabel(0); lvglSetFont(bat_wn[i - 1], 14); lvglSetTextColor(bat_wn[i - 1], C_RED); lvglSetPos(bat_wn[i - 1], x + 120, y + 44);
        i = i + 1;
    }

    // ---- alarm panel ----
    int la = lvglLabel(0); lvglSetText(la, "Alarmanlage"); lvglSetTextColor(la, C_SUB); lvglSetFont(la, 16); lvglSetPos(la, WX, 360);
    int ac = lvglObj(0);
    lvglSetPos(ac, WX, 386); lvglSetSize(ac, 495, 196);
    lvglSetBgColor(ac, C_CARD); lvglSetStyleInt(ac, ST_RADIUS, 10);
    lvglSetStyleInt(ac, ST_BORDER, 1); lvglSetStyleInt(ac, ST_BOPA, 255); lvglSetStyleInt(ac, ST_BCOLOR, C_CARDB);
    sw = lvglSwitch(0);
    lvglSetPos(sw, WX + 24, 414); lvglEventEnable(sw, EV_VALCHG);
    int sl = lvglLabel(0); lvglSetText(sl, "scharf"); lvglSetTextColor(sl, C_TITLE); lvglSetFont(sl, 18); lvglSetPos(sl, WX + 96, 418);
    alarmMsg = lvglLabel(0); lvglSetFont(alarmMsg, 30); lvglSetPos(alarmMsg, WX + 24, 478);

    refreshThermostats();
    refreshWindows();
    updateAlarmMsg();
    return 0;
}

void Every100ms() {
    while (lvglEvent()) {
        int o = lvglEventObj();
        int code = lvglEventCode();
        if (o == sw) { arm_on = lvglIsChecked(sw); tick = 5; }   // force the alarm to process next second
        else {
            if (code == EV_CLICKED) {
                int j = 0;
                while (j < nth) {
                    if (o == btn_up[j]) { th_val[j] = th_val[j] + 1; if (th_val[j] > 30) { th_val[j] = 30; } sendSet(j); }
                    if (o == btn_dn[j]) { th_val[j] = th_val[j] - 1; if (th_val[j] < 5)  { th_val[j] = 5; }  sendSet(j); }
                    if (o == btn_mode[j]) { toggleMode(j); }
                    j = j + 1;
                }
            }
        }
    }
}

void EverySecond() {
    // mirror the door switch (galarm, UDP-shared) <-> the app's arm state
    int g = 0; if (galarm > 0.5) { g = 1; }
    if (g != prev_g) {                                  // changed on the door side -> app follows
        arm_on = g; lvglSetChecked(sw, arm_on); prev_g = g; tick = 5;
    } else {
        int a = 0; if (arm_on > 0) { a = 1; }
        if (a != prev_g) { galarm = a; prev_g = a; }    // changed in the app -> broadcast to the door
    }

    // presence-based backlight — VL53L0X distance published by slot 3 into the share store
    float vd = shareGetFloat("vldist");
    if (vd > 0.5 && vd < NEAR_MM) {                     // person nearby -> full brightness immediately
        idle_s = 0;
        if (dim_now != 100) { tasmDefer("DisplayDimmer 100"); dim_now = 100; addLog("dim: person %.0f mm -> 100", vd); }
    } else {                                            // nobody near -> dim after a minute
        idle_s = idle_s + 1;
        if (idle_s >= 60 && dim_now != 10) { tasmDefer("DisplayDimmer 10"); dim_now = 10; addLog("dim: idle 60s -> 10"); }
    }

    tick = tick + 1;
    if (tick >= 5) {
        tick = 0;
        refreshThermostats();
        refreshWindows();
        alarm_tick();
        updateAlarmMsg();
        lvglSetChecked(sw, arm_on);     // keep the LVGL switch in sync with the web checkbox
    }
}

// web main-page card: arm checkbox + live status (bonus — control from a browser too)
void WebCall() {
    webCheckbox(arm_on, "Alarmanlage scharf");
    if (arequest == 2) { webSend("{s}Alarm{m}<b style='color:#3fb950'>scharf</b>{e}"); }
    else {
        if (arequest == 1) { webSend("{s}Alarm{m}<span style='color:#e0a84e'>aktiviere...</span>{e}"); }
        else { webSend("{s}Alarm{m}aus{e}"); }
    }
    if (wopn > 0) { sprintf(cmd, "{s}Fenster{m}<span style='color:#e0a84e'>%s offen</span>{e}", open_name); webSend(cmd); }
    else { webSend("{s}Fenster{m}alle zu{e}"); }
}

// Test command (verify siren + email without arming + opening a window):
//   MZ   or  MZ m   -> send a test alarm email (same creds/path as the real alarm)
//   MZ s            -> play the siren (/SND/alarm.mp3) once
void Command(char carg[]) {
    int i = 0; while (carg[i] == ' ') { i = i + 1; }
    if (carg[i] == 's' || carg[i] == 'S') {            // MZ s -> siren test
        audioPlay("/SND/alarm.mp3");
        responseCmnd("siren test (alarm.mp3)"); return;
    }
    if (mail_ok) {                                     // MZ / MZ m -> email test (same sendmail as the alarm)
        sprintf(cmd, "sendmail [%s:%s:%s:%s:%s:%s:MORITZ TEST]Test-Mail von der Alarmanlage", m_smtp, m_port, m_user, m_pass, m_from, m_to);
        tasmDefer(cmd);
        responseCmnd("test email queued - check inbox + console log");
    } else {
        responseCmnd("mail_ok=0 - no creds loaded from /alarm.cfg");
    }
}