Zum Inhalt

webcam_tinyc.tc

webcam_tinyc.tc — Security Camera using TinyC integrated cam driver

Source on GitHub

// @name: Webcam Security (TinyC Driver)
// ═══════════════════════════════════════════════════════════════════
// webcam_tinyc.tc — Security Camera using TinyC integrated cam driver
// No Tasmota USE_WEBCAM dependency — uses cameraInit + camControl 8-20
//
// Features: MJPEG stream on port 81, motion detection (pixel frame-diff,
//           firmware-side, with a brightness gate and a sustain count),
//           optional person detection through ESP-DL as a CONFIRMATION of that
//           motion, PIR alarm, picture saving, email alerts with up to 4
//           pictures, timelapse, auto-cleanup (14-day rotation)
//
// Build switches beyond the board:
//   -DHAS_LUX_CHART   6 h lux chart on the main page. OFF by default — it costs
//                     1440 bytes of ring plus a redraw on every page build, and
//                     the light value is already there as a number.
//   HAS_PERSON        person detection, on for the DFRobot board. Needs a
//                     firmware with -DUSE_TINYC_ESPDL and the model as a file;
//                     without either, camControl(21,…) just returns -1.
//
// Compile: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_DFROBOT
//      or: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_GOOUUU
//      or: node compile_cli.js webcam_tinyc.tc webcam_tinyc.tcb -DBOARD_AITHINKER
//
// Camera capture runs in TaskLoop (VM task thread) — required for
// esp_camera_fb_get() which crashes if called from main thread.
// ═══════════════════════════════════════════════════════════════════
// @defines: -DBOARD_DFROBOT

// ─── Board-specific camera pins ───
// Order: pwdn, reset, xclk, sda, scl, d7, d6, d5, d4, d3, d2, d1, d0, vsync, href, pclk
#ifdef BOARD_DFROBOT
// DFRobot Firebeetle 2 ESP32-S3 AI CAM DFR1154 (OV3660)
int campins[] = {-1, -1, 5, 8, 9, 4, 6, 7, 14, 17, 21, 18, 16, 1, 2, 15};
// ⚠️ NIGHT VISION ONLY HERE. It needs three things the other boards lack:
// four 940 nm IR LEDs on GPIO47 (Relay1), an LTR-308 light sensor on I2C 0x53,
// and an OV3660 — the exposure-limit registers 0x3A02/0x3A14 written below
// do not exist on the OV2640; on an AI-Thinker, Relay1 is the WHITE flash LED
// and "automatic" would leave it burning all night (gemu 05.09.2026).
#define HAS_NIGHT
// Person detection through ESP-DL. Needs a firmware built with USE_TINYC_ESPDL
// and the model as a file; without either, camControl(21,…) simply returns -1.
#define HAS_PERSON
#endif

#ifdef BOARD_GOOUUU
// Goouuu ESP32-S3-CAM / ESP32S3_EYE (OV2640)
int campins[] = {-1, -1, 15, 4, 5, 16, 17, 18, 12, 10, 8, 9, 11, 6, 7, 13};
#endif

#ifdef BOARD_AITHINKER
// AI-Thinker ESP32-CAM (OV2640), flash LED on GPIO4
int campins[] = {32, -1, 0, 26, 27, 35, 34, 39, 36, 21, 19, 18, 5, 25, 23, 22};
#endif

// ─── Camera constants ───
#define PIXFORMAT_JPEG      4
// ⚠️ DIESE ZAHLEN WAREN UM ZWEI VERSCHOBEN. Sie stammten aus der klassischen
// framesize_t; der esp32-camera-Zweig in diesem Baum hat zwei Einträge mehr
// (128X128 an 2, 320X320 an 7), siehe lib/lib_div/esp32-camera/driver/include/
// sensor.h. FRAMESIZE_VGA 8 lieferte deshalb CIF 400x296 — am echten Bild
// gemessen (http://<ip>:81/cam.jpg, SOF-Marker), nicht aus der Tabelle
// abgeschrieben.
#define FRAMESIZE_QVGA      6
#define FRAMESIZE_CIF       8
#define FRAMESIZE_VGA       10
#define FRAMESIZE_SVGA      11
#define FRAMESIZE_XGA       12
#define FRAMESIZE_HD        13
#define FRAMESIZE_SXGA      14
#define FRAMESIZE_UXGA      15

// ─── Resolution picker ───
// webPulldown hands back a 0-based INDEX, the sensor wants a FRAMESIZE_*.
// The list is deliberately short: below QVGA the picture is useless for
// motion detection, and the OV2640 tops out at UXGA.
#define RES_COUNT           10
#define RES_FIRST           6       // index 0 = FRAMESIZE_QVGA 320x240
#define RES_DEFAULT         4       // index 4 = FRAMESIZE_VGA 640x480

// Sensor parameter IDs for camControl(9, param, value)
#define CAM_VFLIP           0
#define CAM_BRIGHTNESS      1
#define CAM_SATURATION      2
#define CAM_HMIRROR         3
#define CAM_FRAMESIZE       5
#define CAM_QUALITY         6

// Known sensor PIDs
#define OV2640_PID          0x2642
#define OV3660_PID          0x3660
#define OV5640_PID          0x5640

// ─── Hardware config ───
#define PIR_PIN             -1

// How often the firmware runs its frame-diff (ms). Each run decodes the
// JPEG in cam slot 1 to RGB888 in PSRAM, so this is not free. 500 ms keeps
// a fresh reading available for the 1 Hz evaluation in EverySecond without
// the two cadences beating against each other.
#define MOTION_MS           500

// ─── Persistent settings (survive reboot) ───
// 'watch' enables changed()/snapshot() intrinsics — no manual shadow copies needed
persist watch int limit;      // motion threshold, mean |frame difference| x 100
persist watch int blim;       // brightness gate: discard a reading whose mean
                              // brightness jumped by more than this x 100
