#include #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 #include #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; }