Files
vogelalbum/esp32cam/main/vogelkamera.c
T
christian cc9e0786c2 Vogelalbum 1.0.0
Eine Kamera am Vogelhaus, ein Rechner der erkennt wer da war — und eine
Sammlung, die sich von allein fuellt.

Zwei neuronale Netze: eines findet den Vogel im Bild, eines bestimmt die
Art. 148 heimische Arten, auf Deutschland eingeschraenkt und neu normiert —
ohne diese Einschraenkung wird aus einer Kohlmeise eine nordamerikanische
Schwesterart. Daraus entsteht ein Sammelalbum: nummerierte Seiten,
gefundene Arten in Farbe, fehlende als Schatten, dazu Erstfang, Punkte,
Stufen und 15 Abzeichen.

Laeuft vollstaendig auf eigener Hardware. Kein Cloud-Dienst, kein Konto,
keine Telemetrie; die Bilder verlassen das eigene Netz nicht.

Zwei getrennt deploybare Teile: die Sammlung als Django-Anwendung, die
Erkennung als eigener Dienst. Der Erkennungs-Dienst fragt selbst an und
braucht deshalb weder eine erreichbare Adresse noch einen offenen Port —
er laeuft hinter einem gewoehnlichen Router.

Dabei: Firmware fuer Raspberry Pi und ESP32-S3, Anbindung an Home
Assistant ueber MQTT, Oberflaeche auf Deutsch und Englisch, Einstellungen
je Kamera aus dem Browser, und ein Weg fuer Fotos von unterwegs.

AGPL-3.0.
2026-10-14 18:55:23 +00:00

