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.
This commit is contained in:
christian committed 2026-10-15 14:30:02 +00:00
commit c11fe9ab04
189 files changed
+29806

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(vogelkamera)
+164
View File
@@ -0,0 +1,164 @@
# Vogelkamera auf ESP32-S3
Kameraseite für Vogelalbum auf einem ESP32-S3 mit Kameramodul — das Gegenstück zu
[`../pi/`](../pi/README.md). Gleiche Pfade und gleiches JSON-Schema: der
Detector merkt nicht, ob ein Pi Zero oder ein ESP32 am anderen Ende hängt.
http://<kamera>:8080/stream MJPEG, ein Zuschauer
http://<kamera>:8080/ Vorschauseite
http://<kamera>:8081/status JSON: fps, RSSI, Temperatur, Laufzeit
http://<kamera>:8081/temp Chiptemperatur als Text
http://<kamera>:8081/gesund 200 oder 503
🔑 **Die Diagnose liegt auf einem eigenen Port — das ist der eine Unterschied
zum Pi.** Der Webserver im ESP32 arbeitet Anfragen mit *einem* Faden ab, und
der Stream gibt den nie zurück. Solange jemand zuschaut, käme auf 8080 niemand
mehr an `/status` — also genau dann nicht, wenn man es braucht. Am 23.09.2026
wirkte die Kamera deshalb tot, während sie brav streamte. Seitdem hat die
Diagnose ihren eigenen Port und ihren eigenen Faden.
⚠️ **Bis zum 09.10.2026 stand hier für alles `8080`.** Wer die Zeile kopierte,
bekam eine Anfrage, die stumm ins Zeitlimit lief — dasselbe Bild wie eine tote
Kamera. Der Port ist `VK_PORT + 1`; wer im `menuconfig` den Hauptport ändert,
verschiebt beide.
## Warum ESP32 statt Pi
Am Vogelhaus ist die Bildrate **bandbreitenbegrenzt, nicht rechenbegrenzt**:
der Pi Zero W drückt über seine Chip-Antenne rund 2 Mbit/s durchs WLAN, und
jeder gemessene Einbruch hing an der Funkstrecke. Ein ESP32-S3-Board mit
**externer Antenne** greift genau da an — und kostet ein Vielfaches weniger,
was für einen Bausatz den Unterschied zwischen verkaufbar und nicht
verkaufbar macht.
## Hardware
Getestet wird gegen ein **ESP32-S3-CAM mit OV3660** (N16R8-Modul: 16 MB Flash,
8 MB PSRAM, externe Antenne). Die Firmware läuft aber auf jedem Board, dessen
Pinbelegung im `menuconfig` hinterlegt ist.
> ⚠️ **Das Funkmodul muss ein ESP32-S3-WROOM-1*U* sein** — das *U* steht für
> die Antennenbuchse. Bei manchen Klonen liegt eine Antenne bei, das Modul hat
> aber nur die Platinenantenne; dann ist das Kabel Deko.
> ⚠️ **OV5640 nicht verwenden.** Auf diesen Boards liefert er dokumentiert ein
> schwarzes Bild, und der Sensor überhitzt, weil die Versorgung für seinen
> internen 1,5-V-Regler zu hoch liegt. OV2640 und OV3660 laufen.
## Bauen und flashen
```sh
. ~/esp/esp-idf/export.sh
cd esp32cam
idf.py set-target esp32s3
idf.py menuconfig # Menü "Vogelkamera": WLAN, Board, Bildgröße
idf.py -p /dev/ttyACM0 flash monitor
```
Die WLAN-Daten stehen bewusst **nicht** im Repo — sie landen über
`menuconfig` in `sdkconfig`, und das ist per `.gitignore` draußen.
Zwei USB-C-Buchsen am Board: eine hängt am USB-Seriell-Wandler (CH340), die
andere am nativen USB des Chips. Findet `idf.py flash` nichts, ist es die
andere Buchse. Je nach Board muss zum Flashen BOOT gehalten und RESET kurz
gedrückt werden.
### Wenn die Kamera nicht anspringt
`esp_camera_init` scheitert mit `ESP_ERR_NOT_FOUND` (0x105), wenn die
Pinbelegung nicht zum Board passt — dann ist die **Board-Auswahl im
`menuconfig`** falsch, nicht die Kamera defekt. Die Firmware bricht deshalb
nicht ab: der HTTP-Server läuft weiter und `/status` nennt den Fehlercode.
Ein Gerät im Garten, das bei einem Fehler stumm bleibt, kostet einen Weg
nach draußen.
Zweiter Kandidat: `CONFIG_SPIRAM_MODE_OCT` in `sdkconfig.defaults`. Octal ist
für die R8-Module richtig; ein R2- oder Quad-Modul braucht `QUAD`, sonst
findet die Kamera keinen Framebuffer.
## Ein Zuschauer, mit Absicht
Ein zweiter Client bekommt **503** statt eines halben Bildes. Der Grund steht
im Kopf von [`main/vogelkamera.c`](main/vogelkamera.c): ein zweiter Zuschauer
sättigt die Funkstrecke, der Detector fällt in Timeouts, und zwar ohne dass
irgendwo ein Fehler auftaucht — die Bildrate halbiert sich einfach. Genau das
ist bei der Entwicklung passiert: das Diagnosewerkzeug hat den Fehler erzeugt,
den es messen sollte.
Deshalb gibt es `/status` und `/temp`. **Beide fassen den Stream nicht an** und
sind der richtige Weg, die Kamera im Betrieb zu prüfen:
```sh
curl -s http://<kamera>:8081/status
{"kamera":"bereit","fehler":"0x0","laufzeit_s":615201,"bilder":3206804,
"fps":5.25,"letztes_bild_s":0.1,"zuschauer":true,"rssi":-71,
"temperatur_c":51.7,"speicher_frei":8104348,"psram_frei":7941536}
```
(Echte Ausgabe einer Kamera an einer Vogeltränke nach sieben Tagen
Dauerbetrieb.) **Port 8081, nicht 8080** — siehe oben.
`ping` taugt dafür **nicht** — ICMP fällt bei gesättigtem WLAN als Erstes aus,
während TCP weiterläuft. Eine Entwarnung per `ping` ist Glück, keine Messung.
Was die Werte sagen:
| Feld | Gesund | Woran es sonst liegt |
|---|---|---|
| `fps` | stabil über Stunden | fällt → Funkstrecke, zweiter Zuschauer, Hitze |
| `rssi` | über −70 dBm | darunter Antenne ausrichten oder näher ran |
| `temperatur_c` | unter 70 °C | geschlossenes Gehäuse in der Sonne drosselt |
| `letztes_bild_s` | unter 1 s bei Zuschauer | steht → Sensor hängt, siehe unten |
| `zuschauer` | `true` im Normalbetrieb | `false`, obwohl der Detector läuft → er kommt nicht durch |
Bleibt der Sensor 30 Versuche am Stück stumm, startet das Gerät selbst neu.
Zwei Sekunden Neustart sind billiger als ein Weg in den Garten.
## Einstellungen, die wirklich etwas bringen
**Bildgröße und Qualität sind Bandbreite, nicht Schönheit.** Weniger Qualität
heißt mehr Bilder pro Sekunde, und mehr Bilder finden mehr Vögel als schönere.
Default ist XGA (1024x768) bei Qualität 12.
**Der begrenzende Faktor ist der Bildwinkel, nicht die Auflösung.** Am
Futterhaus scheitert die Erkennung daran, dass der Vogel unter den nötigen
5–8 % Bildfläche bleibt. Mehr Megapixel ändern daran nichts, nähere Montage
schon — 30–40 cm an die Landefläche, und die Landefläche in die untere
Bildmitte, nicht an den Bildrand (der unterste Bildrand ist eine tote Zone,
siehe `python -m detector karte`).
**Kein WLAN-Stromsparen.** `WIFI_PS_NONE` ist gesetzt: der Empfänger darf
zwischen zwei Bildern nicht schlafen. Die Bildrate ist der Zweck des Geräts,
nicht die Batterielaufzeit.
## Feste Adresse
Der Detector braucht eine gleichbleibende Adresse. Der Dev-Server löst **kein
mDNS** auf (kein avahi), `.local`-Namen helfen also nicht — im Router eine
feste DHCP-Lease für die MAC eintragen.
## Im Dauerbetrieb
Die Firmware läuft an einer Vogeltränke im Garten. Gemessen am laufenden
Detector, drei aufeinanderfolgende Minuten:
| | Bilder/s |
|---|---|
| **ESP32-S3-CAM mit OV3660** (dieses Projekt) | **5,2** |
| Raspberry Pi Zero W mit OV5647 (zum Vergleich) | 4,4 |
Insgesamt hat der Detector an dieser Kamera bisher über 2,8 Millionen Bilder
verarbeitet. Die Bildrate ist bandbreitenlimitiert, nicht rechenlimitiert —
deshalb der Rat zur Modulvariante mit Antennenbuchse (ESP32-S3-WROOM-1**U**)
und externer Antenne.
**Ungetestet sind die übrigen Pinbelegungen**: Gebaut und betrieben wurde
bisher nur die S3-CAM-Variante. XIAO-S3 und AI-Thinker sind in `menuconfig`
vorgesehen, aber nie auf Hardware gelaufen.
## Was fehlt
OTA-Updates sind nicht eingebaut. Die Partitionstabelle lässt hinter der
4-MB-App genug Platz, um sie ohne Umbau nachzurüsten — für ein Gerät, das
später im Garten festsitzt, wäre das der nächste sinnvolle Schritt.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
idf_component_register(
SRCS "vogelkamera.c" "netz.c"
INCLUDE_DIRS ""
# app_update liefert esp_ota_ops.h -- ohne das kein Update ueber WLAN.
REQUIRES esp_wifi esp_http_server esp_netif esp_event nvs_flash
esp_timer esp_driver_tsens lwip app_update
)
+124
View File
@@ -0,0 +1,124 @@
menu "Vogelkamera"
# ⚠ Hier standen bis zum 23.09.2026 WLAN-Name und Passwort. Sie sind
# bewusst entfernt: Zugangsdaten, die beim Bauen eingebacken werden,
# zwingen jeden Nutzer dazu, die Firmware selbst zu uebersetzen. Fuer
# ein veroeffentlichtes Repo ist das untauglich.
#
# Stattdessen: Das Geraet macht beim ersten Start ein eigenes WLAN auf
# und fragt im Browser danach. Siehe netz.c, uebernommen aus Hey Fred.
config VK_PORT
int "HTTP-Port"
default 8080
help
Gleicher Port wie am Pi. Der Detector erwartet
http://<kamera>:8080/stream.
choice VK_BOARD
prompt "Pinbelegung der Kamera"
default VK_BOARD_S3_WROOM
help
Die Pins haengen am Board, nicht am Sensor. Stimmt die Belegung
nicht, scheitert esp_camera_init mit 0x105 (ESP_ERR_NOT_FOUND) —
dann ist es die falsche Wahl hier, nicht die Kamera.
config VK_BOARD_S3_WROOM
bool "ESP32-S3-WROOM CAM (Freenove, Heemol und Klone)"
config VK_BOARD_XIAO_S3
bool "Seeed XIAO ESP32S3 Sense"
config VK_BOARD_AI_THINKER
bool "AI-Thinker ESP32-CAM (klassisch, ESP32 statt S3)"
endchoice
config VK_FRAMESIZE
int "Bildgroesse"
default 13
range 0 21
help
⚠ Die Zahlen sind Positionen in framesize_t aus dem
Kameratreiber -- und die haben sich schon einmal verschoben.
Am 23.09.2026 stand hier "9 = XGA 1024x768"; tatsaechlich war
9 laengst HVGA 480x320. Die Kamera lief daraufhin monatelang
auf einem Sechstel der Flaeche, ohne dass etwas gemeldet haette.
Nach einem Treiber-Update deshalb gegen sensor.h pruefen:
managed_components/espressif__esp32-camera/driver/include/sensor.h
Stand des Treibers vom 23.09.2026:
11 = SVGA 800x600
12 = XGA 1024x768
13 = HD 1280x720 (Vorgabe, wie der Pi am Futterhaus)
14 = SXGA 1280x1024
15 = UXGA 1600x1200
Mehr ist nicht besser: ueber WLAN ist die Bildrate
bandbreitenbegrenzt, nicht rechenbegrenzt. Aber zu klein
findet keine Voegel -- unter 5-8 % Bildflaeche erkennt das
Modell sie nicht mehr.
config VK_JPEG_QUALITAET
int "JPEG-Qualitaet (10 = gut, 63 = grob)"
default 12
range 4 63
help
Kleinere Zahl heisst besseres Bild und groessere Dateien. Bei
knapper Funkstrecke bringt eine hoehere Zahl mehr Bilder pro
Sekunde — und mehr Bilder finden mehr Voegel als schoenere.
config VK_HFLIP
bool "Bild horizontal spiegeln"
default n
config VK_VFLIP
bool "Bild vertikal spiegeln (Kamera haengt ueber Kopf)"
default n
config VK_MAX_FPS
int "Bilder je Sekunde hoechstens senden (0 = ohne Bremse)"
range 0 30
default 5
help
Ohne Bremse sendet die Kamera so schnell die Funkstrecke
hergibt -- gemessen 16 Bilder/s. Das heizt den Chip und
saettigt bei schwachem Empfang die Strecke.
Gebraucht wird es nicht: Ein Vogelbesuch dauert Sekunden bis
Minuten, und der Detector bestimmt nur jedes sechste Bild.
Fuenf sind reichlich.
Gemessen am 25.09.2026 an der Traenke, in der Sonne:
17 B/s -> Plateau 78,8 °C, Empfang -69 dBm
5 B/s -> Plateau 76,7 °C, Empfang -57 dBm
Zwei Grad kuehler, und zwoelf dB mehr Reserve auf der
Funkstrecke. Letzteres war nicht beabsichtigt und ist der
groessere Gewinn.
config VK_SAETTIGUNG
int "Farbsaettigung (-2 bis 2)"
range -2 2
default 2
help
Der Sensor liefert von Haus aus ein flaues Bild. Neben der
Pi-Kamera am Futterhaus faellt das sofort auf. Farbe ist hier
nicht nur Geschmack: Die Arterkennung arbeitet damit.
config VK_KONTRAST
int "Kontrast (-2 bis 2)"
range -2 2
default 1
help
Nicht ans Maximum. Zu viel Kontrast frisst die Zeichnung im
dunklen Gefieder — und genau die braucht der Erkenner.
config VK_HELLIGKEIT
int "Helligkeit (-2 bis 2)"
range -2 2
default 0
help
Nur anfassen, wenn das Bild durchweg zu dunkel oder zu hell
ist. Gegen Gegenlicht hilft eher das Ausrichten.
endmenu
+4
View File
@@ -0,0 +1,4 @@
dependencies:
espressif/esp32-camera: "^2.0.11"
idf:
version: ">=5.1"
+453
View File
@@ -0,0 +1,453 @@
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_random.h"
#include "esp_sntp.h"
#include <time.h>
#include <sys/time.h>
#include "netz.h"
static const char *TAG = "netz";
// Der Name des Einrichtungsnetzes steht erst fest, wenn die MAC bekannt
// ist. Einmal gebaut, danach nur noch gelesen.
static char setup_ssid[33] = {0};
const char *netz_setup_ssid(void)
{
if (setup_ssid[0] == '\0') {
uint8_t mac[6] = {0};
esp_wifi_get_mac(WIFI_IF_AP, mac);
snprintf(setup_ssid, sizeof(setup_ssid), "%s-%02X%02X",
NETZ_SETUP_PRAEFIX, mac[4], mac[5]);
}
return setup_ssid;
}
#define NVS_BEREICH "vogelkamera"
#define NVS_SSID "ssid"
#define NVS_PASS "pass"
#define NVS_OTA "ota"
// Wuerfelt beim ersten Aufruf ein Geheimnis und merkt es sich dauerhaft.
// esp_fill_random speist sich aus dem Hardware-Zufall des Chips -- nicht
// aus rand(), das auf jedem Geraet dieselbe Folge liefern wuerde.
const char *netz_ota_token(void)
{
static char token[49] = {0};
if (token[0] != '\0') return token;
nvs_handle_t h;
size_t laenge = sizeof(token);
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) == ESP_OK) {
esp_err_t r = nvs_get_str(h, NVS_OTA, token, &laenge);
nvs_close(h);
if (r == ESP_OK && token[0] != '\0') return token;
}
uint8_t roh[24];
esp_fill_random(roh, sizeof(roh));
for (size_t i = 0; i < sizeof(roh); i++) {
snprintf(token + i * 2, 3, "%02x", roh[i]);
}
if (nvs_open(NVS_BEREICH, NVS_READWRITE, &h) == ESP_OK) {
nvs_set_str(h, NVS_OTA, token);
nvs_commit(h);
nvs_close(h);
ESP_LOGI(TAG, "Update-Geheimnis erzeugt und gespeichert");
}
return token;
}
// Wie lange Fred beim Start um die Verbindung kämpft, bevor er die
// Einrichtung öffnet.
//
// Der erste Entwurf gab nach acht Anläufen auf — rund zwanzig Sekunden. Das
// war falsch gedacht: Ein Router, der nach einem Stromausfall langsamer
// hochkommt als Fred, hätte gereicht, damit ein Kindergerät im Regal
// plötzlich einen Hotspot aufmacht und niemand weiß warum. Drei Minuten
// überstehen jeden Neustart eines Zugangspunkts, und wer wirklich ein
// falsches Passwort eingetippt hat, wartet sie einmalig ab.
#define START_GEDULD_MS (3 * 60 * 1000)
// ⚠ Beim ALLERERSTEN Start, wenn noch nie Zugangsdaten da waren, gilt die
// lange Geduld nicht -- da wartet ein Mensch davor. Drei Minuten
// Schwarzsehen, bevor ueberhaupt ein Netz auftaucht, kosten Nerven und
// erzeugen den Eindruck, das Geraet sei kaputt. Am 23.09.2026 genau so
// erlebt.
#define ERSTSTART_GEDULD_MS (30 * 1000)
// Im laufenden Betrieb wird gar nicht mehr aufgegeben: Ein Aussetzer ist
// kein Grund, die gespeicherten Zugangsdaten in Frage zu stellen.
static EventGroupHandle_t s_ereignisse;
#define VERBUNDEN BIT0
static char s_ip[16];
static esp_netif_t *s_sta_netif;
static esp_netif_t *s_ap_netif;
static int s_trennungen;
static int s_letzter_grund;
static bool s_verbunden;
// Solange true, wird bei jedem Abriss sofort neu verbunden — auch endlos.
static bool s_dranbleiben;
// ------------------------------------------------------------------- NVS
const char *netz_gespeicherte_ssid(void)
{
static char ssid[NETZ_SSID_MAX + 1];
ssid[0] = '\0';
nvs_handle_t h;
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return ssid;
size_t laenge = sizeof(ssid);
if (nvs_get_str(h, NVS_SSID, ssid, &laenge) != ESP_OK) ssid[0] = '\0';
nvs_close(h);
return ssid;
}
bool netz_zugang_vorhanden(void)
{
nvs_handle_t h;
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return false;
size_t laenge = 0;
esp_err_t err = nvs_get_str(h, NVS_SSID, NULL, &laenge);
nvs_close(h);
return err == ESP_OK && laenge > 1;
}
static bool zugang_lesen(char *ssid, char *passwort)
{
nvs_handle_t h;
if (nvs_open(NVS_BEREICH, NVS_READONLY, &h) != ESP_OK) return false;
size_t l_ssid = NETZ_SSID_MAX + 1;
size_t l_pass = NETZ_PASS_MAX + 1;
esp_err_t a = nvs_get_str(h, NVS_SSID, ssid, &l_ssid);
esp_err_t b = nvs_get_str(h, NVS_PASS, passwort, &l_pass);
nvs_close(h);
// Ein offenes Netz hat kein Passwort — das ist kein Fehler.
if (b != ESP_OK) passwort[0] = '\0';
return a == ESP_OK;
}
bool netz_zugang_speichern(const char *ssid, const char *passwort,
esp_err_t *fehler)
{
// ⚠ Hier stand ESP_ERROR_CHECK auf jedem Aufruf. Das beendet bei einem
// Fehler das ganze Geraet -- mitten in der Bearbeitung einer
// Web-Anfrage. Ergebnis: Der Browser wartet ewig auf eine Antwort,
// die nie kommt, nichts ist gespeichert, und der Nutzer sieht nur,
// dass "es nicht geht". Genau so stand jemand am 23.09.2026
// einen Abend lang davor.
//
// Ein Fehler beim Speichern gehoert gemeldet, nicht verschwiegen.
esp_err_t r;
nvs_handle_t h;
r = nvs_open(NVS_BEREICH, NVS_READWRITE, &h);
if (r != ESP_OK) { if (fehler) *fehler = r; return false; }
r = nvs_set_str(h, NVS_SSID, ssid);
if (r == ESP_OK) r = nvs_set_str(h, NVS_PASS, passwort ? passwort : "");
if (r == ESP_OK) r = nvs_commit(h);
nvs_close(h);
if (r != ESP_OK) {
ESP_LOGE(TAG, "Speichern fehlgeschlagen: %s", esp_err_to_name(r));
if (fehler) *fehler = r;
return false;
}
// Zurueckgelesen statt geglaubt: Erst wenn es wirklich drinsteht,
// duerfen wir neu starten.
if (!netz_zugang_vorhanden()) {
ESP_LOGE(TAG, "Nach dem Speichern nichts gefunden");
if (fehler) *fehler = ESP_ERR_NVS_NOT_FOUND;
return false;
}
ESP_LOGI(TAG, "Zugangsdaten für '%s' gespeichert", ssid);
if (fehler) *fehler = ESP_OK;
return true;
}
void netz_zugang_loeschen(void)
{
nvs_handle_t h;
if (nvs_open(NVS_BEREICH, NVS_READWRITE, &h) != ESP_OK) return;
nvs_erase_key(h, NVS_SSID);
nvs_erase_key(h, NVS_PASS);
nvs_commit(h);
nvs_close(h);
ESP_LOGW(TAG, "Zugangsdaten gelöscht");
}
// ---------------------------------------------------------------- Ereignisse
static void ereignis(void *arg, esp_event_base_t basis, int32_t id, void *daten)
{
if (basis == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (basis == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
wifi_event_sta_disconnected_t *e = (wifi_event_sta_disconnected_t *)daten;
s_ip[0] = '\0';
s_verbunden = false;
s_trennungen++;
s_letzter_grund = e->reason;
// Der Grund ist die halbe Miete: 15 und 204 heißen falsches
// Passwort, 201 „Netz nicht gefunden", 2/3/4 zeigen auf
// Verschlüsselung und Management-Frames. Ohne diese Zahl rät man.
ESP_LOGW(TAG, "getrennt (Grund %d), Abriss Nr. %d", e->reason, s_trennungen);
// Im Ereignis nicht schlafen — der Handler blockiert sonst die
// gesamte Ereignisschleife. Ein Rückfall kommt ohnehin gleich wieder.
if (s_dranbleiben) esp_wifi_connect();
} else if (basis == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *e = (ip_event_got_ip_t *)daten;
snprintf(s_ip, sizeof(s_ip), IPSTR, IP2STR(&e->ip_info.ip));
ESP_LOGI(TAG, "verbunden, IP %s", s_ip);
s_verbunden = true;
xEventGroupSetBits(s_ereignisse, VERBUNDEN);
} else if (basis == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
ESP_LOGI(TAG, "jemand hat sich mit %s verbunden", netz_setup_ssid());
}
}
// ------------------------------------------------------------------- Start
void netz_init(void)
{
s_ereignisse = xEventGroupCreate();
s_ip[0] = '\0';
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
// Beide Schnittstellen anlegen, aber nur eine wird eingeschaltet.
s_sta_netif = esp_netif_create_default_wifi_sta();
s_ap_netif = esp_netif_create_default_wifi_ap();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &ereignis, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &ereignis, NULL, NULL));
// Die Zugangsdaten liegen bei uns im NVS, nicht in der WLAN-Ablage des
// Treibers. Sonst hätten wir zwei Wahrheiten, die auseinanderlaufen.
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
}
bool netz_verbinden(void)
{
char ssid[NETZ_SSID_MAX + 1] = {0};
char passwort[NETZ_PASS_MAX + 1] = {0};
if (!zugang_lesen(ssid, passwort)) {
ESP_LOGW(TAG, "keine Zugangsdaten im NVS");
return false;
}
wifi_config_t wcfg = {0};
strlcpy((char *)wcfg.sta.ssid, ssid, sizeof(wcfg.sta.ssid));
strlcpy((char *)wcfg.sta.password, passwort, sizeof(wcfg.sta.password));
// Geschützte Management-Frames. Durch die Null-Initialisierung stünde
// `capable` sonst auf false — und ein Netz, das PMF verlangt, weist
// Fred dann ab, obwohl das Passwort stimmt. Genau das ist bei
// IoT-Netzen von UniFi und FritzBox die Voreinstellung.
wcfg.sta.pmf_cfg.capable = true;
wcfg.sta.pmf_cfg.required = false;
// Erlaubt auch WPA3-Netze (und WPA2/WPA3 gemischt). Ohne das scheitert
// die Anmeldung an neueren Zugangspunkten ohne erkennbaren Grund.
wcfg.sta.sae_pwe_h2e = WPA3_SAE_PWE_BOTH;
// Kein Mindestniveau erzwingen: Was der Zugangspunkt anbietet, nehmen wir.
wcfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
s_dranbleiben = true;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wcfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "verbinde mit '%s' (bis zu %d s Geduld)", ssid, START_GEDULD_MS / 1000);
EventBits_t bits = xEventGroupWaitBits(
s_ereignisse, VERBUNDEN, pdFALSE, pdFALSE, pdMS_TO_TICKS(START_GEDULD_MS));
if (bits & VERBUNDEN) return true;
// Erst jetzt aufgeben — und das Nachfassen einstellen, damit der
// Einrichtungsmodus nicht gegen einen weiterlaufenden Verbindungsversuch
// arbeitet.
s_dranbleiben = false;
ESP_LOGE(TAG, "'%s' in %d s nicht erreicht (zuletzt Grund %d, %d Abrisse)"
" — Einrichtung wird geöffnet",
ssid, START_GEDULD_MS / 1000, s_letzter_grund, s_trennungen);
esp_wifi_stop();
return false;
}
void netz_setup_ap_start(void)
{
const char *name = netz_setup_ssid();
wifi_config_t wcfg = {
.ap = {
.channel = 1,
.max_connection = 4,
// Offen mit Absicht: Wer hier steht, soll ohne Zettel und ohne
// App hineinkommen. Zu holen gibt es nichts — das Formular
// nimmt nur entgegen, es gibt nichts preis.
.authmode = WIFI_AUTH_OPEN,
},
};
// APSTA statt reinem AP: Nur so lässt sich im Einrichtungsmodus nach
// Netzen suchen, und genau das braucht das Formular für seine Liste.
strlcpy((char *)wcfg.ap.ssid, name, sizeof(wcfg.ap.ssid));
wcfg.ap.ssid_len = strlen(name);
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wcfg));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "Einrichtung offen: '%s' → http://192.168.4.1/", name);
}
#define FUNDE_VORRAT 20
static netz_fund_t s_funde[FUNDE_VORRAT];
static int s_funde_anzahl = -1; // -1 = noch nie gesucht
void netz_funde_auffrischen(void)
{
s_funde_anzahl = netz_suchen(s_funde, FUNDE_VORRAT);
ESP_LOGI(TAG, "%d Netze gefunden (einmalig, vor dem ersten Besucher)",
s_funde_anzahl);
}
int netz_funde_holen(netz_fund_t *ziel, int max)
{
if (s_funde_anzahl < 0) netz_funde_auffrischen();
int n = s_funde_anzahl < max ? s_funde_anzahl : max;
for (int i = 0; i < n; i++) ziel[i] = s_funde[i];
return n;
}
int netz_suchen(netz_fund_t *ziel, int max)
{
wifi_scan_config_t cfg = { .show_hidden = false };
if (esp_wifi_scan_start(&cfg, true) != ESP_OK) {
ESP_LOGW(TAG, "Suche fehlgeschlagen");
return 0;
}
uint16_t anzahl = 0;
esp_wifi_scan_get_ap_num(&anzahl);
if (anzahl == 0) return 0;
wifi_ap_record_t *roh = calloc(anzahl, sizeof(wifi_ap_record_t));
if (!roh) return 0;
esp_wifi_scan_get_ap_records(&anzahl, roh);
// Die Liste kommt nach Feldstärke sortiert. Deshalb genügt es, jede
// SSID beim ersten Auftreten zu übernehmen — Mesh-Netze tauchen sonst
// fünfmal auf und das Formular wird unlesbar.
int n = 0;
for (int i = 0; i < anzahl && n < max; i++) {
const char *ssid = (const char *)roh[i].ssid;
if (ssid[0] == '\0') continue;
bool schon_da = false;
for (int k = 0; k < n; k++) {
if (strcmp(ziel[k].ssid, ssid) == 0) { schon_da = true; break; }
}
if (schon_da) continue;
strlcpy(ziel[n].ssid, ssid, sizeof(ziel[n].ssid));
ziel[n].rssi = roh[i].rssi;
ziel[n].verschluesselt = roh[i].authmode != WIFI_AUTH_OPEN;
n++;
}
free(roh);
ESP_LOGI(TAG, "%d Netze gefunden", n);
return n;
}
void zeit_holen(void)
{
if (esp_sntp_enabled()) return;
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "pool.ntp.org");
esp_sntp_init();
// Mitteleuropa mit Sommerzeit. Die Regel steht in der Zeichenkette,
// deshalb stimmt die Umstellung auch ohne Netz — sobald die Uhr
// einmal gestellt ist.
setenv("TZ", "CET-1CEST,M3.5.0/2,M10.5.0/3", 1);
tzset();
ESP_LOGI(TAG, "Uhrzeit wird geholt");
}
bool zeit_steht(void)
{
time_t jetzt = time(NULL);
struct tm z;
localtime_r(&jetzt, &z);
// Vor der ersten Antwort steht die Uhr auf 1970.
return z.tm_year > 120;
}
int netz_feldstaerke(void)
{
// Wie gut Fred den Zugangspunkt hoert, in dBm. Ueber -60 ist gut,
// unter -75 wird es unzuverlaessig — und Unzuverlaessigkeit ist
// genau das, was eine stockende Tonuebertragung erklaeren wuerde.
wifi_ap_record_t ap;
if (esp_wifi_sta_get_ap_info(&ap) != ESP_OK) return 0;
return ap.rssi;
}
const char *netz_ip(void)
{
return s_ip;
}
int netz_trennungen(void)
{
return s_trennungen;
}
int netz_letzter_grund(void)
{
return s_letzter_grund;
}
bool netz_verbunden(void)
{
return s_verbunden;
}
+116
View File
@@ -0,0 +1,116 @@
// WLAN: Zugangsdaten im NVS, Verbindung als Station, Einrichtung als
// Zugangspunkt. Wer die Daten hat, verbindet sich; wer keine hat, macht
// sein eigenes Netz auf.
//
// Uebernommen aus Hey Fred (firmware/main/netz.c) am 23.09.2026. Dort ist
// es erprobt; hier geaendert wurden der NVS-Bereich und der Name des
// Einrichtungsnetzes.
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#define NETZ_SSID_MAX 32
#define NETZ_PASS_MAX 64
// Name des Zugangspunkts zur Einrichtung. ⚠ Anders als bei Fred NICHT
// fest: Die letzten beiden Bytes der MAC haengen hinten dran, also etwa
// "Vogelkamera-A4F2". Zwei Kameras im selben Garten -- Futterhaus und
// Traenke -- wuerden sich sonst denselben Namen teilen, und man wuesste
// beim Einrichten nicht, welche man gerade vor sich hat.
#define NETZ_SETUP_PRAEFIX "Vogelkamera"
// Der tatsaechliche Name, erst zur Laufzeit bekannt.
const char *netz_setup_ssid(void);
// Das Geheimnis fuer Updates ueber WLAN.
//
// ⚠ Bewusst NICHT einkompiliert (so macht es Hey Fred in secrets.h). Fuer
// ein Geraet, das als Bausatz weitergegeben wird, waere das derselbe
// Fehler wie eingebackene WLAN-Daten: Jeder muesste die Firmware selbst
// uebersetzen, und alle Geraete haetten dasselbe Geheimnis.
//
// Stattdessen: Beim ersten Start wuerfelt sich jedes Geraet sein eigenes
// und legt es im NVS ab. Zu sehen bekommt man es im Einrichtungsnetz --
// wer dort steht, steht ohnehin vor dem Geraet.
const char *netz_ota_token(void);
typedef struct {
char ssid[NETZ_SSID_MAX + 1];
int8_t rssi;
bool verschluesselt;
} netz_fund_t;
// Einmal beim Start aufrufen, vor allem anderen.
void netz_init(void);
// Liegen Zugangsdaten im NVS?
bool netz_zugang_vorhanden(void);
// Der gespeicherte Netzname, oder "" wenn keiner da ist. Fuer die Anzeige
// auf der Einrichtungsseite: Erst damit sieht man, ob das Speichern
// ueberhaupt ankommt -- ohne Kabel, ohne Raten.
const char *netz_gespeicherte_ssid(void);
// Gibt false zurueck, wenn das Schreiben scheitert -- der Grund steht dann
// in *fehler. ⚠ Frueher brach das Geraet bei einem Fehler einfach ab
// (ESP_ERROR_CHECK): Es startete mitten im Absenden neu, der Browser blieb
// haengen, nichts war gespeichert, und niemand erfuhr warum.
bool netz_zugang_speichern(const char *ssid, const char *passwort,
esp_err_t *fehler);
void netz_zugang_loeschen(void);
// Verbindet mit den gespeicherten Daten. Blockiert bis zum Erfolg oder bis
// die Versuche aufgebraucht sind. Gibt false zurück, wenn es nicht klappt —
// dann gehört Fred in den Einrichtungsmodus.
bool netz_verbinden(void);
// Öffnet den Zugangspunkt für die Einrichtung. Danach ist Fred unter
// 192.168.4.1 erreichbar.
void netz_setup_ap_start(void);
// Sucht Netze in der Umgebung. Schreibt bis `max` Funde nach `ziel` und gibt
// die Anzahl zurück. Doppelte SSIDs werden übersprungen, der stärkste
// Fund gewinnt.
int netz_suchen(netz_fund_t *ziel, int max);
// Sucht EINMAL und merkt sich das Ergebnis.
//
// ⚠ Der Grund ist unangenehm und kostet sonst Stunden: Eine WLAN-Suche
// wechselt den Funkkanal. Laeuft gleichzeitig der eigene Zugangspunkt,
// fliegen die verbundenen Geraete dabei raus. Sucht man also, waehrend
// jemand das Formular betrachtet, verliert genau dieser Jemand die
// Verbindung -- und sein Absenden kommt nie an. Am 23.09.2026 genau so
// passiert, an Handy wie an Mac.
//
// Deshalb: einmal suchen, bevor der erste Besucher da ist, und danach
// nur noch die Liste ausliefern.
int netz_funde_holen(netz_fund_t *ziel, int max);
void netz_funde_auffrischen(void);
// IP als Zeichenkette, leer wenn nicht verbunden.
const char *netz_ip(void);
// Wie oft die Verbindung seit dem Start abgerissen ist, und warum zuletzt.
// Der Grund ist der Code des WLAN-Treibers: 15 und 204 stehen für ein
// falsches Passwort, 201 für „Netz nicht gefunden", 2 bis 4 zeigen auf
// Verschlüsselung und Management-Frames.
int netz_trennungen(void);
int netz_letzter_grund(void);
// Läuft die Verbindung gerade?
bool netz_verbunden(void);
// Holt die Uhrzeit per SNTP und stellt sie auf deutsche Zeit. Blockiert
// nicht — das Ergebnis trudelt im Hintergrund ein.
void zeit_holen(void);
// Steht die Uhr schon auf einer plausiblen Zeit?
bool zeit_steht(void);
// Empfangsstaerke zum Zugangspunkt in dBm (negativ; naeher an null ist
// besser). 0 heisst: nicht ermittelbar.
int netz_feldstaerke(void);
File diff suppressed because it is too large. Load diff
+54
View File
@@ -0,0 +1,54 @@
#!/bin/sh
# Firmware über Funk aufspielen. Kein Kabel, kein Aufschrauben.
#
# ./ota.sh vogelkamera.local <schluessel>
#
# Den Schlüssel zeigt das Gerät beim Start auf der seriellen Leitung
# ("Schluessel fuer Updates: …") und die Einrichtungsseite. Er bleibt über
# Neustarts und Updates hinweg derselbe -- er liegt im NVS.
#
# ⚠ --limit-rate ist KEIN Schönheitsfehler, sondern Bedingung. Ohne
# Drosselung kommt der Chip mit dem Schreiben in den Flash nicht
# hinterher, hört auf vom Socket zu lesen, und beide Seiten warten
# aufeinander, bis jemand aufgibt. Am 24.09.2026 hat genau das zwei
# Anläufe gekostet, bevor klar war, dass es nicht am Endpunkt liegt.
#
# ⚠ Der Detector muss weg. Der Webserver auf der Kamera hat einen
# einzigen Arbeiter, und der Stream gibt ihn nie zurück:
#
# ssh root@erkennung.local systemctl stop vogelalbum-detector@traenke
# ./ota.sh …
# ssh root@erkennung.local systemctl start vogelalbum-detector@traenke
set -e
ZIEL="$1"
SCHLUESSEL="$2"
ABBILD="${3:-build/vogelkamera.bin}"
if [ -z "$ZIEL" ] || [ -z "$SCHLUESSEL" ]; then
echo "Aufruf: $0 <adresse> <schluessel> [abbild]" >&2
exit 2
fi
if [ ! -f "$ABBILD" ]; then
echo "Kein Abbild unter $ABBILD -- erst 'idf.py build'." >&2
exit 2
fi
echo "Vorher:"
curl -s -m 5 "http://$ZIEL:8081/status" || echo " (keine Antwort)"
echo
echo "Spiele $(wc -c < "$ABBILD") Bytes auf $ZIEL auf …"
curl --fail --max-time 300 --limit-rate 150k \
-w '\n HTTP %{http_code} %{size_upload} Bytes in %{time_total}s\n' \
-X POST \
-H "Expect:" \
-H "X-Vogelkamera-Token: $SCHLUESSEL" \
--data-binary "@$ABBILD" \
"http://$ZIEL:8080/update"
echo
echo "Warte auf den Neustart …"
sleep 12
echo "Nachher:"
curl -s -m 8 "http://$ZIEL:8081/status" || echo " (noch nicht zurück -- gleich nochmal schauen)"
+16
View File
@@ -0,0 +1,16 @@
# Zwei App-Bereiche, damit sich das Geraet ueber WLAN aktualisieren kann.
#
# 🔑 Grundsatz in diesem Projekt: JEDES Geraet muss OTA koennen. Diese Tabelle ist die
# Voraussetzung dafuer, und sie gehoert von Anfang an so angelegt -- sie
# nachtraeglich zu aendern kostet genau das, was OTA vermeiden soll: einen
# Gang zum Geraet mit dem Kabel. (Am 23.09.2026 genau deshalb nachgeholt.)
#
# Bei 16 MB Flash ist das muehelos: zwei mal 4 MB App, der Rest bleibt frei.
# Das Abbild liegt bei knapp 1 MB, also ist reichlich Luft.
#
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x6000
otadata, data, ota, 0xf000, 0x2000
phy_init, data, phy, 0x11000, 0x1000
ota_0, app, ota_0, 0x20000, 4M
ota_1, app, ota_1, , 4M
1 # Zwei App-Bereiche, damit sich das Geraet ueber WLAN aktualisieren kann.
2 #
3 # 🔑 Grundsatz in diesem Projekt: JEDES Geraet muss OTA koennen. Diese Tabelle ist die
4 # Voraussetzung dafuer, und sie gehoert von Anfang an so angelegt -- sie
5 # nachtraeglich zu aendern kostet genau das, was OTA vermeiden soll: einen
6 # Gang zum Geraet mit dem Kabel. (Am 23.09.2026 genau deshalb nachgeholt.)
7 #
8 # Bei 16 MB Flash ist das muehelos: zwei mal 4 MB App, der Rest bleibt frei.
9 # Das Abbild liegt bei knapp 1 MB, also ist reichlich Luft.
10 #
11 # Name, Type, SubType, Offset, Size
12 nvs, data, nvs, 0x9000, 0x6000
13 otadata, data, ota, 0xf000, 0x2000
14 phy_init, data, phy, 0x11000, 0x1000
15 ota_0, app, ota_0, 0x20000, 4M
16 ota_1, app, ota_1, , 4M
+60
View File
@@ -0,0 +1,60 @@
# Ziel ist der ESP32-S3 mit 8 MB PSRAM (N16R8-Module).
CONFIG_IDF_TARGET="esp32s3"
# Ohne PSRAM passt kein JPEG-Framebuffer in den Speicher. Octal ist bei den
# R8-Modulen richtig; bei einem R2/R8-Quad-Modul stattdessen QUAD waehlen,
# sonst findet die Kamera keinen Puffer.
CONFIG_SPIRAM=y
CONFIG_SPIRAM_MODE_OCT=y
CONFIG_SPIRAM_SPEED_80M=y
CONFIG_SPIRAM_USE_MALLOC=y
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
# 16 MB Flash (N16R8).
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_ESPTOOLPY_FLASHMODE_QIO=y
CONFIG_ESPTOOLPY_FLASHFREQ_80M=y
# Die Funkstrecke ist der Engpass, nicht die CPU: mehr und groessere
# Sendepuffer heben die Bildrate deutlich mehr als ein schnellerer Takt.
CONFIG_ESP_WIFI_STATIC_TX_BUFFER_NUM=16
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
CONFIG_ESP_WIFI_TX_BA_WIN=16
CONFIG_ESP_WIFI_RX_BA_WIN=16
CONFIG_LWIP_TCP_SND_BUF_DEFAULT=65534
CONFIG_LWIP_TCP_WND_DEFAULT=65534
# Volle Taktrate — JPEG kommt fertig aus dem Sensor, aber das Umkopieren in
# die Sendepuffer laeuft auf der CPU.
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
# Der Stream haelt einen Socket dauerhaft offen; /status braucht daneben Platz.
CONFIG_LWIP_MAX_SOCKETS=10
# Ein Geraet im Garten soll sich selbst erholen statt stumm zu bleiben.
CONFIG_ESP_TASK_WDT_EN=y
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
CONFIG_ESP_INT_WDT=y
# Kameraframes gehoeren ins PSRAM, nicht ins knappe interne RAM.
CONFIG_CAMERA_CORE0=y
# Kamera haengt ueber Kopf: beide Spiegelungen zusammen ergeben 180 Grad.
# ⚠ Gilt fuer JEDES Geraet aus diesem Baum -- wer eine Kamera anders
# herum montiert, braucht hier einen eigenen Stand. Gemeldet am
# 24.09.2026, nachdem die Traenke haengt: "Bild muss um 180 grad gedreht
# werden."
# CONFIG_VK_HFLIP is not set
CONFIG_VK_VFLIP=y
# Farbe: der Sensor liefert von Haus aus ein flaues Bild.
CONFIG_VK_SAETTIGUNG=2
CONFIG_VK_KONTRAST=1
CONFIG_VK_HELLIGKEIT=0
# Bremse fuer den Bildstrom. Ohne sie sendet die Kamera mit Anschlag --
# das heizt und saettigt die Funkstrecke, ohne der Erkennung zu nuetzen.
CONFIG_VK_MAX_FPS=5