persist watch int mrun;       // seconds of sustained motion before it counts
#ifdef HAS_PERSON
persist watch int pdet;       // 1 = have the neural net confirm the motion first
persist watch int pthr;       // the net's score threshold x100
#endif
persist watch int pir;        // hardware PIR enabled
persist watch int ma;         // send email on alarm
persist watch int spir;       // software motion detector enabled
persist watch int rpt;        // take picture every N minutes (0=off)
persist watch int ufl;        // use flash LED
persist watch int mtl;        // merge pictures into timelapse mjpeg
persist watch int npic;       // number of pictures per alarm email (1-4)
persist watch int res;        // resolution, INDEX into the pulldown (not FRAMESIZE_*)
#ifdef HAS_NIGHT
// ─── Night vision ───
// ⚠️ WHY THE DARK PICTURE WAS BAD (measured 05.09.2026 on the DFR1154):
//   * the four 940 nm IR LEDs sit on GPIO47 = Relay1, and the script only
//     flashed them for 150 ms when taking a picture — never for the stream;
//   * the sensor's AEC sat at BOTH factory ceilings: exposure 0x0930 of max
//     0x0930, gain 0xF8 of max 0xF8. Night mode (0x3A00 bit 2) was already on,
//     but it may not go anywhere. Raising the limits made the AEC take all the
//     exposure it was given and drop gain from 15.5x to 5.4x.
// The LTR-308 light sensor on the board decides "dark" in the automatic modes.
persist watch int ir_mode;    // IR light: 0 off, 1 on, 2 automatic (LTR-308)
persist watch int ir_lux;     // automatic: below this it is "dark" (lux)
persist watch int nacht;      // night sensor mode: 0 off, 1 on, 2 automatic
persist watch int nacht_st;   // strength index: 0 x2/8x, 1 x4/8x, 2 x8/16x
persist int nacht_seen;       // defaults written once (index 0 is a legal value)
#endif
persist int res_seen;         // ⚠️ 1 once `res` was ever stored. Needed because
                              // index 0 is a LEGAL choice (QVGA): without this
                              // marker, "0 means unset" would make QVGA
                              // impossible to select — it would snap back to
                              // VGA on every restart.

// ─── Global state ───
int mot;                // current motion value (JPEG size diff)
int alarm_active;       // 1 = alarm triggered, wait for motion to stop
int mailout;            // mail sent flag
int take_pic;           // button: take picture now
int del_all;            // button: delete all pictures
int stream_on;          // stream started flag
int last_hour;          // for midnight detection
int rpt_timer;          // countdown for periodic capture (seconds)
int cam_ok;             // camera initialized
int mbright;            // current mean brightness x 100 (camControl 17,1)
int mjump;              // brightness change against the previous second
int last_bri;           // previous brightness reading, -1 = none yet
int mnet;               // motion with the global light change taken out
int mpeak;              // highest corrected value seen since the last change
                        // of settings — this is the number to set the
                        // threshold above while nothing is happening
int last_mot;           // previous raw reading, to spot a NEW measurement
int mstreak;            // consecutive NEW readings above the threshold
int veto_count;         // readings thrown away because the light changed
int mot_on;             // 1 = firmware frame-diff enabled
#ifdef HAS_PERSON
int person_pruefen;     // set by EverySecond, worked off in TaskLoop
int person_n;           // hits in the last run
int person_score;       // best score x100
int person_x;
int person_y;
int person_w;
int person_h;
int person_zuletzt;     // millis() of the last positive run, 0 = never yet
int person_gesamt;      // counter since start
int person_ms;          // compute time of the last run
#endif
int frame_count;        // total frames captured
int motion_detect;      // total motion detections
int doCapture;          // button: capture from web UI
int doHiRes;            // button: hi-res capture from web UI

char buf[256];
char fnam[80];
char tmp[64];

// ═══════════════════════════════════════════════════════════════════
// Initialize camera with board-specific pins
// ═══════════════════════════════════════════════════════════════════
int initCamera() {
    int ok;

    // ⚠️ Mit der GESPEICHERTEN Auflösung starten, nicht mit VGA: sonst steht
    // nach jedem Neustart wieder VGA im Sensor, während das Klappmenü etwas
    // anderes anzeigt.
    ok = cameraInit(campins, PIXFORMAT_JPEG, res_framesize(), 12, 0, 0, -1, -1);
    if (ok != 0) {
        print(ok);
        addLog("TCC: camera init FAILED");
        return -1;
    }
    addLog("TCC: camera initialized OK");

    // Check sensor type and apply sensor-specific settings
    int pid = camControl(8, 0, 0);
    addLog("TCC: sensor PID = 0x%x", pid);

    if (pid == OV3660_PID) {
        camControl(9, CAM_VFLIP, 1);
        camControl(9, CAM_BRIGHTNESS, 1);
        camControl(9, CAM_SATURATION, -2);
        addLog("TCC: OV3660 settings applied");
    }

    return 0;
}

// ═══════════════════════════════════════════════════════════════════
// Capture to PSRAM slot (1 = stream, 2-4 = pictures)
// Must run in TaskLoop (VM task thread)
// ═══════════════════════════════════════════════════════════════════
int captureToSlot(int slot) {
    int size;
    size = camControl(10, slot, 0);
    if (size <= 0) {
        return -1;
    }
    frame_count = frame_count + 1;
    return size;
}

// ═══════════════════════════════════════════════════════════════════
// Log message with timestamp
// ═══════════════════════════════════════════════════════════════════
// Index of the pulldown -> FRAMESIZE_* the sensor understands.
// ⚠️ Clamped: `res` comes from persistent storage, and a value from an older
// version (or a corrupted one) would otherwise configure the sensor with
// nonsense — the camera then delivers nothing and the reason is invisible.
// Breite und Hoehe der gewaehlten Auflösung.
//
// ⚠️ NICHT camControl(3/4, ...) FRAGEN. Die Selektoren 0..7 gehören zum
// Tasmota-Webcam-Treiber; dieser Bau hat nur den TinyC-eigenen (8..18), und
// dann liefern sie -1. Auf der Einstellseite stand deshalb "-1x-1".
// Die Maße stehen ohnehin fest -- es ist dieselbe Liste wie im Klappmenü.
void res_masse() {
    int i = res;
    if (i < 0) { i = RES_DEFAULT; }
    if (i >= RES_COUNT) { i = RES_DEFAULT; }
    if (i == 0) { cam_w = 320;  cam_h = 240; }
    if (i == 1) { cam_w = 320;  cam_h = 320; }
    if (i == 2) { cam_w = 400;  cam_h = 296; }
    if (i == 3) { cam_w = 480;  cam_h = 320; }
    if (i == 4) { cam_w = 640;  cam_h = 480; }
    if (i == 5) { cam_w = 800;  cam_h = 600; }
    if (i == 6) { cam_w = 1024; cam_h = 768; }
    if (i == 7) { cam_w = 1280; cam_h = 720; }
    if (i == 8) { cam_w = 1280; cam_h = 1024; }
    if (i == 9) { cam_w = 1600; cam_h = 1200; }
}

int res_framesize() {
    int i = res;
    if (i < 0) { i = RES_DEFAULT; }
    if (i >= RES_COUNT) { i = RES_DEFAULT; }
    return RES_FIRST + i;
}

void log_msg(char msg[]) {
    char line[128];
    timeStamp(line);
    strcat(line, " - ");
    strcat(line, msg);
    fileLog("/log.txt", line, 8192);
}

