BirdNET-Go ohne HomeAssistant mit ESP32 und Raspberry Pi

Als Informatiklehrer im Ruhestand bastle ich an unterschiedlichen Sensor-Projekten auf der Basis von unterschiedlichen ESP32, die Daten dann auf Raspberrys Pis verarbeiten und visualisieren. Dazu nutze ich InfluxDB und Grafana.

Neu habe ich BirdNET-Go auf einem weiteren Raspberry Pi (Docker compose) eingerichtet, der die Daten aus dem Garten von einem Adafruit ESP32 Feather v2 mit INMP441-Mikrofon bekommt. Da ich dort keinen Stromanschluss habe, läuft alles am Akku. Weil Streaming beim Akkubetrieb keine gute Idee ist, sendet der ESP32 je nach Tageszeit alle 5 bzw. 10 Minuten eine WAV-Datei von 15 Sekunden (ca. 1,5 MB) an den Raspberry Pi, sonst ist er im “Deep Sleep”. Auf dem Raspberry Pi ist alles so eingerichtet, dass die Dateien von BirdNET-Go als Stream gedeutet werden.

Zusätzlich zu der eingebauten Visualisierung von BirdNET-Go übertrage ich die Daten auch zum anderen Raspberry Pi mit InfluxDB und Grafana.

Die Schnittstellen (ESP32 zu BirdNET-Go sowie BirdNET-Go zur InfluxdB) übernehmen jeweils Python-Skripte. (“Claude” war bei den Skripten genial.)

Der Stack läuft erst seit gut einer Woche und ich bin noch beim Feintuning, was wohl auch noch einige Zeit dauern wird.

Bei Interesse an weiteren Einzelheiten einfach nachfragen.

Sigurd

Das sieht sehr spannend aus. Ich bin durch Deinen Beitrag erst darauf gestoßen, dass es BirdNET-Go gibt. Habe mir das gleich mal auf einem Mini-PC installliert - aber erst mal unabhängig von HA.

Ich nutze da derzeit einen Kamera-Stream, der kontinuierlich läuft. Darüber habe ich die Vögel hier am Haus und das ist schon ziemlich klasse.

Allerdings würde ich auch gern Vögel von anderen Positionen haben, bei denen ich keine netzgebundene Kamera habe. Also ähnlich wie bei Dir.

Allerdings stellt sich mir die Frage: Du jubelst dann ja eine WAV-Datei oder so als Stream unter, oder? Wie machst du das? Ich habe da keine Möglichkeit gefunden.

Es laufen bei mir drei Komponenten zusammen. Zunächst ein Ardunio-Sketch auf einem Adafruit ESP32 Feather v2:

#define CONFIG_I2S_SUPPRESS_DEPRECATE_WARN 1
#include <Arduino.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <driver/i2s.h>
#include <driver/adc.h>
#include <time.h>

const char* WIFI_SSID     = “…”;
const char* WIFI_PASSWORD = “…”;
const char* SERVER_URL    = “http://192.168.0.114:8889/upload”;
const char* NTP_SERVER    = “pool.ntp.org”;

// InfluxDB
#define INFLUXDB_URL    “http://192.168.0.126:8086/api/v2/write?org=privat&bucket=esp32-birdnet&precision=s”
#define INFLUXDB_TOKEN  “…”

// Alle wieviel Boots die Akkuspannung senden (ca. 1h im Tagestakt)
#define BATT_SEND_EVERY_N_BOOTS  6

// Aufnahme-Parameter
#define SAMPLE_RATE      48000
#define RECORD_SECONDS   15
#define SHIFT            13
#define TOTAL_SAMPLES    (SAMPLE_RATE * RECORD_SECONDS)
#define TOTAL_BYTES      (TOTAL_SAMPLES * 2)
#define DMA_BUF_LEN      64

// I2S Pins
#define I2S_WS   4
#define I2S_SCK  5
#define I2S_SD   19

// Intervalle in Sekunden
#define INTERVALL_MORGEN  (5  * 60)   // 04:00–09:00 Uhr
#define INTERVALL_TAG     (10 * 60)   // Rest