1149 lines
45 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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://<kamera>:8080/stream MJPEG
* http://<kamera>:8080/ Vorschauseite
*
* http://<kamera>:8081/status JSON: fps, RSSI, Temperatur, Laufzeit
* (gleiche Felder wie pi/vogelkamera.py)
* http://<kamera>:8081/temp Chiptemperatur als Text
* http://<kamera>: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 <stdio.h>
#include <string.h>
#include <sys/time.h>
#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[] =
"<!doctype html>\n"
"<html lang=\"de\"><head><meta charset=\"utf-8\"><title>Vogelkamera</title>\n"
"<style>body{margin:0;background:#111;color:#eee;font-family:system-ui,sans-serif}\n"
"img{display:block;width:100%;height:auto}p{padding:10px 14px;margin:0;font-size:14px}</style>\n"
"</head><body>\n"
"<img src=\"/stream\" alt=\"Livebild\">\n"
"<p>Solange diese Seite offen ist, bekommt der Detector kein Bild.</p>\n"
"</body></html>\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: <geheim>" http://<kamera>: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,
"<!doctype html><meta charset=utf-8>"
"<body style=\"font-family:system-ui;padding:24px\">"
"<h1>WLAN vergessen</h1>"
"<p>Die Kamera startet neu und &ouml;ffnet ihr Einrichtungsnetz.</p>");
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, "&lt;"); break;
case '>': k += snprintf(ziel + k, max - k, "&gt;"); break;
case '&': k += snprintf(ziel + k, max - k, "&amp;"); break;
case '"': k += snprintf(ziel + k, max - k, "&quot;"); break;
default: ziel[k++] = quelle[i];
}
}
ziel[k] = '\0';
}
#define FUNDE_MAX 20
static const char EINRICHT_KOPF[] =
"<!doctype html><html lang=de><meta charset=utf-8>"
"<meta name=viewport content='width=device-width,initial-scale=1'>"
"<title>Vogelkamera einrichten</title><style>"
"body{font-family:system-ui,sans-serif;background:#eef0f4;margin:0;padding:20px;color:#222}"
"h1{font-size:21px;margin:0 0 4px}"
".hin{color:#666;font-size:14px;margin:0 0 16px}"
"form{background:#fff;border-radius:14px;padding:18px}"
"label{display:block;font-size:13px;color:#666;margin:14px 0 6px}"
"select,input{width:100%;box-sizing:border-box;font-size:17px;padding:12px;"
"border:1px solid #d0d0d8;border-radius:9px;background:#fff}"
"button{width:100%;margin-top:22px;padding:15px;font-size:17px;font-weight:600;"
"border:0;border-radius:9px;background:#2b6cb0;color:#fff}"
"</style><h1>Vogelkamera einrichten</h1>";
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),
"<p style=\"background:%s;border:1px solid %s;border-radius:9px;"
"padding:10px 12px;margin:0 0 14px;font-size:14px\">"
"Gespeichert: <b>%s</b></p>",
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),
"<p style=\"background:#fde8e8;border:1px solid #f5b5b5;"
"border-radius:9px;padding:12px;margin:0 0 16px;font-size:14px\">"
"<b>Letzter Versuch gescheitert.</b><br>Grund %d: %s"
"<br><span style=color:#666>Abrisse insgesamt: %d</span></p>",
grund, klartext, netz_trennungen());
httpd_resp_sendstr_chunk(req, zeile);
}
if (n > 0) {
httpd_resp_sendstr_chunk(req,
"<p class=hin>W&auml;hle dein WLAN und gib das Passwort ein.</p>"
"<form method=post action=/speichern>"
"<label for=ssid>WLAN</label><select id=ssid name=ssid>");
char sicher[208];
for (int i = 0; i < n; i++) {
html_sicher(funde[i].ssid, sicher, sizeof(sicher));
snprintf(zeile, sizeof(zeile), "<option value=\"%s\">%s%s</option>",
sicher, sicher, funde[i].verschluesselt ? "" : " (offen)");
httpd_resp_sendstr_chunk(req, zeile);
}
httpd_resp_sendstr_chunk(req, "</select>");
} else {
/* Kein Fund ist kein Grund aufzugeben -- versteckte Netze und
* schlechter Empfang gibt es wirklich. */
httpd_resp_sendstr_chunk(req,
"<p class=hin>Es wurde kein Netz gefunden. Du kannst den Namen "
"von Hand eingeben.</p>"
"<form method=post action=/speichern>"
"<label for=ssid>WLAN-Name</label>"
"<input id=ssid name=ssid autocapitalize=off autocorrect=off>");
}
httpd_resp_sendstr_chunk(req,
"<label for=pw>Passwort</label>"
"<input id=pw name=pass type=password autocapitalize=off autocorrect=off>"
"<button type=submit>Speichern und verbinden</button></form>");
/* ⚠ 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),
"<p class=hin style=\"margin-top:20px\">Schl&uuml;ssel f&uuml;r "
"Updates &uuml;ber WLAN:</p>"
"<code style=\"display:block;word-break:break-all;background:#fff;"
"padding:12px;border-radius:9px;font-size:13px\">%s</code>",
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,
"<!doctype html><meta charset=utf-8>"
"<body style=\"font-family:system-ui;padding:24px\">"
"<p>So aufrufen:<br><code>/speichern?ssid=NETZ&amp;pass=PASSWORT</code>");
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, "<!doctype html><meta charset=utf-8>"
"<p>Es fehlt der WLAN-Name. <a href=/>Zur&uuml;ck</a>");
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),
"<!doctype html><html lang=de><meta charset=utf-8>"
"<meta name=viewport content='width=device-width,initial-scale=1'>"
"<body style=\"font-family:system-ui;padding:24px\">"
"<h1>Speichern fehlgeschlagen</h1>"
"<p>Die Kamera konnte die Zugangsdaten nicht ablegen.</p>"
"<p><b>Grund:</b> %s (0x%x)</p>"
"<p><a href=/>Zur&uuml;ck</a></p>",
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),
"<!doctype html><html lang=de><meta charset=utf-8>"
"<meta name=viewport content='width=device-width,initial-scale=1'>"
"<body style=\"font-family:system-ui;padding:24px\">"
"<h1>Gespeichert</h1>"
"<p>Die Kamera startet neu und verbindet sich mit <b>%s</b>.</p>"
"<p style=color:#666>Dieses Netz verschwindet gleich &mdash; das ist "
"richtig so. Verbinde dein Handy wieder mit dem gewohnten WLAN.</p>"
"<p style=color:#666>Klappt die Anmeldung nicht, kommt die Kamera "
"von selbst wieder mit diesem Einrichtungsnetz zur&uuml;ck.</p>"
"<hr style=\"border:0;border-top:1px solid #ddd;margin:22px 0\">"
"<h2 style=\"font-size:16px\">Schl&uuml;ssel f&uuml;r Updates</h2>"
"<p style=color:#666>Notiere ihn. Mit ihm l&auml;sst sich die "
"Kamera sp&auml;ter &uuml;ber WLAN aktualisieren, ohne sie "
"abzubauen.</p>"
"<code style=\"display:block;word-break:break-all;background:#f2f2f5;"
"padding:12px;border-radius:8px;font-size:14px\">%s</code>"
"<p style=color:#666;font-size:13px>Verloren? Dann die Kamera "
"&uuml;ber <b>/wlan-vergessen</b> zur&uuml;cksetzen &mdash; hier "
"steht er wieder.</p>",
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);
}