#ifdef HAS_NIGHT
// ─── LTR-308 ambient light sensor (on the board, I2C 0x53) ───
// Gain 18x, 20 bit / 400 ms (measurement rate 500 ms):
//     lux = 0.6 * raw / (18 * 0.4) = raw / 12      -> 0.083 lx per count
// ⚠️ WHY NOT ltr308.tc's 3x / 100 ms: there one count is 2 lx, so the reading
// is always an even integer and the decimal place never moves — "in der
// Praxis sehe ich nie eine Kommastelle" (gemu 05.09.2026). That was the
// setting, not the sensor: it resolves 0.01 lx with enough gain and time.
// This camera decides "dark" around 10 lx; fine steps THERE are what count.
// Ceiling: 2^20 counts / 12 = 87,000 lx — daylight indoors is far below.
// ⚠️ The first version also divided by 5 with integer maths (0.1 s taken for
// 1.0 s) and showed "0 lx" next to ltr308.tc's 4.0.
// ⚠️ ONE DRIVER PER SENSOR. This script owns the LTR-308 now; ltr308.tc must
// not run alongside it. Two readers race for the data-ready bit (whoever
// reads first clears it for the other) and both rewrite the configuration.
// That, not the sensor, produced "0 lx" and "4.0 lux" on the same page.
int ltr_finden() {
    int bus = 0;
    while (bus < 2) {
        i2cSetDevice(0x53, bus);
        int id = i2cRead8(0x53, 0x06, bus);
        if (id == 0xB1) {
            i2cWrite8(0x53, 0x00, 0x10, bus);       // soft reset
            delay(100);
            i2cWrite8(0x53, 0x04, 0x03, bus);       // 20 bit / 400 ms, rate 500 ms
            i2cWrite8(0x53, 0x05, 0x04, bus);       // gain 18x
            i2cWrite8(0x53, 0x00, 0x02, bus);       // ALS enable
            addLog("TCC: LTR-308 found on I2C bus %d", bus);
            return bus;
        }
        bus++;
    }
    addLog("TCC: no LTR-308 — automatic modes fall back to 'on'");
    return -1;
}

// Returns 1 when a fresh reading was taken.
int ltr_lesen() {
    if (ltr_bus < 0) return 0;
    int st = i2cRead8(0x53, 0x07, ltr_bus);
    if (!(st & 0x08)) return 0;                    // no new data yet
    if (!i2cRead(0x53, 0x0D, ltr_buf, 3, ltr_bus)) return 0;
    int raw = (ltr_buf[0] & 0xFF) | ((ltr_buf[1] & 0xFF) << 8) | ((ltr_buf[2] & 0x0F) << 16);
    lux = (float)raw / 12.0;
    return 1;
}

// Night sensor settings — MUST run in TaskLoop (camera access).
//
// ⚠️ THE HORIZONTAL STRIPES IN DIM LIGHT (the "unsolved" DFR1154 issue) have a
// measured cause: the IR LEDs inject a ripple (period ~8 rows) that HIGH GAIN
// makes visible. Stripe measure (std of the high-passed row profile), same
// scene, same brightness, 05.09.2026:
//     IR on,  gain 21x, 1-frame exposure   4.4   moving pattern
//     IR off, gain 21x                      2.45  static remainder
//     IR on,  gain  2x, 3-frame exposure    2.4   static remainder
// The AEC fills one frame, then climbs gain up to the ceiling, and only
// then inserts frames (night mode). With a ceiling <= 16x it went to 3-frame
// exposure at 2x gain; at 24x it chose 1 frame at 21x — and the stripes.
// So the GAIN CEILING is the lever, not the exposure limit: keep it low and
// let exposure do the work. Price: frame rate in the dark.
// ⚠️ Writing a LOWER max exposure does not pull a running exposure down: the
// AEC only honours the limit while increasing. Toggling night mode (aec2)
// off/on makes it re-converge — measured, exposure went 0x2490 -> 0x0930.
void nacht_anwenden(int an) {
    // Belt and braces: the registers below are OV3660's. Should this build
    // ever run with another sensor, leave it alone.
    if (camControl(8, 0, 0) != 0x3660) {
        addLog("TCC: night sensor mode skipped, sensor is not an OV3660");
        nacht_ist = an;
        return;
    }
    if (an) {
        int gc = 0x80;   int hi = 0x12; int lo = 0x60;    // x2 exposure, 8x gain
        if (nacht_st == 1) { gc = 0x80;  hi = 0x24; lo = 0x90; }   // x4, 8x
        if (nacht_st == 2) { gc = 0x100; hi = 0x49; lo = 0x20; }   // x8, 16x
        camControl(9, 18, gc);
        camControl(20, 0x3a02, hi); camControl(20, 0x3a03, lo);
        camControl(20, 0x3a14, hi); camControl(20, 0x3a15, lo);
    } else {
        camControl(9, 18, 0xF8);                             // factory 15.5x
        camControl(20, 0x3a02, 0x09); camControl(20, 0x3a03, 0x30);
        camControl(20, 0x3a14, 0x09); camControl(20, 0x3a15, 0x30);
    }
    camControl(9, 13, 0); delay(300); camControl(9, 13, 1);   // AEC re-converge
    nacht_ist = an;
    addLog("TCC: night sensor mode %d (strength %d)", an, nacht_st);
}
#endif

// ─── Flash LED helpers ───
//
// ⚠️⚠️ DAS SKRIPT DARF DEN SCHALTER NICHT UEBERSTIMMEN. `flash_off` setzte im
// Nicht-Nacht-Zweig hart `tasm_power = 0` — und weil um JEDE Aufnahme herum
// geblitzt wird (Bewegung, Zeitraffer, Knopf), schaltete jede Aufnahme das
// Relais aus. Wer es von Hand einschaltete, sah es Sekunden spaeter wieder
// ausgehen: „der on/off switch scheint nicht mehr zu gehen" (gemu 06.09.2026,
// auf .88 mit BOARD_GOOUUU).
//
// ⚠️ Aufgefallen ist es erst, als der Praeprozessor richtig rechnete. Vorher
// war HAS_NIGHT wegen des @defines-Pragmas IMMER an, also lief hier
// `tasm_power = ir_soll`, und ohne LTR-308 ist „Automatik" = ein — das Relais
// blieb an, und niemand merkte etwas. Der Fehler stand seit jeher im
// #else-Zweig, den nie jemand uebersetzt hat.
//
// Jetzt wird zurueckgestellt, was VOR dem Blitzen da war. Im Nacht-Zweig
// bleibt es beim Wunsch der Nachtlogik: dort gehoert das Relais ihr.
int flash_vorher;

void flash_on() {
    if (ufl > 0) {
        flash_vorher = tasm_power;
        tasm_power = 1;
        delay(150);
    }
}

void flash_off() {
    if (ufl > 0) {
#ifdef HAS_NIGHT
        tasm_power = ir_soll;
#else
        tasm_power = flash_vorher;
#endif
    }
}

// ═══════════════════════════════════════════════════════════════════
// Build timestamped filename: /PICS/27_2_2026-14_30_05.jpg
// ═══════════════════════════════════════════════════════════════════
void build_pic_name() {
    sprintf(fnam, "/PICS/%d_%d_%d-%d_%d_%d.jpg", tasm_day, tasm_month, tasm_year, tasm_hour, tasm_minute, tasm_second);
}