// RTC-Speicher — überlebt Deep Sleep
RTC_DATA_ATTR bool ntpSynced = false;
RTC_DATA_ATTR int  bootCount = 0;

void writeWavHeader(uint8_t* buf, uint32_t dataBytes) {
uint32_t fileSize   = dataBytes + 36;
uint32_t byteRate   = SAMPLE_RATE * 2;
uint16_t blockAlign = 2;
uint32_t sr         = SAMPLE_RATE;
uint32_t fmtSize    = 16;
uint16_t audioFmt   = 1;
uint16_t ch         = 1;
uint16_t bits       = 16;
memcpy(buf,      “RIFF”,      4);
memcpy(buf +  4, &fileSize,   4);
memcpy(buf +  8, “WAVE”,      4);
memcpy(buf + 12, "fmt ",      4);
memcpy(buf + 16, &fmtSize,    4);
memcpy(buf + 20, &audioFmt,   2);
memcpy(buf + 22, &ch,         2);
memcpy(buf + 24, &sr,         4);
memcpy(buf + 28, &byteRate,   4);
memcpy(buf + 32, &blockAlign, 2);
memcpy(buf + 34, &bits,       2);
memcpy(buf + 36, “data”,      4);
memcpy(buf + 40, &dataBytes,  4);
}

void i2sInit() {
i2s_config_t cfg = {
.mode                 = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate          = SAMPLE_RATE,
.bits_per_sample      = I2S_BITS_PER_SAMPLE_32BIT,
.channel_format       = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags     = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count        = 8,
.dma_buf_len          = DMA_BUF_LEN,
.use_apll             = false,
.tx_desc_auto_clear   = false,
.fixed_mclk           = 0
};
i2s_pin_config_t pins = {
.bck_io_num   = I2S_SCK,
.ws_io_num    = I2S_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num  = I2S_SD
};
i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL);
i2s_set_pin(I2S_NUM_0, &pins);
i2s_zero_dma_buffer(I2S_NUM_0);
}

uint32_t schlafSekundenBerechnen(struct tm* jetzt) {
int stunde = jetzt->tm_hour;
if (stunde >= 4 && stunde < 9) {
return INTERVALL_MORGEN - RECORD_SECONDS;
} else {
return INTERVALL_TAG - RECORD_SECONDS;
}
}

void sendeAkkuSpannungAnInflux(float battVoltage, float rssi) {
String body = “birdnet_status,device=esp32-birdnet battery_voltage=”;
body += String(battVoltage, 3);
body += “,wifi_rssi=”;
body += String(rssi, 1);

HTTPClient http;
http.begin(INFLUXDB_URL);
http.addHeader("Authorization", "Token " + String(INFLUXDB_TOKEN));
http.addHeader("Content-Type", "text/plain; charset=utf-8");
int code = http.POST(body);
Serial.printf("InfluxDB HTTP: %d\n", code);
http.end();

}

