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.
1149 lines
45 KiB
C
1149 lines
45 KiB
C
/* 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 ö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, "<"); 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[] =
|
||
"<!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ä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üssel für "
|
||
"Updates ü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&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ü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ü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 — 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ück.</p>"
|
||
"<hr style=\"border:0;border-top:1px solid #ddd;margin:22px 0\">"
|
||
"<h2 style=\"font-size:16px\">Schlüssel für Updates</h2>"
|
||
"<p style=color:#666>Notiere ihn. Mit ihm lässt sich die "
|
||
"Kamera später ü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 "
|
||
"über <b>/wlan-vergessen</b> zurücksetzen — 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);
|
||
|
||
}
|