// ═══════════════════════════════════════════════════════════════════
// Save picture from PSRAM slot 2 to timestamped file
// Capture must have been done in TaskLoop before calling this
// ═══════════════════════════════════════════════════════════════════
void save_pic_from_slot(int slot) {
    build_pic_name();
    int fh = fileOpen(fnam, 'w');
    if (fh >= 0) {
        int written = camControl(11, slot, fh);
        fileClose(fh);
        sprintf(buf, "TCC: saved %d bytes to ", written);
        strcat(buf, fnam);
        addLog(buf);
    } else {
        addLog("TCC: file io error");
    }
}

// ═══════════════════════════════════════════════════════════════════
// Append picture from PSRAM slot to timelapse mjpeg
// ═══════════════════════════════════════════════════════════════════
void save_mjpeg_from_slot(int slot) {
    int fh = fileOpen("/PICS/tlapse.mjpeg", 'a');
    if (fh >= 0) {
        camControl(11, slot, fh);
        fileClose(fh);
    } else {
        addLog("TCC: timelapse file io error");
    }
}

// ═══════════════════════════════════════════════════════════════════
// Delete all pictures in /PICS
// ═══════════════════════════════════════════════════════════════════
void del_folder() {
    log_msg("delete all pictures");
    char entry[48];
    char path[80];
    int dh = fileOpenDir("/PICS");
    if (dh >= 0) {
        while (fileReadDir(dh, entry)) {
            strcpy(path, "/PICS/");
            strcat(path, entry);
            fileDelete(path);
        }
        fileClose(dh);
    }
}

// ═══════════════════════════════════════════════════════════════════
// Delete outdated files (14 days back)
// ═══════════════════════════════════════════════════════════════════
void del_old() {
    log_msg("delete outdated pictures");

    char ts[24];
    timeStamp(ts);
    timeOffset(ts, -14, 1);
    char dyear[8];
    char dmonth[4];
    char dday[4];
    strToken(dyear, ts, '-', 1);
    strToken(dmonth, ts, '-', 2);
    char rest[16];
    strToken(rest, ts, '-', 3);
    strToken(dday, rest, 'T', 1);
    char cutoff[16];
    strcpy(cutoff, dday);
    strcat(cutoff, "_");
    strcat(cutoff, dmonth);
    strcat(cutoff, "_");
    strcat(cutoff, dyear);

    char entry[48];
    char path[80];
    char fdate[16];
    int dh = fileOpenDir("/PICS");
    if (dh >= 0) {
        while (fileReadDir(dh, entry)) {
            strToken(fdate, entry, '-', 1);
            if (strcmp(fdate, cutoff) == 0) {
                strcpy(path, "/PICS/");
                strcat(path, entry);
                fileDelete(path);
            }
        }
        fileClose(dh);
    }
}

// ═══════════════════════════════════════════════════════════════════
// Send alarm email with up to 4 pictures
// Pictures were captured to slots 1-4 in TaskLoop
// ═══════════════════════════════════════════════════════════════════
int alarm_pics_pending;    // number of alarm pics still to capture
int alarm_pics_captured;   // number captured so far
int do_res;                // resolution change requested, carried out in TaskLoop
int cam_w;                 // current width  — see res_masse()
int cam_h;                 // current height
#ifdef HAS_NIGHT
int ir_soll;               // IR light wanted now (0/1), from ir_mode + darkness
int nacht_soll;            // night sensor settings wanted now (0/1)
int nacht_ist;             // ... and what is actually applied in the sensor
int do_nacht;              // apply nacht_soll in TaskLoop (camera access there only)
int dunkel;                // LTR-308 says dark (with hysteresis)
float lux;                 // last LTR-308 reading, lux
int ltr_bus;               // I2C bus the LTR-308 answered on, -1 = not found
char ltr_buf[4];
// ─── Lux chart on the main page — OFF unless built with -DHAS_LUX_CHART ───
// 6 h at one sample a minute, taken over from ltr308.tc (that script is gone,
// the camera owns the sensor). Most of the time you do not want this: it costs
// 1440 bytes for the ring plus a redraw on every page build, and the light
// value is already there as a number in the sensor block.
#ifdef HAS_LUX_CHART
#define LUX_LEN 360
float hist_lux[LUX_LEN];
int hist_pos;
int hist_tick;
#endif
#endif

void send_alarm_email() {
    if (ma == 0) { return; }

    log_msg("motion alarm email");

    char body[64];
    strcpy(body, "Motion alarm<br>");
    mailBody(body);

    // Attach captured pictures (slots 1..npic)
    int i = 1;
    while (i <= npic) {
        mailAttachPic(i);
        // Also save to filesystem
        save_pic_from_slot(i);
        i = i + 1;
    }

    char params[128];
    strcpy(params, "[*:*:*:*:*:your@email.com:Alarm]");
    mailSend(params);
    mailout = 1;
}