void setup() {
Serial.begin(115200);
bootCount++;
Serial.printf(“Boot #%d\n”, bootCount);

// Akkuspannung VOR WiFi.begin() lesen — Legacy ADC1 API (kompatibel mit Legacy I2S)
adc1_config_width(ADC_WIDTH_BIT_12);
adc1_config_channel_atten(ADC1_CHANNEL_7, ADC_ATTEN_DB_11);  // GPIO35 = ADC1_CHANNEL_7
int raw = adc1_get_raw(ADC1_CHANNEL_7);
float battVoltage = (raw / 4095.0f) * 3.3f * 2.0f;
Serial.printf("Akkuspannung: %.2f V (raw: %d)\n", battVoltage, raw);

// WiFi verbinden
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("WiFi verbinden");
int versuche = 0;
while (WiFi.status() != WL_CONNECTED && versuche < 20) {
    delay(500); Serial.print(".");
    versuche++;
}
if (WiFi.status() != WL_CONNECTED) {
    Serial.println("\nWiFi fehlgeschlagen — schlafe 60s");
    esp_sleep_enable_timer_wakeup(60ULL * 1000000ULL);
    esp_deep_sleep_start();
}
Serial.println("\nVerbunden: " + WiFi.localIP().toString());

// NTP beim ersten Boot oder alle 10 Boots neu synchronisieren
if (!ntpSynced || bootCount % 10 == 0) {
    configTime(0, 0, NTP_SERVER);
    setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
    tzset();
    Serial.print("NTP sync");
    struct tm t;
    int i = 0;
    while (!getLocalTime(&t) && i < 10) {
        delay(500); Serial.print("."); i++;
    }
    if (getLocalTime(&t)) {
        ntpSynced = true;
        Serial.printf("\nZeit: %02d:%02d:%02d\n", t.tm_hour, t.tm_min, t.tm_sec);
    } else {
        Serial.println("\nNTP fehlgeschlagen");
    }
}

// Akkuspannung alle N Boots an InfluxDB senden
if (bootCount % BATT_SEND_EVERY_N_BOOTS == 0) {
    Serial.println("Sende Akkuspannung an InfluxDB...");
    sendeAkkuSpannungAnInflux(battVoltage, (float)WiFi.RSSI());
}

// Aktuelle Zeit holen
struct tm jetzt;
getLocalTime(&jetzt);
Serial.printf("Uhrzeit: %02d:%02d — ", jetzt.tm_hour, jetzt.tm_min);

// I2S starten
i2sInit();
delay(300);

// Audio-Buffer
uint32_t totalBytes = TOTAL_BYTES + 44;
uint8_t* audioBuf = (uint8_t*)malloc(totalBytes);
if (!audioBuf) {
    Serial.println("FEHLER: Kein RAM!");
    goto sleep;
}
writeWavHeader(audioBuf, TOTAL_BYTES);

{
    // DC-Offset messen
    int64_t dcSum = 0;
    for (int i = 0; i < 500; i++) {
        int32_t stereo[2] = {0, 0};
        size_t b = 0;
        i2s_read(I2S_NUM_0, stereo, 8, &b, portMAX_DELAY);
        dcSum += (stereo[1] >> SHIFT);
    }
    int16_t dcOffset = (int16_t)(dcSum / 500);
    Serial.printf("DC-Offset: %d\n", dcOffset);

    // Aufnahme
    Serial.printf("Aufnahme %d Sekunden...\n", RECORD_SECONDS);
    int16_t* dst = (int16_t*)(audioBuf + 44);
    uint32_t samplesRecorded = 0;
    while (samplesRecorded < TOTAL_SAMPLES) {
        int32_t stereo[2] = {0, 0};
        size_t bytesRead = 0;
        i2s_read(I2S_NUM_0, stereo, 8, &bytesRead, portMAX_DELAY);
        if (bytesRead == 8) {
            int16_t s = (int16_t)(stereo[1] >> SHIFT) - dcOffset;
            dst[samplesRecorded++] = s;
        }
    }
    Serial.println("Aufnahme fertig.");

    // Audio-Upload
    Serial.println("Sende Audio...");
    HTTPClient http;
    http.begin(SERVER_URL);
    http.addHeader("Content-Type", "audio/wav");
    int code = http.POST(audioBuf, totalBytes);
    Serial.printf("HTTP Audio: %d\n", code);
    http.end();
}

free(audioBuf);

sleep:
i2s_driver_uninstall(I2S_NUM_0);
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);

// Schlafzeit berechnen
getLocalTime(&jetzt);
uint32_t schlafSek = schlafSekundenBerechnen(&jetzt);
Serial.printf("Schlafe %d Sekunden (Intervall %s)\n",
    schlafSek,
    (jetzt.tm_hour >= 4 && jetzt.tm_hour < 9) ? "5 min" : "10 min");

esp_sleep_enable_timer_wakeup((uint64_t)schlafSek * 1000000ULL);
esp_deep_sleep_start();

}

void loop() {}

Dabei ist die IP 192.168.0.114 der Raspi, auf dem Birdnet-Go läuft. Auf dem 192.168.0.126 laufen InfluxDB und Grafana. Es sind beides allerdings Raspi 5 mit NVMe-SSD und ohne SD-Karte.
Zum Verständnis: Das Bucket bei InfluxDB heißt bei mir “esp32-birdnet” und die Organisation “privat”. Ich greife hier auch noch alle 6 Übertragungen einmal die Akkuspannung und die WiFi-Stärke ab un sende sie zu InfluxDB/Grafana.

