/* MJPEG-Server fuer die Kameraseite, ESP32-S3 mit Kameramodul. * * Das Gegenstueck zu pi/vogelkamera.py: das Geraet streamt nur, erkannt wird * auf einer Maschine mit Rechenleistung. Gleicher Port, gleiche Pfade — der * Detector merkt nicht, ob ein Pi oder ein ESP32 am anderen Ende haengt. * * http://:8080/stream MJPEG * http://:8080/ Vorschauseite * * http://:8081/status JSON: fps, RSSI, Temperatur, Laufzeit * (gleiche Felder wie pi/vogelkamera.py) * http://:8081/temp Chiptemperatur als Text * http://:8081/gesund 200 oder 503 * * ⚠ Die Diagnose liegt auf VK_PORT + 1 und nicht auf dem Hauptport -- der * Grund steht unten bei diagnose_server_start(). Hier stand bis zum * 09.10.2026 fuer alle sechs Wege die 8080; eine Anfrage dorthin laeuft * waehrend des Streams stumm ins Zeitlimit und sieht aus wie eine tote * Kamera. Genau der Irrtum, gegen den der zweite Server gebaut wurde. * * Drei Dinge sind aus dem Betrieb am Vogelhaus gelernt und stehen deshalb hier * drin: * * 1. EIN ZUSCHAUER. Ueber WLAN ist die Bildrate bandbreitenbegrenzt, nicht * rechenbegrenzt. Ein zweiter Client teilt sich die Funkstrecke, und der * Detector faellt in Timeouts, ohne dass irgendwo ein Fehler auftaucht. * Genau das ist bei der Entwicklung passiert: das Diagnosewerkzeug hat den * Fehler erzeugt, den es messen sollte. Ein zweiter Zuschauer bekommt * deshalb 503 statt eines halben Bildes. * * 2. DIAGNOSE, DIE DEN STREAM NICHT ANFASST. /status liefert Bildrate, * Funkstaerke und Temperatur, ohne ein Bild zu holen. Am Pi fehlte das: * dort blieb nur, das Log zu lesen oder den Stream zu stoeren. 'ping' taugt * dafuer nicht — ICMP faellt bei gesaettigtem WLAN als Erstes aus, waehrend * TCP weiterlaeuft. * * 3. DER SERVER LAEUFT AUCH OHNE KAMERA WEITER. Scheitert die Initialisierung, * sagt /status warum. Ein Geraet im Garten, das bei einem Fehler stumm * bleibt, kostet einen Weg nach draussen. */ #include #include #include #include "esp_camera.h" #include "esp_event.h" #include "esp_http_server.h" #include "esp_log.h" #include "esp_netif.h" #include "esp_system.h" #include "esp_timer.h" #include "esp_wifi.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "nvs_flash.h" #include "esp_ota_ops.h" #include "netz.h" #include "sdkconfig.h" #if SOC_TEMP_SENSOR_SUPPORTED #include "driver/temperature_sensor.h" #endif static const char *TAG = "vogelkamera"; /* Ein abgewaehltes 'bool' in Kconfig erzeugt kein #define 0, sondern gar * keines. Ohne diese zwei Zeilen bricht der Build ab, sobald man einen der * Schalter ausschaltet — also im Normalfall. */ #ifndef CONFIG_VK_HFLIP #define CONFIG_VK_HFLIP 0 #endif #ifndef CONFIG_VK_VFLIP #define CONFIG_VK_VFLIP 0 #endif /* ------------------------------------------------------------------ Pins */ /* Die Belegung haengt am Board, nicht am Sensor. Stimmt sie nicht, scheitert * esp_camera_init mit ESP_ERR_NOT_FOUND (0x105) — dann ist die Board-Auswahl * im menuconfig falsch, nicht die Kamera defekt. */ #if CONFIG_VK_BOARD_S3_WROOM #define PIN_PWDN -1 #define PIN_RESET -1 #define PIN_XCLK 15 #define PIN_SIOD 4 #define PIN_SIOC 5 #define PIN_D7 16 #define PIN_D6 17 #define PIN_D5 18 #define PIN_D4 12 #define PIN_D3 10 #define PIN_D2 8 #define PIN_D1 9 #define PIN_D0 11 #define PIN_VSYNC 6 #define PIN_HREF 7 #define PIN_PCLK 13 #elif CONFIG_VK_BOARD_XIAO_S3 #define PIN_PWDN -1 #define PIN_RESET -1 #define PIN_XCLK 10 #define PIN_SIOD 40 #define PIN_SIOC 39 #define PIN_D7 48 #define PIN_D6 11 #define PIN_D5 12 #define PIN_D4 14 #define PIN_D3 16 #define PIN_D2 18 #define PIN_D1 17 #define PIN_D0 15 #define PIN_VSYNC 38 #define PIN_HREF 47 #define PIN_PCLK 13 #else /* CONFIG_VK_BOARD_AI_THINKER */ #define PIN_PWDN 32 #define PIN_RESET -1 #define PIN_XCLK 0 #define PIN_SIOD 26 #define PIN_SIOC 27 #define PIN_D7 35 #define PIN_D6 34 #define PIN_D5 39 #define PIN_D4 36 #define PIN_D3 21 #define PIN_D2 19 #define PIN_D1 18 #define PIN_D0 5 #define PIN_VSYNC 25 #define PIN_HREF 23 #define PIN_PCLK 22 #endif /* --------------------------------------------------------------- Zustand */ #define GRENZE "BILDGRENZE" #define TEIL_KOPF "\r\n--" GRENZE "\r\nContent-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n" /* So viele Fehlversuche am Stueck gelten als kaputt. Der Sensor haengt sich * im Feld gelegentlich auf; ein Neustart dauert zwei Sekunden, ein Weg in den * Garten deutlich laenger. */ #define FEHLER_BIS_NEUSTART 30 static SemaphoreHandle_t zuschauer_platz; /* genau einer, siehe Kopf */ static bool kamera_bereit; static esp_err_t kamera_fehler = ESP_OK; static volatile uint32_t bilder_gesamt; static volatile int64_t letztes_bild_us; static volatile float fps_aktuell; static volatile bool schaut_jemand; #if SOC_TEMP_SENSOR_SUPPORTED static temperature_sensor_handle_t tsens; #endif static float chiptemperatur(void) { float c = -1.0f; #if SOC_TEMP_SENSOR_SUPPORTED if (tsens != NULL && temperature_sensor_get_celsius(tsens, &c) != ESP_OK) { c = -1.0f; } #endif return c; } static int funkstaerke(void) { wifi_ap_record_t ap; if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) { return ap.rssi; } return 0; } /* ---------------------------------------------------------------- Kamera */ static esp_err_t kamera_start(void) { camera_config_t c = { .pin_pwdn = PIN_PWDN, .pin_reset = PIN_RESET, .pin_xclk = PIN_XCLK, .pin_sccb_sda = PIN_SIOD, .pin_sccb_scl = PIN_SIOC, .pin_d7 = PIN_D7, .pin_d6 = PIN_D6, .pin_d5 = PIN_D5, .pin_d4 = PIN_D4, .pin_d3 = PIN_D3, .pin_d2 = PIN_D2, .pin_d1 = PIN_D1, .pin_d0 = PIN_D0, .pin_vsync = PIN_VSYNC, .pin_href = PIN_HREF, .pin_pclk = PIN_PCLK, .xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0, /* JPEG direkt aus dem Sensor: alles andere muesste der Chip erst * komprimieren, und die Funkstrecke ist ohnehin der Engpass. */ .pixel_format = PIXFORMAT_JPEG, .frame_size = (framesize_t)CONFIG_VK_FRAMESIZE, .jpeg_quality = CONFIG_VK_JPEG_QUALITAET, /* Zwei Puffer im PSRAM, und immer das neueste Bild ausliefern. Ein * Rueckstau waere hier schaedlich: wer sich verspaetet einklinkt, will * das aktuelle Bild und nicht die Warteschlange von vor zehn Sekunden * nachholen. */ .fb_count = 2, .fb_location = CAMERA_FB_IN_PSRAM, .grab_mode = CAMERA_GRAB_LATEST, }; esp_err_t err = esp_camera_init(&c); if (err != ESP_OK) { return err; } sensor_t *s = esp_camera_sensor_get(); if (s != NULL) { s->set_hmirror(s, CONFIG_VK_HFLIP ? 1 : 0); s->set_vflip(s, CONFIG_VK_VFLIP ? 1 : 0); /* Farbe. Der Sensor liefert von Haus aus ein flaues Bild -- * nebeneinander mit der Pi-Kamera am Futterhaus fiel das am * 24.09.2026 sofort auf: dort sattes Gruen, hier grau. * * ⚠ Das ist nicht nur Geschmack. Die Arterkennung arbeitet mit * Farbe; ein blasser Vogel vor blassem Laub ist schwerer zu * bestimmen als ein farbiger. * * Beide Bereiche gehen von -2 bis 2. Nicht ans Maximum: Zu viel * Kontrast frisst die Zeichnung im dunklen Gefieder, und die * braucht der Erkenner. */ if (s->set_saturation) s->set_saturation(s, CONFIG_VK_SAETTIGUNG); if (s->set_contrast) s->set_contrast(s, CONFIG_VK_KONTRAST); if (s->set_brightness) s->set_brightness(s, CONFIG_VK_HELLIGKEIT); camera_sensor_info_t *info = esp_camera_sensor_get_info(&s->id); ESP_LOGI(TAG, "Sensor: %s", info ? info->name : "unbekannt"); } return ESP_OK; } /* ------------------------------------------------------------- Endpunkte */ static const char SEITE[] = "\n" "Vogelkamera\n" "\n" "\n" "\"Livebild\"\n" "