// ═══════════════════════════════════════════════════════════════════
// TaskLoop — runs in VM task (Core 1)
// Camera capture MUST run here, not in EverySecond
// ═══════════════════════════════════════════════════════════════════
void TaskLoop() {
    if (cam_ok != 1) {
        delay(100);
        return;
    }

    // Arm the firmware frame-diff once the camera is up. Threshold 0 on
    // purpose: the firmware then only MEASURES and never sets its own
    // `triggered` flag, so the whole alarm policy stays here in the script.
    // Done from TaskLoop because everything that touches the camera has to
    // run in the VM task thread.
    if (mot_on == 0) {
        camControl(16, MOTION_MS, 0);
        mot_on = 1;
        addLog("TCC: frame-diff motion detect on, every %d ms", MOTION_MS);
    }

#ifdef HAS_PERSON
    // Work off a pending check — here in the VM task, not in EverySecond.
    if (person_pruefen == 1) {
        person_pruefen = 0;
        int t = millis();
        int n = camControl(21, pthr, 0);
        person_ms = millis() - t;
        if (n < 0) {
            person_n = 0;
            addLog("TCC: person detect error %d", n);
        } else {
            person_n = n;
            if (n > 0) {
                person_score = camControl(22, 1, 0);
                person_x = camControl(22, 2, 0);
                person_y = camControl(22, 3, 0);
                person_w = camControl(22, 4, 0);
                person_h = camControl(22, 5, 0);
                person_zuletzt = millis();
                person_gesamt = person_gesamt + 1;
                addLog("TCC: person detected, %d%% at %d,%d (%d ms)",
                       person_score, person_x, person_y, person_ms);
            }
        }
    }
#endif

    // Continuous capture to slot 1 for streaming.
    // ⚠️ Script slot 1 IS the buffer the firmware detector reads
    // (TC_SendCamSlotAsJpeg(slot - 1) -> tc_cam_slot[0]), so this one capture
    // feeds the stream AND the motion detection. No second grab needed.
    // The old detector compared JPEG BYTE SIZES here — it fired on anything
    // that changed scene complexity (a cloud, rain, the IR LEDs) and its
    // threshold had to be retuned after every resolution change.
    captureToSlot(1);

    // Alarm picture capture (triggered from EverySecond)
    if (alarm_pics_pending > 0) {
        flash_on();
        int slot = alarm_pics_captured + 1;
        captureToSlot(slot);
        alarm_pics_captured = alarm_pics_captured + 1;
        alarm_pics_pending = alarm_pics_pending - 1;
        flash_off();

        if (alarm_pics_pending == 0) {
            // All alarm pics captured — send email from EverySecond
            send_alarm_email();
        }
    }

    // Single picture capture via web button
    if (doCapture == 1) {
        doCapture = 0;
        flash_on();
        captureToSlot(2);
        save_pic_from_slot(2);
        flash_off();
    }

#ifdef HAS_NIGHT
    if (do_nacht == 1) {
        do_nacht = 0;
        nacht_anwenden(nacht_soll);
    }
#endif

    // ⚠️ RESOLUTION CHANGE BELONGS HERE, NOT IN EverySecond(). Anything that
    // touches the camera has to run in the VM task thread — from the main
    // thread it freezes the device (see the TinyC reference on camControl).
    // EverySecond only sets the flag.
    //
    // The stream is stopped around it: it hands out frames from the buffer
    // that is being resized underneath it.
    if (do_res == 1) {
        do_res = 0;
        camControl(15, 0, 0);                       // stop MJPEG server
        delay(200);
        camControl(9, CAM_FRAMESIZE, res_framesize());
        delay(300);                                 // sensor needs a moment
        res_masse();
        camControl(15, 1, 0);                       // and back on
        camControl(18, 0, 0);                       // reference frame is void now
        mot_on = 0;                                 // re-arm with the new size
        mstreak = 0;
        last_bri = -1;
        last_mot = -1;
        addLog("TCC: resolution now %d (%dx%d)", res_framesize(), cam_w, cam_h);
    }

    // Hi-res capture via web button
    if (doHiRes == 1) {
        doHiRes = 0;
        flash_on();
        camControl(9, CAM_FRAMESIZE, FRAMESIZE_UXGA);
        delay(500);
        captureToSlot(2);
        save_pic_from_slot(2);
        // ⚠️ BACK TO THE CHOSEN RESOLUTION, not to a hard-wired VGA — that
        // silently overrode the setting on the first hi-res shot.
        camControl(9, CAM_FRAMESIZE, res_framesize());
        flash_off();
    }

    // Periodic capture
    if (take_pic == 2) {
        take_pic = 0;
        flash_on();
        captureToSlot(2);
        if (mtl > 0) {
            save_mjpeg_from_slot(2);
        } else {
            save_pic_from_slot(2);
        }
        flash_off();
    }

    delay(100);
}

// ═══════════════════════════════════════════════════════════════════
// EverySecond — main thread, lightweight checks only
// ═══════════════════════════════════════════════════════════════════
void EverySecond() {
    // Delete old pictures at midnight
    if (tasm_hour == 0 && last_hour != 0) {
        del_old();
    }
    last_hour = tasm_hour;

    // ── Motion evaluation, once a second ────────────────────────────
    // camControl 17 only reads two numbers the firmware already computed, so
    // it is safe outside the VM task. sel 0 = mean |difference| x 100 against
    // the previous frame, sel 1 = mean brightness x 100. Both are whole-frame
    // averages: there is no region and no position.
    int raw = camControl(17, 0, 0);
    int bri = camControl(17, 1, 0);

    // ⚠️ THE DETECTOR IS MUCH SLOWER THAN THIS CALLBACK — AND THIS MUST NOT
    // BE AN EARLY `return`: the periodic timelapse capture and the
    // delete-all button live further down in EverySecond.
    // Measured on the DFR1154 on 16.09.2026: asked for a reading every
    // 500 ms, it actually delivers one every ~3 s on average and sometimes
    // only after 9 s — decoding the JPEG to RGB888 costs that much. So the
    // same numbers come back several times in a row. Counting those repeats
    // as "sustained motion" defeats the sustain test completely (seen live:
    // streak 7 with the picture standing still), and the light correction
    // would compare a frame against itself. Only a CHANGED reading counts.
    if (raw != last_mot || bri != mbright) {
        last_mot = raw;
        mot = raw;
        mjump = bri - last_bri;
        if (mjump < 0) { mjump = 0 - mjump; }
        // No previous reading, or the firmware has not measured yet (it
        // reports brightness 0 until its first comparison) — nothing to
        // compare against.
        if (last_bri <= 0 || bri == 0) { mjump = 0; }
        last_bri = bri;
        mbright = bri;

        // ⚠️ SUBTRACT THE LIGHT CHANGE, DON'T JUST VETO ON IT.
        // Shift every pixel by the same Δ and the mean |difference| is |Δ|,
        // so a global light change enters the motion value almost one to one.
        // Measured here on 16.09.2026, seconds apart with nothing moving:
        //   motion 4222 / jump 4019, 5157 / 5098, 7321 / 7245.
        // That is the auto-exposure hunting, not an intruder. Throwing those
        // readings away (the first version of this did) blinded the camera
        // for most of its readings; taking the jump out leaves 203, 59, 76 —
        // a usable signal, and it keeps working while the light settles.
        mnet = mot - mjump;
        if (mnet < 0) { mnet = 0; }
        if (mnet > mpeak) { mpeak = mnet; }

        // The subtraction is only first-order: once a jump is big enough to
        // clip parts of the picture it is no longer a uniform shift, so a
        // really violent light change still throws the reading away.
        if (blim > 0 && mjump > blim) {
            veto_count = veto_count + 1;
            mstreak = 0;
        } else {
            if (mnet > limit) {
                mstreak = mstreak + 1;
            } else {
                mstreak = 0;
            }
        }
    }

    int bewegung = 0;
    if (mstreak >= mrun) { bewegung = 1; }

    // Check PIR / software motion alarm (one-shot: fires once, re-arms when clear)
#ifdef HAS_PERSON
    // ⚠️ THIS IS THE WHOLE POINT: the cheap detector is the TRIGGER, the
    // network is the CONFIRMATION. The pixel difference costs ~40 ms and only
    // says "something changed"; the network costs ~470 ms and says "that is a
    // person". Running the network continuously would be waste, and trusting
    // the difference alone means one mail per passing cloud.
    // TaskLoop does the actual work — this only sets a flag, otherwise the
    // main loop would stand still for half a second.
    if (pdet > 0 && bewegung == 1 && alarm_active == 0) {
        person_pruefen = 1;
    }
#endif

    int ausloeser = 0;
    if (pir > 0 && PIR_PIN >= 0 && digitalRead(PIR_PIN) == 0) { ausloeser = 1; }
    if (spir > 0 && bewegung == 1) {
#ifdef HAS_PERSON
        // With confirmation on, only a confirmed hit counts — and only while
        // it is fresh (the run itself takes a second or two).
        if (pdet > 0) {
            if (person_n > 0) { ausloeser = 1; }
        } else { ausloeser = 1; }
#else
        ausloeser = 1;
#endif
    }

    if (ausloeser == 1) {
        if (alarm_active == 0) {
            alarm_active = 1;
            motion_detect = motion_detect + 1;
            log_msg("motion alarm");
            // Trigger alarm picture capture in TaskLoop
            alarm_pics_pending = npic;
            alarm_pics_captured = 0;
        }
    } else {
        alarm_active = 0;
    }

    // Periodic picture taking (every rpt minutes)
    if (rpt > 0) {
        if (rpt_timer > 0) {
            rpt_timer = rpt_timer - 1;
        }
        if (rpt_timer == 0) {
            rpt_timer = rpt * 60;
            take_pic = 2;  // signal TaskLoop to capture + save
        }
    } else {
        rpt_timer = 0;
    }

    // Delete all pictures on button press
    if (del_all > 0) {
        del_all = 0;
        del_folder();
    }

#ifdef HAS_NIGHT
    // ─── Darkness, IR light, night sensor mode ───
    if (ltr_lesen()) {
#ifdef HAS_LUX_CHART
        hist_tick++;
        if (hist_tick >= 60) {
            hist_tick = 0;
            hist_lux[hist_pos % LUX_LEN] = lux;
            hist_pos++;
        }
#endif
        // Hysteresis: dark below the threshold, light again only above twice
        // the threshold — the IR light itself brightens the scene a little.
        if (dunkel == 0 && lux < (float)ir_lux) dunkel = 1;
        if (dunkel == 1 && lux > (float)(ir_lux * 2)) dunkel = 0;
    }
    int auto_dunkel = dunkel;
    if (ltr_bus < 0) auto_dunkel = 1;        // no sensor: automatic = on
    ir_soll = 0;
    if (ir_mode == 1) ir_soll = 1;
    if (ir_mode == 2) ir_soll = auto_dunkel;
    if (tasm_power != ir_soll) tasm_power = ir_soll;
    nacht_soll = 0;
    if (nacht == 1) nacht_soll = 1;
    if (nacht == 2) nacht_soll = auto_dunkel;
    if (nacht_soll != nacht_ist && do_nacht == 0) do_nacht = 1;

    // Auto-save persist variables when any setting changes
    if (changed(nacht_st) && nacht_ist == 1) do_nacht = 1;   // new strength now
#endif
    if (changed(res)) {
        snapshot(res);
        do_res = 1;                 // carried out in TaskLoop, see there
    }
    if (changed(limit) || changed(blim) || changed(mrun) ||
#ifdef HAS_PERSON
        changed(pdet) || changed(pthr) ||
#endif
        changed(pir) || changed(ma) ||
        changed(spir) || changed(rpt) || changed(ufl) ||
        changed(mtl) || changed(npic) || do_res == 1
#ifdef HAS_NIGHT
        || changed(ir_mode) || changed(ir_lux) || changed(nacht) || changed(nacht_st)
#endif
        ) {
        mpeak = 0;                  // tuning restarts with the new settings
        snapshot(limit); snapshot(blim); snapshot(mrun);
#ifdef HAS_PERSON
        snapshot(pdet); snapshot(pthr);
#endif
        snapshot(pir); snapshot(ma);
        snapshot(spir); snapshot(rpt); snapshot(ufl);
        snapshot(mtl); snapshot(npic);
#ifdef HAS_NIGHT
        snapshot(ir_mode); snapshot(ir_lux); snapshot(nacht); snapshot(nacht_st);
#endif
        saveVars();
    }
}