Auf dem 192.168.0.114 laufen zwei Python-Skripte:

“wav_stream.py” lauscht auf neue Dateien i den vordefinierten Verzeichnis “/mnt/daten/birdnet/input":

#!/usr/bin/env python3
import os, time, glob, subprocess, threading, queue, datetime
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
HTTPServer = ThreadingHTTPServer
HTTPServer.allow_reuse_address = True

INPUT_DIR      = “/mnt/daten/birdnet/input”
PORT           = 8889
MAX_ALTER_TAGE = 2
SAMPLE_RATE    = 48000

wav_queue = queue.Queue()

class StreamHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != “/stream”:
self.send_response(404)
self.end_headers()
return

    self.send_response(200)
    self.send_header("Content-Type", "audio/wav")
    self.send_header("Transfer-Encoding", "identity")
    self.end_headers()

    print("BirdNET-Go verbunden.")

    while True:
        try:
            wav = wav_queue.get(timeout=300)
            print(f"Streame: {os.path.basename(wav)}")
            proc = subprocess.Popen(
                ["ffmpeg", "-i", wav,
                 "-ar", str(SAMPLE_RATE),
                 "-ac", "1",
                 "-f", "wav",
                 "pipe:1"],
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL
            )
            while True:
                chunk = proc.stdout.read(4096)
                if not chunk:
                    break
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    print("BirdNET-Go getrennt.")
                    proc.kill()
                    return
            proc.wait()
            print(f"Fertig: {os.path.basename(wav)}")

        except queue.Empty:
            # Stille senden damit Verbindung offen bleibt
            try:
                self.wfile.write(b"\x00" * (SAMPLE_RATE * 2 * 60))
                self.wfile.flush()
            except:
                return

def do_POST(self):
    length = int(self.headers.get("Content-Length", 0))
    data = self.rfile.read(length)
    if len(data) > 100:
        ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
        filename = os.path.join(INPUT_DIR, f"audio_{ts}.wav")
        with open(filename, "wb") as f:
            f.write(data)
        print(f"Empfangen: audio_{ts}.wav ({len(data)} Bytes)")
    self.send_response(200)
    self.send_header("Content-Length", "2")
    self.end_headers()
    self.wfile.write(b"OK")
    self.wfile.flush()

def log_message(self, format, *args):
    pass

def ordner_beobachten():
bekannt = set()
while True:
dateien = sorted(glob.glob(f"{INPUT_DIR}/*.wav"))
for d in dateien:
if d not in bekannt:
bekannt.add(d)
wav_queue.put(d)
print(f"Neue Datei: {os.path.basename(d)}")

    # Alte Dateien löschen
    jetzt = time.time()
    for d in list(bekannt):
        if os.path.exists(d):
            alter = jetzt - os.path.getmtime(d)
            if alter > MAX_ALTER_TAGE * 86400:
                os.remove(d)
                bekannt.discard(d)
                print(f"Gelöscht: {os.path.basename(d)}")

    time.sleep(5)

if name == “main”:
t = threading.Thread(target=ordner_beobachten, daemon=True)
t.start()
print(f"Stream läuft: http://192.168.0.126:{PORT}/stream")
server = HTTPServer((“0.0.0.0”, PORT), StreamHandler)
server.serve_forever()

Das gaukelt Birdnet-Go einen kontinuierlichen HTTP-Stream vor. Für Birdnet-Go reicht das schon.

Da ich noch mit weitergehenden Visualisierungen spiele, läuft noch alle 10 Minuten ein weiteres Python-Skript (eingetragen in der crontab). Hierfür habe ich in InfluxDB ein weiteres Bucket “birdnet” eingerichtet.

Wenn daran noch Interesse besteht, einfach nachfragen.

Ich will mich aber nicht mit fremden Federn schmücken, die Codes stammen in weiten Teilen von “Claude”.:sweat_smile: