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

+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;
}