// ═══════════════════════════════════════════════════════════════════
// WebCall — sensor values (AJAX-refreshed area)
// Stream image is on main page via FUNC_WEB_ADD_MAIN_BUTTON
// ═══════════════════════════════════════════════════════════════════
void WebCall() {
    webSend("{s}<b style='color:cyan'>Camera</b>{m}{e}");
    sprintf(buf, "{s}Frames{m}%d{e}", frame_count);
    webSend(buf);
    sprintf(buf, "{s}Motion{m}%d net (raw %d, peak %d, limit %d, %d/%d){e}",
            mnet, mot, mpeak, limit, mstreak, mrun);
    webSend(buf);
    sprintf(buf, "{s}Light{m}%d (jump %d){e}", mbright, mjump);
    webSend(buf);
    sprintf(buf, "{s}Detected{m}%d (%d ignored, light){e}", motion_detect, veto_count);
    webSend(buf);
#ifdef HAS_PERSON
    if (pdet > 0) {
        if (person_zuletzt > 0) {
            int alter = (millis() - person_zuletzt) / 1000;
            if (alter < 120) {
                // Fresh: loud on purpose, this is the notice that matters.
                sprintf(buf, "{s}<b style='color:#ff5661'>&#x1F6B6; PERSON</b>{m}"
                             "<b style='color:#ff5661'>%d s ago &nbsp;%d&#37; &nbsp;%dx%d@%d,%d</b>{e}",
                        alter, person_score, person_w, person_h, person_x, person_y);
            } else {
                if (alter < 3600) {
                    sprintf(buf, "{s}Person{m}last seen %d min ago{e}", alter / 60);
                } else {
                    sprintf(buf, "{s}Person{m}last seen %d h ago{e}", alter / 3600);
                }
            }
            webSend(buf);
        } else {
            webSend("{s}Person{m}none yet{e}");
        }
        sprintf(buf, "{s}Person runs{m}%d hits, last %d ms{e}", person_gesamt, person_ms);
        webSend(buf);
    }
#endif
#ifdef HAS_NIGHT
    if (ltr_bus >= 0) {
        sprintf(buf, "{s}Light{m}%.2f lx{e}", lux);
        webSend(buf);
    }
    webSend("{s}Night{m}");
    if (ir_soll) webSend("IR on"); else webSend("IR off");
    if (nacht_ist) webSend(" &middot; sensor night{e}"); else webSend(" &middot; sensor day{e}");
#endif
    sprintf(buf, "{s}Heap{m}%d kB{e}", tasm_heap / 1000);
    webSend(buf);
}

