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:
commit
cc9e0786c2
189 files changed
+29792
No files matched your search
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
dependencies:
|
||||
espressif/esp32-camera: "^2.0.11"
|
||||
idf:
|
||||
version: ">=5.1"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user