Solange diese Seite offen ist, bekommt der Detector kein Bild.

\n" "\n"; static esp_err_t h_seite(httpd_req_t *r) { httpd_resp_set_type(r, "text/html; charset=utf-8"); return httpd_resp_send(r, SEITE, HTTPD_RESP_USE_STRLEN); } static esp_err_t h_status(httpd_req_t *r) { char json[400]; int64_t jetzt = esp_timer_get_time(); float alter = letztes_bild_us ? (jetzt - letztes_bild_us) / 1e6f : -1.0f; snprintf(json, sizeof(json), "{\"kamera\":\"%s\",\"fehler\":\"0x%x\"," "\"laufzeit_s\":%lld,\"bilder\":%lu,\"fps\":%.2f," "\"letztes_bild_s\":%.1f,\"zuschauer\":%s," "\"rssi\":%d,\"temperatur_c\":%.1f," "\"speicher_frei\":%lu,\"psram_frei\":%u}\n", kamera_bereit ? "bereit" : "fehlt", (unsigned)kamera_fehler, jetzt / 1000000, (unsigned long)bilder_gesamt, fps_aktuell, alter, schaut_jemand ? "true" : "false", funkstaerke(), chiptemperatur(), (unsigned long)esp_get_free_heap_size(), (unsigned)heap_caps_get_free_size(MALLOC_CAP_SPIRAM)); httpd_resp_set_type(r, "application/json"); return httpd_resp_send(r, json, HTTPD_RESP_USE_STRLEN); } static esp_err_t h_temp(httpd_req_t *r) { char text[32]; float c = chiptemperatur(); if (c < 0) { snprintf(text, sizeof(text), "unbekannt\n"); } else { snprintf(text, sizeof(text), "%.1f\xC2\xB0\x43\n", c); /* °C in UTF-8 */ } httpd_resp_set_type(r, "text/plain; charset=utf-8"); return httpd_resp_send(r, text, HTTPD_RESP_USE_STRLEN); } static esp_err_t h_gesund(httpd_req_t *r) { httpd_resp_set_type(r, "text/plain; charset=utf-8"); if (!kamera_bereit) { httpd_resp_set_status(r, "503 Service Unavailable"); return httpd_resp_send(r, "Kamera nicht initialisiert, siehe /status\n", HTTPD_RESP_USE_STRLEN); } /* Mit Zuschauer genuegt der Blick auf das letzte Bild. Ohne Zuschauer holt * niemand Bilder, dann wird eines geholt — ein einzelnes, kein Stream. */ if (schaut_jemand) { float alter = (esp_timer_get_time() - letztes_bild_us) / 1e6f; if (alter < 5.0f) { return httpd_resp_send(r, "ok\n", HTTPD_RESP_USE_STRLEN); } httpd_resp_set_status(r, "503 Service Unavailable"); return httpd_resp_send(r, "Zuschauer verbunden, aber kein frisches Bild\n", HTTPD_RESP_USE_STRLEN); } camera_fb_t *fb = esp_camera_fb_get(); if (fb == NULL) { httpd_resp_set_status(r, "503 Service Unavailable"); return httpd_resp_send(r, "kein Bild\n", HTTPD_RESP_USE_STRLEN); } esp_camera_fb_return(fb); return httpd_resp_send(r, "ok\n", HTTPD_RESP_USE_STRLEN); } static esp_err_t h_stream(httpd_req_t *r) { if (!kamera_bereit) { httpd_resp_set_status(r, "503 Service Unavailable"); httpd_resp_set_type(r, "text/plain; charset=utf-8"); return httpd_resp_send(r, "Kamera nicht initialisiert, siehe /status\n", HTTPD_RESP_USE_STRLEN); } /* Nicht blockierend pruefen: ein zweiter Zuschauer soll eine klare Absage * bekommen und nicht in einer Warteschlange haengen. */ if (xSemaphoreTake(zuschauer_platz, 0) != pdTRUE) { ESP_LOGW(TAG, "Zuschauer abgewiesen — es schaut schon jemand zu"); httpd_resp_set_status(r, "503 Service Unavailable"); httpd_resp_set_type(r, "text/plain; charset=utf-8"); return httpd_resp_send( r, "Es ist bereits ein Zuschauer verbunden.\n" "Diese Kamera liefert nur einen Stream gleichzeitig — ein zweiter\n" "wuerde die Funkstrecke saettigen und beide Bilder ruckeln lassen.\n", HTTPD_RESP_USE_STRLEN); } schaut_jemand = true; ESP_LOGI(TAG, "Zuschauer verbunden"); esp_err_t res = httpd_resp_set_type( r, "multipart/x-mixed-replace; boundary=" GRENZE); if (res == ESP_OK) { httpd_resp_set_hdr(r, "Cache-Control", "no-cache, private"); httpd_resp_set_hdr(r, "Pragma", "no-cache"); } char kopf[80]; int64_t fenster_start = esp_timer_get_time(); uint32_t fenster_bilder = 0; int fehler_in_folge = 0; /* 🔑 Bremse. Bis zum 25.09.2026 gab es keine: Die Kamera sendete, so * schnell die Funkstrecke hergab -- gemessen 16 Bilder/s -- und der * Detector las, so schnell sie kamen. Das war kein eingestellter Wert, * sondern schlicht das Maximum. * * ⚠️ Das kostet doppelt: Dauerfunk heizt den Chip, und bei schwachem * Empfang saettigt es die Strecke. Am selben Tag stand die Traenke bei * 78,7 °C in der Sonne. * * Und gebraucht wird es nicht: eine echte Kohlmeise war 29 * Sekunden da. Bei fuenf Bildern/s sind das 145 Bilder, und der * Detector bestimmt ohnehin nur jedes sechste. * * 0 heisst: keine Bremse, so schnell wie moeglich. */ const int64_t mindestabstand_us = CONFIG_VK_MAX_FPS > 0 ? 1000000 / CONFIG_VK_MAX_FPS : 0; int64_t letzte_sendung = 0; while (res == ESP_OK) { if (mindestabstand_us > 0) { int64_t wartet = mindestabstand_us - (esp_timer_get_time() - letzte_sendung); if (wartet > 0) { /* ⚠️ vTaskDelay, nicht busy-wait: Die Pause soll den Chip * entlasten, nicht ihn beschaeftigen. Andere Aufgaben -- * unter anderem der Diagnoseserver -- kommen dadurch dran. */ vTaskDelay(pdMS_TO_TICKS((wartet + 999) / 1000)); } } letzte_sendung = esp_timer_get_time(); camera_fb_t *fb = esp_camera_fb_get(); if (fb == NULL) { ESP_LOGW(TAG, "kein Bild vom Sensor"); if (++fehler_in_folge >= FEHLER_BIS_NEUSTART) { ESP_LOGE(TAG, "%d Fehlversuche am Stueck — Neustart", fehler_in_folge); esp_restart(); } vTaskDelay(pdMS_TO_TICKS(100)); continue; } fehler_in_folge = 0; int n = snprintf(kopf, sizeof(kopf), TEIL_KOPF, (unsigned)fb->len); res = httpd_resp_send_chunk(r, kopf, n); if (res == ESP_OK) { res = httpd_resp_send_chunk(r, (const char *)fb->buf, fb->len); } esp_camera_fb_return(fb); bilder_gesamt++; fenster_bilder++; letztes_bild_us = esp_timer_get_time(); /* Bildrate ueber ein Fenster von fuenf Sekunden. Ein Momentanwert * schwankt zu stark, um daraus etwas zu lesen. */ int64_t seit = letztes_bild_us - fenster_start; if (seit > 5000000) { fps_aktuell = fenster_bilder * 1e6f / (float)seit; fenster_start = letztes_bild_us; fenster_bilder = 0; } } /* Kein Fehler: der Detector hat aufgelegt oder das WLAN hat gehustet. */ ESP_LOGI(TAG, "Zuschauer getrennt"); fps_aktuell = 0.0f; schaut_jemand = false; xSemaphoreGive(zuschauer_platz); return ESP_OK; } /* ---------------------------------------------------------- Diagnose */ /* * ⚠ Ein zweiter Server, und zwar aus einem handfesten Grund: Der Webserver * im ESP32 arbeitet Anfragen mit EINEM Faden ab. Der Stream gibt den nie * zurueck -- er laeuft ja endlos. Solange also jemand zuschaut, kommt * niemand mehr an /status, /temp oder /gesund. * * Genau dann braucht man sie aber: Die Doku nennt /status ausdruecklich * den richtigen Weg, die Kamera im Betrieb zu pruefen. Am 23.09.2026 hat * sich gezeigt, dass das nicht stimmte -- die Kamera wirkte tot, waehrend * sie brav streamte. * * Die Diagnose liegt deshalb auf einem eigenen Port mit eigenem Faden. */ /* Meldet einen Weg an und sagt Bescheid, wenn es nicht klappt. * * 🔑 Ohne diese Zeile stand am 24.09.2026 ein Endpunkt einfach nicht zur * Verfuegung, ohne dass irgendwo etwas davon stand. Ein Fehler, der sich * nicht meldet, kostet Stunden -- und hier waren es OTA-Updates. */ static void weg_anmelden(httpd_handle_t srv, const httpd_uri_t *weg) { esp_err_t r = httpd_register_uri_handler(srv, weg); if (r != ESP_OK) { ESP_LOGE(TAG, "Weg '%s' NICHT angemeldet: %s — er wird 404 liefern", weg->uri, esp_err_to_name(r)); } } static httpd_handle_t diagnose_server_start(void) { httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); cfg.server_port = CONFIG_VK_PORT + 1; cfg.ctrl_port = 32770; /* nicht derselbe wie beim Hauptserver */ cfg.max_open_sockets = 3; cfg.lru_purge_enable = true; httpd_handle_t srv = NULL; if (httpd_start(&srv, &cfg) != ESP_OK) return NULL; static const httpd_uri_t wege[] = { {.uri = "/status", .method = HTTP_GET, .handler = h_status}, {.uri = "/temp", .method = HTTP_GET, .handler = h_temp}, {.uri = "/gesund", .method = HTTP_GET, .handler = h_gesund}, }; for (size_t i = 0; i < sizeof(wege) / sizeof(wege[0]); i++) { weg_anmelden(srv, &wege[i]); } return srv; } /* ------------------------------------------------------------- Update */ /* * 🔑 Ohne diesen Endpunkt muesste man das Geraet zum Aktualisieren aus dem * Garten holen, aufschrauben und ans Kabel haengen. Bei einem Bausatz fuer * andere Leute waere es damit ganz vorbei. * * Aufruf: * curl -X POST --data-binary @vogelkamera.bin \ * -H "X-Vogelkamera-Token: " http://:8080/update */ static bool token_stimmt(httpd_req_t *req) { char wert[64] = {0}; if (httpd_req_get_hdr_value_str(req, "X-Vogelkamera-Token", wert, sizeof(wert)) != ESP_OK) { return false; } /* Laengenvergleich zuerst, dann Inhalt -- strcmp bricht beim ersten * Unterschied ab und verraet ueber die Laufzeit etwas ueber das * Geheimnis. Im Heimnetz akademisch, aber es kostet nichts. */ const char *soll = netz_ota_token(); size_t a = strlen(wert), b = strlen(soll); if (a != b) return false; unsigned diff = 0; for (size_t i = 0; i < a; i++) diff |= (unsigned)(wert[i] ^ soll[i]); return diff == 0; } static esp_err_t update_handler(httpd_req_t *req) { if (!token_stimmt(req)) { ESP_LOGW(TAG, "Update ohne gueltiges Token abgelehnt"); httpd_resp_send_err(req, HTTPD_401_UNAUTHORIZED, "Token fehlt oder falsch"); return ESP_FAIL; } const esp_partition_t *ziel = esp_ota_get_next_update_partition(NULL); if (!ziel) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "keine OTA-Partition"); return ESP_FAIL; } ESP_LOGI(TAG, "Update nach %s, %d Bytes erwartet", ziel->label, req->content_len); esp_ota_handle_t ota; if (esp_ota_begin(ziel, req->content_len, &ota) != ESP_OK) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "ota_begin fehlgeschlagen"); return ESP_FAIL; } char *puffer = malloc(2048); if (!puffer) { esp_ota_abort(ota); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "kein Speicher"); return ESP_FAIL; } /* ⚠ Am 24.09.2026 blieb genau diese Schleife stehen: Nach etwa elf * Sekunden kam nichts mehr an, und das 'continue' drehte sich ewig * weiter. Nach aussen sah das aus wie ein haengender Upload, im * Protokoll wie eine endlose Reihe 'recv : 11'. * * Drei Dinge dagegen: * - eine Obergrenze fuer Leerlaeufe am Stueck, danach sauberer Abbruch * - eine Fortschrittszeile, damit man SIEHT wo es stockt * - Geduld statt Eile (recv_wait_timeout steht oben bei der Config) */ const int LEERLAUF_MAX = 20; /* × recv_wait_timeout */ int leerlauf = 0; int offen = req->content_len; int gesamt = req->content_len; int naechste_meldung = gesamt - 102400; while (offen > 0) { int will = offen < 2048 ? offen : 2048; int gelesen = httpd_req_recv(req, puffer, will); if (gelesen == HTTPD_SOCK_ERR_TIMEOUT) { if (++leerlauf >= LEERLAUF_MAX) { ESP_LOGE(TAG, "Update steht seit %d Leerlaeufen bei %d von %d " "Bytes — Abbruch", leerlauf, gesamt - offen, gesamt); esp_ota_abort(ota); free(puffer); httpd_resp_send_err(req, HTTPD_408_REQ_TIMEOUT, "Sender liefert nichts mehr"); return ESP_FAIL; } continue; } leerlauf = 0; if (gelesen <= 0) { ESP_LOGE(TAG, "Verbindung abgerissen bei %d von %d Bytes", gesamt - offen, gesamt); esp_ota_abort(ota); free(puffer); return ESP_FAIL; } if (esp_ota_write(ota, puffer, gelesen) != ESP_OK) { esp_ota_abort(ota); free(puffer); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Schreiben fehlgeschlagen"); return ESP_FAIL; } offen -= gelesen; if (offen <= naechste_meldung) { ESP_LOGI(TAG, "Update: %d von %d Bytes", gesamt - offen, gesamt); naechste_meldung -= 102400; } } free(puffer); /* esp_ota_end prueft die Pruefsumme. Ein halb uebertragenes Abbild * fliegt hier raus, bevor es Startpartition werden kann -- das Geraet * laeuft dann einfach auf der alten Fassung weiter. */ if (esp_ota_end(ota) != ESP_OK) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Abbild ungueltig"); return ESP_FAIL; } ESP_ERROR_CHECK(esp_ota_set_boot_partition(ziel)); httpd_resp_sendstr(req, "uebernommen, Kamera startet neu\n"); ESP_LOGI(TAG, "Update uebernommen, Neustart"); vTaskDelay(pdMS_TO_TICKS(500)); esp_restart(); return ESP_OK; } /* ⚠ Weg zurueck. Ohne den bleibt ein Geraet, das den Besitzer wechselt oder * mit dem der Nutzer umzieht, fuer immer im alten Netz gefangen -- und man * muesste die Firmware neu aufspielen, um es zu loesen. */ static esp_err_t wlan_vergessen_handler(httpd_req_t *req) { netz_zugang_loeschen(); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_sendstr(req, "" "" "

WLAN vergessen

" "

Die Kamera startet neu und öffnet ihr Einrichtungsnetz.

"); vTaskDelay(pdMS_TO_TICKS(1200)); esp_restart(); return ESP_OK; } static httpd_handle_t server_start(void) { httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); cfg.server_port = CONFIG_VK_PORT; cfg.ctrl_port = CONFIG_VK_PORT + 1000; cfg.lru_purge_enable = true; cfg.stack_size = 8192; /* 🔑 DAS hat den 24.09.2026 gekostet. Die Vorgabe sind ACHT Handler. * Unten werden NEUN angemeldet -- und der neunte war /update. Er wurde * stillschweigend abgewiesen: kein Absturz, keine Warnung, nur ein * 404 beim Aufspielen. OTA hat auf diesem Geraet also nie funktioniert, * ausgerechnet die Eigenschaft, auf der ausdruecklich * bestanden hatte ("alle Geraete muessen OTA koennen"). * * Reichlich Luft, damit der naechste Endpunkt nicht wieder der eine * zu viel ist. */ cfg.max_uri_handlers = 16; /* ⚠ Fuers Update: Die Vorgabe sind 5 Sekunden Geduld beim Empfangen. * Waehrend esp_ota_write einen Flash-Block loescht, liest niemand vom * Socket -- bei knapper Funkstrecke reicht das, um die Uebertragung * abreissen zu lassen. 20 Sekunden kosten nichts und halten durch. */ cfg.recv_wait_timeout = 20; cfg.send_wait_timeout = 20; /* Der Stream haelt seinen Socket dauerhaft; /status braucht daneben * Platz. * * ⚠ Und das Update braucht ebenfalls welchen: Mit nur vier Plaetzen * und eingeschaltetem Aufraeumen kann ausgerechnet die * Update-Verbindung weggeworfen werden -- sie hat waehrend eines * Flash-Schreibvorgangs am laengsten nichts gesagt und gilt damit als * die aelteste. Abgebrochene Versuche belegen ausserdem Plaetze, bis * TCP sie aufgibt. */ cfg.max_open_sockets = 7; httpd_handle_t srv = NULL; if (httpd_start(&srv, &cfg) != ESP_OK) { return NULL; } const httpd_uri_t routen[] = { {.uri = "/", .method = HTTP_GET, .handler = h_seite}, {.uri = "/index.html", .method = HTTP_GET, .handler = h_seite}, {.uri = "/stream", .method = HTTP_GET, .handler = h_stream}, {.uri = "/status", .method = HTTP_GET, .handler = h_status}, {.uri = "/temp", .method = HTTP_GET, .handler = h_temp}, {.uri = "/gesund", .method = HTTP_GET, .handler = h_gesund}, {.uri = "/health", .method = HTTP_GET, .handler = h_gesund}, }; /* ⚠ Rueckgabewert pruefen. Genau das Verschweigen war der Fehler: * httpd_register_uri_handler meldet ESP_ERR_HTTPD_HANDLERS_FULL und * laeuft weiter, als waere nichts gewesen. */ for (size_t i = 0; i < sizeof(routen) / sizeof(routen[0]); i++) { weg_anmelden(srv, &routen[i]); } httpd_uri_t vergessen = { .uri = "/wlan-vergessen", .method = HTTP_GET, .handler = wlan_vergessen_handler }; httpd_uri_t update = { .uri = "/update", .method = HTTP_POST, .handler = update_handler }; weg_anmelden(srv, &vergessen); weg_anmelden(srv, &update); return srv; } /* --------------------------------------------------------- Einrichtung */ /* * Die WLAN-Zugangsdaten stehen NICHT im Quelltext. Wer das Geraet auspackt, * soll es ins eigene Netz bringen koennen, ohne die Firmware neu zu bauen -- * sonst ist der Bausatz fuer alle ausser dem Entwickler unbrauchbar. * * Der Weg ist aus Hey Fred uebernommen und dort erprobt: Liegt nichts im * Speicher, macht das Geraet ein eigenes, offenes WLAN auf und zeigt ein * Formular. Danach speichert es dauerhaft und startet neu. */ static void url_entschluesseln(char *s) { char *lese = s, *schreibe = s; while (*lese) { if (*lese == '%' && lese[1] && lese[2]) { char hex[3] = { lese[1], lese[2], '\0' }; *schreibe++ = (char)strtol(hex, NULL, 16); lese += 3; } else if (*lese == '+') { *schreibe++ = ' '; lese++; } else { *schreibe++ = *lese++; } } *schreibe = '\0'; } static void html_sicher(const char *quelle, char *ziel, size_t max) { size_t k = 0; for (size_t i = 0; quelle[i] && k + 7 < max; i++) { switch (quelle[i]) { case '<': k += snprintf(ziel + k, max - k, "<"); break; case '>': k += snprintf(ziel + k, max - k, ">"); break; case '&': k += snprintf(ziel + k, max - k, "&"); break; case '"': k += snprintf(ziel + k, max - k, """); break; default: ziel[k++] = quelle[i]; } } ziel[k] = '\0'; } #define FUNDE_MAX 20 static const char EINRICHT_KOPF[] = "" "" "Vogelkamera einrichten

Vogelkamera einrichten

"; static esp_err_t formular_handler(httpd_req_t *req) { netz_fund_t *funde = calloc(FUNDE_MAX, sizeof(netz_fund_t)); if (!funde) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "kein Speicher"); return ESP_FAIL; } /* ⚠ Aus dem Vorrat, NICHT frisch suchen: Eine Suche wechselt den * Funkkanal und wirft dabei den Besucher aus dem Einrichtungsnetz. * Sein Absenden kaeme dann nie an. Gesucht wird einmal beim Oeffnen * des Netzes, bevor jemand da ist. */ int n = netz_funde_holen(funde, FUNDE_MAX); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_send_chunk(req, EINRICHT_KOPF, HTTPD_RESP_USE_STRLEN); char zeile[512]; /* ⚠ Warum die Verbindung scheiterte, gehoert HIERHER und nicht nur ins * serielle Protokoll. Am 23.09.2026 stand jemand im Buero vor einem * Geraet, das sich nicht verbinden wollte -- und kam an den Grund * nicht heran, weil dieses Board an beiden Buchsen chipeigenes USB * hat: Beim Reset verschwindet der Anschluss, und das Mitlesen stirbt * mit. Ein Geraet, das seinen Fehler nur ueber ein Kabel verraet, das * es gar nicht gibt, verraet ihn gar nicht. */ /* 🔑 Die eine Zeile, die den Abend des 23.09.2026 haette sparen * koennen: Steht hier ein Name, kommt das Speichern an und es hakt am * Verbinden. Steht "nichts gespeichert", hakt es davor. Zwei voellig * verschiedene Baustellen -- und ohne diese Anzeige raet man. */ const char *gemerkt = netz_gespeicherte_ssid(); snprintf(zeile, sizeof(zeile), "

" "Gespeichert: %s

", gemerkt[0] ? "#e8f5e9" : "#fff4e5", gemerkt[0] ? "#a5d6a7" : "#ffcc80", gemerkt[0] ? gemerkt : "nichts"); httpd_resp_sendstr_chunk(req, zeile); int grund = netz_letzter_grund(); if (grund != 0) { const char *klartext; switch (grund) { case 15: case 204: klartext = "Passwort stimmt nicht"; break; case 201: klartext = "Netz nicht gefunden - falscher Name, oder 5 GHz " "(die Kamera kann nur 2,4 GHz)"; break; case 2: case 3: case 4: klartext = "Verschluesselung passt nicht"; break; case 205: klartext = "Zugangspunkt hat die Verbindung beendet"; break; default: klartext = "siehe Espressif-Liste der WLAN-Gruende"; break; } snprintf(zeile, sizeof(zeile), "

" "Letzter Versuch gescheitert.
Grund %d: %s" "
Abrisse insgesamt: %d

", grund, klartext, netz_trennungen()); httpd_resp_sendstr_chunk(req, zeile); } if (n > 0) { httpd_resp_sendstr_chunk(req, "

Wähle dein WLAN und gib das Passwort ein.

" "
" ""); } else { /* Kein Fund ist kein Grund aufzugeben -- versteckte Netze und * schlechter Empfang gibt es wirklich. */ httpd_resp_sendstr_chunk(req, "

Es wurde kein Netz gefunden. Du kannst den Namen " "von Hand eingeben.

" "" "" ""); } httpd_resp_sendstr_chunk(req, "" "" "
"); /* ⚠ Der Schluessel gehoert AUF DIESE Seite, nicht erst auf die * Bestaetigung danach. Wer ihn nachschlagen will, hat sein WLAN ja * laengst eingerichtet -- muesste er zum Nachsehen erst wieder alles * neu eintragen, waere das eine Zumutung. So genuegt es, das Geraet * kurz in die Einrichtung zu schicken und hier nachzulesen. */ snprintf(zeile, sizeof(zeile), "

Schlüssel für " "Updates über WLAN:

" "%s", netz_ota_token()); httpd_resp_sendstr_chunk(req, zeile); httpd_resp_send_chunk(req, NULL, 0); free(funde); return ESP_OK; } static esp_err_t zugang_uebernehmen(httpd_req_t *req, char *rumpf); /* ⚠ Auch per GET erreichbar, und das ist kein Schoenheitsfehler: * * Am 23.09.2026 kam das abgesendete Formular nie beim Geraet an -- weder * vom Handy noch vom Mac -- und wir konnten stundenlang nicht * unterscheiden, ob das Absenden scheitert oder das Speichern. Mit einem * Aufruf als Adresse laesst sich genau das trennen: * * http://192.168.4.1/speichern?ssid=MeinNetz&pass=geheim * * Es ist ausserdem der Weg, der bleibt, wenn ein Browser sich quer * stellt. Im Einrichtungsnetz ist nichts zu holen, was das Formular * nicht ohnehin entgegennimmt. */ static esp_err_t speichern_get_handler(httpd_req_t *req) { char rumpf[256] = {0}; size_t laenge = httpd_req_get_url_query_len(req) + 1; if (laenge <= 1 || laenge > sizeof(rumpf)) { httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_sendstr(req, "" "" "

So aufrufen:
/speichern?ssid=NETZ&pass=PASSWORT"); return ESP_OK; } httpd_req_get_url_query_str(req, rumpf, laenge); return zugang_uebernehmen(req, rumpf); } static esp_err_t speichern_handler(httpd_req_t *req) { char rumpf[256] = {0}; int laenge = req->content_len < (int)sizeof(rumpf) - 1 ? req->content_len : (int)sizeof(rumpf) - 1; int gelesen = httpd_req_recv(req, rumpf, laenge); if (gelesen <= 0) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "nichts empfangen"); return ESP_FAIL; } rumpf[gelesen] = '\0'; return zugang_uebernehmen(req, rumpf); } /* Der gemeinsame Teil von POST und GET. */ static esp_err_t zugang_uebernehmen(httpd_req_t *req, char *rumpf) { char ssid[NETZ_SSID_MAX + 1] = {0}; char pass[NETZ_PASS_MAX + 1] = {0}; httpd_query_key_value(rumpf, "ssid", ssid, sizeof(ssid)); httpd_query_key_value(rumpf, "pass", pass, sizeof(pass)); url_entschluesseln(ssid); url_entschluesseln(pass); if (ssid[0] == '\0') { httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_sendstr(req, "" "

Es fehlt der WLAN-Name. Zurück"); return ESP_OK; } esp_err_t fehler = ESP_OK; if (!netz_zugang_speichern(ssid, pass, &fehler)) { /* ⚠ Sichtbar scheitern statt still. Vorher riss das Geraet hier * ab, der Browser wartete ewig, und niemand erfuhr warum. */ char meldung[512]; snprintf(meldung, sizeof(meldung), "" "" "" "

Speichern fehlgeschlagen

" "

Die Kamera konnte die Zugangsdaten nicht ablegen.

" "

Grund: %s (0x%x)

" "

Zurück

", esp_err_to_name(fehler), (unsigned)fehler); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_sendstr(req, meldung); return ESP_OK; } char sicher[208]; html_sicher(ssid, sicher, sizeof(sicher)); /* Grosszuegig: fester Text, SSID (bis 208 nach Maskierung) und das * 48-stellige Geheimnis. Bei 1024 hat der Uebersetzer zu Recht * gewarnt, dass es abgeschnitten werden koennte. */ char antwort[2048]; snprintf(antwort, sizeof(antwort), "" "" "" "

Gespeichert

" "

Die Kamera startet neu und verbindet sich mit %s.

" "

Dieses Netz verschwindet gleich — das ist " "richtig so. Verbinde dein Handy wieder mit dem gewohnten WLAN.

" "

Klappt die Anmeldung nicht, kommt die Kamera " "von selbst wieder mit diesem Einrichtungsnetz zurück.

" "
" "

Schlüssel für Updates

" "

Notiere ihn. Mit ihm lässt sich die " "Kamera später über WLAN aktualisieren, ohne sie " "abzubauen.

" "%s" "

Verloren? Dann die Kamera " "über /wlan-vergessen zurücksetzen — hier " "steht er wieder.

", sicher, netz_ota_token()); httpd_resp_set_type(req, "text/html; charset=utf-8"); httpd_resp_sendstr(req, antwort); free(antwort); /* Kurz warten, damit die Antwort noch rausgeht -- ein Neustart mitten * im Senden laesst den Nutzer vor einer leeren Seite sitzen. */ vTaskDelay(pdMS_TO_TICKS(1200)); esp_restart(); return ESP_OK; } static httpd_handle_t einrichtung_server_start(void) { httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); cfg.server_port = 80; /* Im Einrichtungsnetz der uebliche Port */ cfg.ctrl_port = 32769; cfg.lru_purge_enable = true; /* 🔑 DAS war der Fehler, der den 23.09.2026 gekostet hat. * * Die Vorgabe sind 4 KB Stapelspeicher. Der Speichern-Handler legte * dort 2,5 KB ab (Anfrage, maskierte SSID, Antwortseite) -- dazu das, * was snprintf selbst braucht. Ergebnis: Stapelueberlauf, und zwar * genau beim Absenden des Formulars. * * Was man davon sah: Die Seite liess sich oeffnen (der Handler dafuer * ist sparsam), aber das Abschicken warf das Geraet in einen * Neustart -- ohne Fehlermeldung, ohne gespeicherte Daten. Von aussen * sah es aus, als kaeme die Anfrage nie an. * * Der Hauptserver hatte die 8 KB von Anfang an. Nur dieser hier nicht. */ cfg.stack_size = 8192; httpd_handle_t srv = NULL; if (httpd_start(&srv, &cfg) != ESP_OK) return NULL; httpd_uri_t formular = { .uri = "/", .method = HTTP_GET, .handler = formular_handler }; httpd_uri_t speichern = { .uri = "/speichern", .method = HTTP_POST, .handler = speichern_handler }; httpd_uri_t speichern_get = { .uri = "/speichern", .method = HTTP_GET, .handler = speichern_get_handler }; httpd_register_uri_handler(srv, &formular); httpd_register_uri_handler(srv, &speichern); httpd_register_uri_handler(srv, &speichern_get); return srv; } /* ------------------------------------------------------------------ main */ void app_main(void) { esp_err_t nvs = nvs_flash_init(); if (nvs == ESP_ERR_NVS_NO_FREE_PAGES || nvs == ESP_ERR_NVS_NEW_VERSION_FOUND) { ESP_ERROR_CHECK(nvs_flash_erase()); ESP_ERROR_CHECK(nvs_flash_init()); } /* ⚠ Das Geheimnis JETZT erzeugen -- vor allem, was mit Funk zu tun * hat. Es anzulegen heisst, in den Flash zu schreiben, und dabei wird * der Befehlszwischenspeicher kurz abgeschaltet. Das waehrend eines * laufenden Zugangspunkts zu tun, waehrend ein Handy verbunden ist und * nebenher nach Netzen gesucht wird, ist grob fahrlaessig. * * Genau so stand es am 23.09.2026 zuerst hier: erzeugt beim Anzeigen * des Einrichtungsformulars. Danach fand die Kamera ihr WLAN nicht * mehr wieder. Ob es die Ursache war, liess sich aus der Ferne nicht * beweisen -- aber der Zeitpunkt war so oder so falsch gewaehlt. * * Hier ist es harmlos: kein Funk aktiv, nichts verbunden. Danach * liegt es im Zwischenspeicher und wird nur noch gelesen. */ ESP_LOGW(TAG, "Schluessel fuer Updates: %s", netz_ota_token()); zuschauer_platz = xSemaphoreCreateMutex(); #if SOC_TEMP_SENSOR_SUPPORTED temperature_sensor_config_t ts = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); if (temperature_sensor_install(&ts, &tsens) == ESP_OK) { temperature_sensor_enable(tsens); } #endif /* 🔑 Netz ZUERST, Kamera danach -- und das ist nicht Geschmackssache. * * Bis zum 23.09.2026 stand es andersherum. Dann liess sich die Kamera * einen Abend lang nicht einrichten: Die Seite kam, das Abschicken * erreichte das Geraet aber nie, und gespeichert wurde nichts. * * Der entscheidende Hinweis kam aus der Praxis: Bei zwei anderen * Pixelmatrix laeuft dieselbe netz.c seit Monaten ohne Murren. Der * Unterschied zwischen ihnen und dieser Kiste ist genau eine Sache -- * die Kamera. Ihr Treiber belegt grosse Puffer und eigene * Uebertragungskanaele, und das alles lief hier schon, bevor das WLAN * ueberhaupt anfing. * * Im Einrichtungsmodus wird sie jetzt gar nicht erst gestartet. Dort * gibt es nichts zu filmen, und der Weg ist damit derselbe wie bei * Fred -- der nachweislich funktioniert. */ netz_init(); /* Zwei Wege, und die Reihenfolge ist Absicht: Erst versuchen wir das * gespeicherte Netz. Nur wenn das scheitert -- oder nie eines da war -- * machen wir die Einrichtung auf. So kommt ein Geraet im Garten nach * einem Router-Neustart von selbst zurueck, statt als offener * Zugangspunkt dazustehen. */ bool verbunden = netz_zugang_vorhanden() && netz_verbinden(); if (!verbunden) { ESP_LOGW(TAG, "Keine Verbindung — Einrichtung wird geoeffnet"); netz_setup_ap_start(); /* Jetzt suchen, solange garantiert niemand verbunden ist. */ netz_funde_auffrischen(); if (einrichtung_server_start() == NULL) { ESP_LOGE(TAG, "Einrichtungsserver startet nicht — Neustart"); esp_restart(); } ESP_LOGI(TAG, "WLAN '%s' offen, Formular auf http://192.168.4.1/", netz_setup_ssid()); return; /* Kamera bleibt aus -- hier wird nichts gefilmt. */ } /* Kein Stromsparen: Der Empfaenger darf zwischen zwei Bildern nicht * schlafen, sonst ruckelt der Stream. Die Bildrate ist hier der Zweck * des Geraets, nicht die Batterielaufzeit. */ esp_wifi_set_ps(WIFI_PS_NONE); /* Erst jetzt die Kamera: Das Netz steht, die Einrichtung ist vorbei. */ kamera_fehler = kamera_start(); kamera_bereit = (kamera_fehler == ESP_OK); if (!kamera_bereit) { /* Bewusst kein Abbruch: der Server soll laufen, damit /status die * Ursache nennt. Sonst steht man im Garten und raet. */ ESP_LOGE(TAG, "Kamera nicht bereit: %s (0x%x). Falsche Board-Auswahl " "im menuconfig?", esp_err_to_name(kamera_fehler), (unsigned)kamera_fehler); } if (server_start() == NULL) { ESP_LOGE(TAG, "HTTP-Server startet nicht — Neustart"); esp_restart(); } /* Kein Abbruch, wenn die Diagnose nicht startet: Der Stream ist der * Zweck des Geraets. Ohne Diagnose ist es unbequem, ohne Stream * nutzlos. */ if (diagnose_server_start() == NULL) { ESP_LOGW(TAG, "Diagnose-Server startet nicht — /status nur auf %d", CONFIG_VK_PORT); } ESP_LOGI(TAG, "Bereit: http://%s:%d/stream", netz_ip(), CONFIG_VK_PORT); ESP_LOGI(TAG, "Diagnose: http://%s:%d/status", netz_ip(), CONFIG_VK_PORT + 1); }