// ═══════════════════════════════════════════════════════════════════
// WebUI — controls on Tasmota main page
// Note: stream image on main page is provided by camControl(15,1,0)
//       stream server — no WebPage() needed
// ═══════════════════════════════════════════════════════════════════
void WebUI() {
    // Optik wie bei den SML-Treibern (common/sml_descriptor.tc): eine Karte mit
    // Überschriften, kleiner Erklärzeile und fester Breite.
    // ⚠️ Die Breite gehört an `body>div`. Tasmotas Rahmen ist ein
    // inline-block mit min-width:340px und wächst mit seinem breitesten Kind --
    // ohne die feste Breite zieht ein langer Klappmenü-Eintrag die ganze Seite
    // auseinander. 96vw hält sie auf dem Telefon im Rahmen.
    webSend("<style>body>div{width:480px;max-width:96vw;box-sizing:border-box}.cam-p{max-width:100%;box-sizing:border-box;margin:12px auto;background:#f0f0f0;color:#000;padding:16px;border:2px solid #ccc;border-radius:6px;text-align:left}.cam-p h3{margin:0 0 8px}.cam-p hr{border:0;border-top:1px solid #bbb;margin:12px 0}.cam-p b{display:inline-block;margin-bottom:4px;font-size:13px}.cam-p div{margin:6px 0}.cam-p .hint{font-size:9px;color:#555;line-height:1.4}</style>");
    webSend("<div class='cam-p'><h3>&#x1F4F7; Camera</h3>");

    webSend("<b>&#x1F5BC;&#xFE0F; Picture</b>");
    webPulldown(res, "Resolution",
                "QVGA 320x240|320x320|CIF 400x296|HVGA 480x320|VGA 640x480|SVGA 800x600|XGA 1024x768|HD 1280x720|SXGA 1280x1024|UXGA 1600x1200");
    sprintf(buf, "<div class='hint'>Camera is set to <b>%dx%d</b>. Changing this restarts the stream on port 81 for a moment. Larger pictures cost PSRAM and frame rate; for motion detection VGA is plenty.</div>", cam_w, cam_h);
    webSend(buf);

    webSend("<hr><b>&#x1F6B6; Motion</b>");
    webCheckbox(spir, "Software motion detector");
    webNumber(limit, 20, 2000, "Motion threshold");
    webNumber(blim, 0, 2000, "Ignore above light change (0=off)");
    webNumber(mrun, 1, 30, "Readings of sustained motion");
#ifdef HAS_PERSON
    webCheckbox(pdet, "Confirm with the neural net (person detection)");
    webNumber(pthr, 10, 95, "Person score threshold (%)");
#endif
    if (PIR_PIN >= 0) {
        webCheckbox(pir, "Hardware PIR");
    }
    webSend("<div class='hint'>The detector compares the actual PIXELS of successive frames: the value is the mean brightness difference &times;100, so it no longer depends on the resolution. A change that hits the whole picture at once -- a cloud, a lamp, the IR LEDs -- also moves the mean <b>light</b> value, and that jump is <b>subtracted</b> from the motion value, because shifting every pixel by the same amount moves the mean by exactly that amount. Only a jump bigger than the second number (where the shift is no longer uniform) throws the reading away entirely. The alarm needs the corrected value to stay above the threshold for that many consecutive <b>new</b> readings -- measured here the detector manages one about every 1 s. With <b>person detection</b> on, motion is only the TRIGGER: the neural net then looks at the same frame (~470 ms) and the alarm fires only if it really finds a person -- that is what stops a cloud from sending mail. <b>To tune:</b> leave the scene alone for a few minutes and read <i>peak</i> on the main page; set the threshold above it. The peak restarts whenever you save here.</div>");

    webSend("<hr><b>&#x2709;&#xFE0F; Alarm</b>");
    webCheckbox(ma, "Send email on alarm");
    webNumber(npic, 1, 4, "Pictures per alarm (1-4)");
#ifdef HAS_NIGHT
    webCheckbox(ufl, "IR light while taking a picture");

    webSend("<hr><b>&#x1F319; Night</b>");
    webPulldown(ir_mode, "IR light (4 x 940 nm)", "Off|On|Automatic");
    webNumber(ir_lux, 1, 1000, "Automatic: dark below (lux)");
    webPulldown(nacht, "Night sensor mode", "Off|On|Automatic");
    webPulldown(nacht_st, "Night strength", "x2 exposure, 8x gain|x4 exposure, 8x gain|x8 exposure, 16x gain");
    if (ltr_bus >= 0) {
        sprintf(buf, "<div class='hint'>Light sensor reads <b>%.2f lx</b>", lux);
        webSend(buf);
        if (dunkel) webSend(" (dark). "); else webSend(" (light). ");
        webSend("Automatic = IR light and long exposure only when it is dark, off again above twice the threshold. Longer exposure means brighter, less noisy pictures at a lower frame rate; in real darkness x8 can drop to about 1 frame/s. The gain ceiling is kept low on purpose: above 16x the IR LEDs' ripple shows as horizontal stripes.</div>");
    } else {
        webSend("<div class='hint'>No LTR-308 light sensor found &mdash; Automatic behaves like On.</div>");
    }
#else
    webCheckbox(ufl, "Use flash LED");
#endif

    webSend("<hr><b>&#x23F1;&#xFE0F; Timelapse</b>");
    webNumber(rpt, 0, 60, "Take picture every N minutes");
    webCheckbox(mtl, "Merge into tlapse.mjpeg");
    webSend("<div class='hint'>0 minutes = off.</div>");

    webSend("<hr><b>&#x1F4F8; Now</b>");
    webButton(doCapture, "Take Picture|saved");
    webButton(doHiRes, "Hi-Res Capture|saved");
    webButton(del_all, "Delete all pictures|deleted");

    webSend("<div style='text-align:center;font-size:10px;color:#777;margin-top:10px'><b>webcam_tinyc.tc</b></div></div>");
}

#ifdef HAS_LUX_CHART
// ═══════════════════════════════════════════════════════════════════
// WebPage — lux chart on the main page (below the stream, which the driver
// draws itself). Taken over from ltr308.tc.
//
// ⚠️ THIS CHART NEVER SHOWED ANYTHING. Three bugs in the one call:
//
//   interval was 0. That is the spacing of the points IN MINUTES, which the
//   time axis is built from — at 0 all 360 points land on the same instant.
//   We sample once a minute, so it is 1.
//
//   pos was hist_pos. That counts up without bound since start (361, 362, …)
//   while the ring's write cursor is hist_pos % LUX_LEN. WebChart reads from
//   pos, so it read outside the buffer.
//
//   count was always LUX_LEN. Until the ring has filled, that draws 300-odd
//   zeros along with the data and presses the real values onto the baseline.
//   It has to be the number of VALID points.
//
// ymin >= ymax means auto-scale per the reference, so 0/0 was right all along
// and stays.
// ═══════════════════════════════════════════════════════════════════
void WebPage() {
    if (hist_pos > 0) {
        int gueltig = hist_pos;
        if (gueltig > LUX_LEN) { gueltig = LUX_LEN; }
        webSend("<div>");
        WebChartSize(0, 300);       // 0 = full card width
        WebChart('l', "Illuminance", "lux", 0xf1c40f,
                 hist_pos % LUX_LEN, gueltig, hist_lux, 1, 1, 0, 0);
        webSend("</div>");
    }
}
#endif

// ═══════════════════════════════════════════════════════════════════
// JSON output for integration
// ═══════════════════════════════════════════════════════════════════
void JsonCall() {
    // Two whole calls instead of one with an #ifdef in the middle — both
    // work, but split up it reads better.
#ifdef HAS_PERSON
    sprintf(buf, ",\"TinyCam\":{\"Motion\":%d,\"Raw\":%d,\"Light\":%d,\"Jump\":%d,\"Streak\":%d,\"Detected\":%d,\"Vetoed\":%d,\"Frames\":%d,\"Person\":%d,\"PersonScore\":%d,\"PersonTotal\":%d}",
            mnet, mot, mbright, mjump, mstreak, motion_detect, veto_count, frame_count,
            person_n, person_score, person_gesamt);
#else
    sprintf(buf, ",\"TinyCam\":{\"Motion\":%d,\"Raw\":%d,\"Light\":%d,\"Jump\":%d,\"Streak\":%d,\"Detected\":%d,\"Vetoed\":%d,\"Frames\":%d}",
            mnet, mot, mbright, mjump, mstreak, motion_detect, veto_count, frame_count);
#endif
    responseAppend(buf);
}

// ═══════════════════════════════════════════════════════════════════
// OnExit — clean up camera resources
// ═══════════════════════════════════════════════════════════════════
void OnExit() {
    camControl(15, 0, 0);      // stop stream server
    camControl(18, 0, 0);      // free motion buffers
    camControl(12, 0, 0);      // free all PSRAM slots
    camControl(13, 0, 0);      // deinit camera
    addLog("TCC: camera shut down");
}

// ═══════════════════════════════════════════════════════════════════
// MAIN — initialization
// ═══════════════════════════════════════════════════════════════════
int main() {
    mot = 0;
    mailout = 0;
    alarm_active = 1;       // suppress alarm until first motion-clear
    take_pic = 0;
    doCapture = 0;
    doHiRes = 0;
    del_all = 0;
    stream_on = 0;
    last_hour = -1;
    cam_ok = 0;
    mot_on = 0;
#ifdef HAS_PERSON
    person_pruefen = 0; person_n = 0; person_score = 0;
    person_x = 0; person_y = 0; person_w = 0; person_h = 0;
    person_zuletzt = 0; person_gesamt = 0; person_ms = 0;
#endif
    mbright = 0;
    mjump = 0;
    last_bri = -1;
    mnet = 0;
    mpeak = 0;
    last_mot = -1;
    mstreak = 0;
    veto_count = 0;
    frame_count = 0;
    motion_detect = 0;
    alarm_pics_pending = 0;
    alarm_pics_captured = 0;

    // Default settings if not persisted
    if (limit == 0) { limit = 400; }
    // ⚠️ MIGRATION off the old JPEG-size detector. Its threshold was measured
    // in BYTES and was typically a few thousand; the new one is mean pixel
    // difference x 100, where the web field caps at 2000. So anything above
    // 2000 can only be a leftover from the old scale — kept as is it would
    // silently never trigger again, i.e. an alarm camera that stays quiet.
    if (limit > 2000) {
        addLog("TCC: motion threshold %d was on the old JPEG-size scale, reset to 400", limit);
        limit = 400;
    }
    // 30 grey levels: below that the subtraction above handles the light
    // change; above it the shift is no longer uniform and the reading is junk.
    if (blim == 0) { blim = 3000; }
    if (mrun == 0) { mrun = 2; }        // two NEW readings (~6 s here, see above)
    if (mrun > 30) { mrun = 30; }
#ifdef HAS_PERSON
    if (pthr == 0) { pthr = 50; }       // 0.50 — the model's own default of
                                        // 0.70 is too strict for a house cam
    if (pthr < 10) { pthr = 10; }
    if (pthr > 95) { pthr = 95; }
#endif
    if (npic == 0) { npic = 1; }
    if (npic > 4) { npic = 4; }
    if (res_seen == 0) { res = RES_DEFAULT; res_seen = 1; }
#ifdef HAS_NIGHT
    if (nacht_seen == 0) {
        ir_mode = 2; ir_lux = 10; nacht = 2; nacht_st = 1;    // automatic, x4/48x
        nacht_seen = 1;
    }
    if (ir_lux < 1) ir_lux = 10;
#endif

    // Snapshot all watch vars so changed() starts clean
    snapshot(limit); snapshot(pir); snapshot(ma);
    snapshot(spir); snapshot(rpt); snapshot(ufl);
    snapshot(mtl); snapshot(npic); snapshot(res);
    snapshot(blim); snapshot(mrun);
#ifdef HAS_PERSON
    snapshot(pdet); snapshot(pthr);
#endif
#ifdef HAS_NIGHT
    snapshot(ir_mode); snapshot(ir_lux); snapshot(nacht); snapshot(nacht_st);
    nacht_ist = 0; dunkel = 0; lux = 0.0; ltr_bus = -1;
#ifdef HAS_LUX_CHART
    hist_pos = 0; hist_tick = 55;          // first chart point after 5 s, not 60
#endif
#endif

    // Sanitize corrupted persist values
    if (pir > 1 || ma > 1 || spir > 1 || ufl > 1 || mtl > 1) {
        pir = 0; ma = 0; spir = 0; ufl = 0; mtl = 0;
        saveVars();
    }

    rpt_timer = rpt * 60;

    // PIR input pin (only if configured, PIR_PIN=-1 means no hardware PIR)
    if (PIR_PIN >= 0) {
        pinMode(PIR_PIN, 0);
    }

    // Create pictures folder
    if (!fileExists("/PICS")) {
        fileMkdir("/PICS");
    }

    // Initialize camera
    int ok = initCamera();
    if (ok != 0) {
        return -1;
    }
    cam_ok = 1;
    res_masse();

    // First test capture. ⚠️ The very first frame after init fails (-1) — the
    // sensor is still settling. Not the light sensor's fault (checked: same
    // with the LTR-308 probed afterwards). One retry after a short wait.
    int size = captureToSlot(1);
    if (size < 0) { delay(400); size = captureToSlot(1); }
    addLog("TCC: camera ready, first frame: %d bytes, heap: %d", size, tasm_heap);

    // ⚠️ The light sensor AFTER the camera, not before. It sits on I2C bus 1 —
    // GPIO 8/9, the very pins the camera uses for SCCB. Probing it first
    // left the camera's first capture failing (-1 bytes, 05.09.2026).
#ifdef HAS_NIGHT
    ltr_bus = ltr_finden();
#endif

    // ⚠️ Register a page label — WITHOUT it the settings never appear.
    // A bare WebUI() used to get a generic "TinyC UI" button on the main page;
    // that button is gone (it resolved to the wrong slot and answered 503).
    // Now only a slot that registers a page via webPageLabel() gets a button,
    // so this script's checkboxes had become unreachable.
    webPageLabel(0, "Camera Setup");

    // Start MJPEG stream server on port 81
    // (deferred automatically if WiFi not ready yet — safe for autoexec)
    camControl(15, 1, 0);

    log_msg("camera booted");

    return 0;
}