Die DIY-WoMo-Alarmanlage: Part 2
Eine smarte, autarke Wohnmobil-Alarmanlage mit ESP32, GrapheneOS und ioBroker bauen.
Das Wohnmobil steht wochenlang unbeaufsichtigt auf Campingplätzen, in Wäldern oder auf Wanderparkplätzen - teures Hab und Gut, zugänglich für jeden, der einen Schraubenzieher dabei hat. Im Vorgänger-Artikel wurden die Grundkonzepte einer DIY-Alarmanlage mit ESP32 und ioBroker vorgestellt; dieses Update baut die Lösung zu einem vollständigen System aus - mit Radar-Präsenzsensor, ESP32-Kamera für die Beweissicherung, WiFi-Probe-Sniffer zur forensischen Tätereingrenzung und ntfy als selbst gehostetem Push-Dienst statt Telegram. Im Mittelpunkt stehen ein Pixel 7 mit GrapheneOS als Kamera- und Internetknoten, ein ESP32 als Sensor-Hub und OPNsense als stabiler Heimat-Anker im Tunnel.
Ja. Ich bin wieder mit dem Wohnmobil unterwegs und habe ein paar preiswerte Bastelsachen und Elektronik-Werkzeug dabei.
Übersicht
Das Szenario, das die meisten ignorieren
Einbrüche sind das eine. Was aber durchaus häufig passiert - und im schlimmsten Fall tödlich endet - ist ein Gasleck. Ein Wohnmobil läuft im Winter mit einer Flüssiggasheizung. Ein loser Schlauch am Gasherd, ein gerissenes O-Ring am Druckminderer, und schon steigt in der Nacht unbemerkt die LPG-Konzentration. Propan und Butan sind schwerer als Luft - sie sammeln sich am Boden, wo schlafende Menschen liegen. Die untere Explosionsgrenze von Propan liegt bei 2,1%, unterhalb dieser Schwelle ist die Konzentration für Schlafende bereits lebensgefährlich.
Gleichzeitig ist Kohlenmonoxid das stille Risiko: Eine schlecht eingestellte Heizung, ein Dieselheizgerät mit unzureichender Abgasführung, und CO sammelt sich unbemerkt an - geruchlos, farblos, tödlich. Der ESP32 misst permanent LPG-Konzentration über einen MQ-2-Sensor und CO-Konzentration über einen MQ-7-Sensor. Überschreitet einer der Werte den konfigurierten Schwellenwert, landet sofort eine Push-Benachrichtigung mit Kamera-Snapshot via ntfy auf dem Smartphone und dem lokalen Piezo-Summer - weit bevor ein klassischer Rauchmelder anspringen würde.
Systemarchitektur im Überblick
Alle Komponenten bilden ein in sich geschlossenes System ohne Cloud-Abhängigkeit. Da ich nur einen ESP32 mit auf Reisen genommen habe, sieht mein provisorischer Aufbau zunächst so aus:

Das Pixel 7 dient als Internet-Gateway: Der ESP32 und die ESP32-CAM spannen eigene WireGuard-Tunnel durch den Hotspot des Telefons direkt zur Heim-OPNsense auf. Alle drei landen im Tunnel-Subnetz 10.6.0.0/24 und sind von ioBroker aus adressierbar. Die Simbase IoT eSIM liefert die mobile Datenverbindung nach dem Pay-as-you-go-Prinzip - im Idle-Betrieb kaum mehr als ein paar Kilobyte pro Stunde.
WireGuard-Server auf OPNsense einrichten
Ab OPNsense 24.1 ist WireGuard Bestandteil des Kernsystems - kein Plugin mehr nötig. Die Einrichtung beginnt unter VPN → WireGuard → General: Haken bei Enable WireGuard setzen, dann Apply. Wer lieber ein Shell-Script zur Peer-Generierung nutzt, findet dafür auf meinem Blog den passenden Script-Time-Artikel.
Schritt 1 – Server-Instanz anlegen
Unter VPN → WireGuard → Instances mit + eine neue Instanz anlegen:
| Feld | Wert |
|---|---|
| Name | WoMo-VPN |
| Listen Port | 51820 |
| Tunnel Address | 10.6.0.1/24 |
| Private Key | ⚙ Auto-generieren |
Den angezeigten Public Key notieren - er wird in alle drei Client-Konfigurationen eingetragen.
Schritt 2 – Drei Peers anlegen
Unter VPN → WireGuard → Peers je einen Eintrag für Pixel 7, ESP32-Sensor-Hub und ESP32-CAM anlegen:
| Name | Allowed IPs | Keepalive |
|---|---|---|
Pixel7-Kamera |
10.6.0.2/32 |
25 |
ESP32-SensorHub |
10.6.0.3/32 |
25 |
ESP32-CAM |
10.6.0.4/32 |
25 |
Die Public Keys der jeweiligen Clients entstehen in den folgenden Abschnitten; die Felder zunächst leer lassen und nach der Schlüsselgenerierung nachtragen.
Schritt 3 – Interface zuweisen, Firewall und MSS-Normalisierung
Unter Interfaces → Assignments das Interface wg0 hinzufügen und als WG_WoMo beschriften, aktivieren. Unter Firewall → Rules → WAN eine Regel für UDP Port 51820 anlegen. Unter Firewall → Rules → WG_WoMo eine Regel Source: WireGuard net, Destination: LAN net, Protocol: any hinzufügen. Abschließend unter Firewall → Settings → Normalization eine MSS-Regel anlegen (Interface WG_WoMo, Protocol TCP, Max MSS 1380) - ohne diese fragmentiert TCP über den Tunnel.
Das Pixel 7 als privater Kamera-Knoten
Das Pixel 7 mit GrapheneOS erfüllt zwei Rollen gleichzeitig: mobiles Internet via Simbase eSIM und MJPEG-Kameraserver. Die Trennung zwischen privatem und Alarm-Betrieb erfolgt über ein separates Benutzerprofil - unter Einstellungen → System → Mehrere Nutzer lässt sich ein zweites Profil anlegen, das ausschließlich WireGuard-App und Kamera-App enthält.
eSIM und Hotspot
Die Simbase IoT eSIM lässt sich per QR-Code in GrapheneOS einbinden: Einstellungen → Netzwerk → SIM-Karten → eSIM hinzufügen. Den Hotspot im Alarm-Profil aktivieren (SSID WoMo-Hotspot) - er ist auch für ESP32 und ESP32-CAM erreichbar, da der Hotspot unabhängig vom Benutzerprofil läuft.
WireGuard-Client
Die WireGuard-App für Android generiert das Schlüsselpaar automatisch (als GrapheneOS-Nutzer will man die APP natürlich nicht von Google). Ich habe das also mit meinem eigenen Tool generiert. Im Alarm-Profil einen neuen Tunnel anlegen:
[Interface]
PrivateKey = <PIXEL7_PRIVATE_KEY>
Address = 10.6.0.2/32
DNS = 10.6.0.1
[Peer]
PublicKey = <OPNSENSE_PUBLIC_KEY>
AllowedIPs = 192.168.1.0/24, 10.6.0.0/24
Endpoint = home.example.de:51820
PersistentKeepalive = 25
Den angezeigten Public Key in den OPNsense-Peer Pixel7-Kamera eintragen und WireGuard neu starten. AllowedIPs ist als Split-Tunnel konfiguriert - nur Traffic Richtung Heimnetz und VPN-Subnetz läuft durch den Tunnel, der restliche Datenverkehr geht direkt über die Simbase-Verbindung.
Ich habe bei ersten Tests festgestellt, dass der Tunnel stirbt, wenn das Smartphone eine neue IP bekommt. Deshalb behelfe ich mir damit, die IP des iobroker auf Erreichbarkeit zu prüfen. Falls das dreimal schief geht, wird der Tunnel ab- und wieder aufgebaut.
android-ip-camera
Die quelloffene App android-ip-camera von DigitallyRefined ist auf F-Droid verfügbar (kein Play Store, keine Analytics, keine Hintergrundprozesse). Nach dem Start startet die App einen MJPEG-Server, der den Stream unter https://[ip_address]:4444/stream bereitstellt - absicherbar per Basic Auth und TLS-Zertifikat. Auflösung auf 640×480 und Frame Rate auf 5 fps setzen.
ESP32: Sensoren, Präsenz, WireGuard, MQTT und Probe-Sniffer
Sensorbestückung
| Sensor | Typ | GPIO | Funktion |
|---|---|---|---|
| PIR HC-SR501 | Digital IN | GPIO 13 | Bewegungserkennung |
| LD2410B (OUT-Pin) | Digital IN | GPIO 25 | Radar-Präsenzerkennung (statisch) |
| Reed-Kontakt Tür | Digital IN | GPIO 12 | Türalarm (Pull-up) |
| Reed-Kontakt Fenster | Digital IN | GPIO 14 | Fensteralarm (Pull-up) |
| Vibrationssensor SW-420 | Digital IN | GPIO 15 | Erschütterung / Abschleppen |
| MQ-2 Gassensor | Analog ADC | GPIO 34 | LPG / Propan / Rauch |
| MQ-7 CO-Sensor | Analog ADC | GPIO 35 | Kohlenmonoxid |
| DS18B20 Temperatur | 1-Wire | GPIO 4 | Innentemperatur |
Der HLK-LD2410B von HiLink ist ein 24-GHz-Radar-Präsenzsensor, der im Gegensatz zu einem PIR-Sensor auch statisch anwesende Personen erkennt - über die Atemdetektion, die minimale Brustkorbbewegung auch im Stillstand sichtbar macht. Beim PIR-Sensor reicht es, kurz innezuhalten; beim LD2410B nicht. Der Sensor wird mit 5 V versorgt und gibt am OUT-Pin schlicht HIGH aus, solange Präsenz erkannt wird. Wer tiefer einsteigen möchte, kann über UART (TX/RX an GPIO 16/17) mit der Bibliothek ncmreynolds/ld2410 Bewegungs- und Standzonen feingranular konfigurieren.
WireGuard-Schlüssel für ESP32 generieren
Da die ciniml/WireGuard-ESP32-Arduino-Bibliothek kein Key-Management mitbringt, wird das Schlüsselpaar extern erzeugt, was ich natürlich wieder mit meinem Script von oben erstellt habe:
wg genkey | tee esp32_private.key | wg pubkey > esp32_public.key
cat esp32_private.key # → WG_PRIVATE_KEY im Sketch
cat esp32_public.key # → Public Key in OPNsense-Peer "ESP32-SensorHub"
Bibliotheken (PlatformIO platformio.ini)
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
lib_deps =
ciniml/WireGuard-ESP32@^0.1.5
knolleary/PubSubClient@^2.8
milesburton/DallasTemperature@^3.11
paulstoffregen/OneWire@^2.3
Vollständiger Sketch
// womo-alarm-esp32.ino – Sensor-Hub mit Probe-Sniffer
// M. Meister – Version 0.7
#include <WiFi.h>
#include <WireGuard-ESP32.h>
#include <PubSubClient.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include "esp_wifi.h"
// =============================================================
// KONFIGURATION
// =============================================================
const char WIFI_SSID[] = "WoMo-Hotspot";
const char WIFI_PASSWORD[] = "sicheres_passwort";
const char WG_PRIVATE_KEY[] = "DEIN_ESP32_PRIVATE_KEY_BASE64=";
const IPAddress WG_LOCAL_IP(10, 6, 0, 3);
const char WG_ENDPOINT[] = "home.example.de";
const char WG_PEER_PUBLIC_KEY[] = "OPNSENSE_PUBLIC_KEY_BASE64=";
const int WG_ENDPOINT_PORT = 51820;
const char MQTT_SERVER[] = "192.168.1.50";
const int MQTT_PORT = 1883;
const char MQTT_USER[] = "iobroker";
const char MQTT_PASS[] = "mqtt_passwort";
const char MQTT_BASE[] = "wohnmobil/sensor/";
const int PIN_PIR = 13;
const int PIN_LD2410 = 25; // LD2410B OUT-Pin
const int PIN_REED_TUER = 12;
const int PIN_REED_FEN = 14;
const int PIN_VIBRATION = 15;
const int PIN_MQ2 = 34;
const int PIN_MQ7 = 35;
const int PIN_DS18B20 = 4;
const unsigned long PUBLISH_INTERVAL = 30000;
// =============================================================
static WireGuard wg;
WiFiClient wifiClient;
PubSubClient mqtt(wifiClient);
OneWire oneWire(PIN_DS18B20);
DallasTemperature tempSensor(&oneWire);
unsigned long lastPublish = 0;
bool probeCapturePending = false;
// ---- Probe-Sniffer ------------------------------------------
struct ProbeEntry { char mac[18]; char ssid[33]; int8_t rssi; };
const int MAX_PROBES = 50;
ProbeEntry probeLog[MAX_PROBES];
int probeCount = 0;
volatile bool scanActive = false;
void IRAM_ATTR snifferCb(void* buf, wifi_promiscuous_pkt_type_t type) {
if (!scanActive || type != WIFI_PKT_MGMT) return;
wifi_promiscuous_pkt_t* pkt = (wifi_promiscuous_pkt_t*)buf;
uint8_t* d = pkt->payload;
int len = pkt->rx_ctrl.sig_len;
// Probe Request: Frame-Control Byte 0 = 0x40
if (len < 28 || (d[0] & 0xFC) != 0x40) return;
// SSID IE: ID=0, dann Länge, dann SSID
if (d[24] != 0x00) return;
uint8_t slen = d[25];
if (slen == 0 || slen > 32 || (26 + slen) > len) return;
char ssid[33] = {0};
memcpy(ssid, &d[26], slen);
// De-Duplikation
for (int i = 0; i < probeCount; i++)
if (strcmp(probeLog[i].ssid, ssid) == 0) return;
if (probeCount >= MAX_PROBES) return;
if (pkt->rx_ctrl.rssi < -85) return; // nur nahe Geräte
ProbeEntry& e = probeLog[probeCount++];
snprintf(e.mac, sizeof(e.mac), "%02X:%02X:%02X:%02X:%02X:%02X",
d[10],d[11],d[12],d[13],d[14],d[15]);
strncpy(e.ssid, ssid, 32);
e.rssi = pkt->rx_ctrl.rssi;
}
void runProbeScan(int durationSec) {
probeCount = 0;
WiFi.disconnect(true);
delay(300);
esp_wifi_set_promiscuous(true);
esp_wifi_set_promiscuous_rx_cb(snifferCb);
scanActive = true;
unsigned long end = millis() + durationSec * 1000UL;
uint8_t ch = 1;
while (millis() < end) {
esp_wifi_set_channel(ch, WIFI_SECOND_CHAN_NONE);
ch = (ch % 13) + 1;
delay(100);
}
scanActive = false;
esp_wifi_set_promiscuous(false);
Serial.printf("Probe-Scan abgeschlossen: %d SSIDs\n", probeCount);
}
void sendProbeResults() {
if (probeCount == 0) return;
String json = "[";
for (int i = 0; i < probeCount; i++) {
if (i > 0) json += ",";
json += "{\"mac\":\"" + String(probeLog[i].mac) + "\"";
json += ",\"ssid\":\"" + String(probeLog[i].ssid) + "\"";
json += ",\"rssi\":" + String(probeLog[i].rssi) + "}";
}
json += "]";
publish("probe_requests", json);
}
// -------------------------------------------------------------
void mqttReconnect() {
int retries = 0;
while (!mqtt.connected() && retries++ < 5) {
Serial.print("MQTT...");
if (mqtt.connect("ESP32-WoMo", MQTT_USER, MQTT_PASS))
Serial.println("OK");
else { Serial.printf("Fehler %d\n", mqtt.state()); delay(3000); }
}
}
void connectWiFiAndWG() {
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (!WiFi.isConnected()) { delay(500); Serial.print("."); }
Serial.printf("\nIP: %s\n", WiFi.localIP().toString().c_str());
configTime(3600, 3600, "pool.ntp.org", "time.cloudflare.com");
struct tm ti;
while (!getLocalTime(&ti)) delay(500);
wg.begin(WG_LOCAL_IP, WG_PRIVATE_KEY,
WG_ENDPOINT, WG_PEER_PUBLIC_KEY, WG_ENDPOINT_PORT);
delay(3000);
}
void publish(const char* sub, const String& val) {
mqtt.publish((String(MQTT_BASE) + sub).c_str(), val.c_str(), true);
}
void setup() {
Serial.begin(115200);
pinMode(PIN_PIR, INPUT);
pinMode(PIN_LD2410, INPUT);
pinMode(PIN_REED_TUER, INPUT_PULLUP);
pinMode(PIN_REED_FEN, INPUT_PULLUP);
pinMode(PIN_VIBRATION, INPUT);
connectWiFiAndWG();
mqtt.setServer(MQTT_SERVER, MQTT_PORT);
mqttReconnect();
tempSensor.begin();
Serial.println("Bereit.");
}
void loop() {
if (!mqtt.connected()) mqttReconnect();
mqtt.loop();
// ---- Sofort-Ereignisse ----
if (digitalRead(PIN_PIR) == HIGH) { publish("pir", "1"); delay(500); }
if (digitalRead(PIN_LD2410) == HIGH) { publish("praesenz", "1"); delay(200); }
if (digitalRead(PIN_REED_TUER) == LOW) { publish("tuer", "1"); }
if (digitalRead(PIN_REED_FEN) == LOW) { publish("fenster", "1"); }
if (digitalRead(PIN_VIBRATION) == HIGH) { publish("vibration","1"); delay(200); }
// ---- Zyklische Analogwerte ----
if (millis() - lastPublish >= PUBLISH_INTERVAL) {
lastPublish = millis();
publish("gas_lpg", String(analogRead(PIN_MQ2)));
publish("co", String(analogRead(PIN_MQ7)));
tempSensor.requestTemperatures();
float t = tempSensor.getTempCByIndex(0);
if (t != DEVICE_DISCONNECTED_C) publish("temperatur", String(t, 1));
}
// ---- Post-Alarm: Probe-Sniffer ----
// ioBroker publiziert "wohnmobil/alarm/probe_scan = 1" um den Scan auszulösen
if (probeCapturePending) {
probeCapturePending = false;
publish("alarm_status", "probe_scan_start");
runProbeScan(30); // 30 Sekunden scannen
connectWiFiAndWG(); // Verbindung wiederherstellen
mqttReconnect();
sendProbeResults(); // JSON-Array an ioBroker
publish("alarm_status", "probe_scan_done");
}
delay(100);
}
Der Probe-Sniffer läuft bewusst nach der initialen MQTT-Alarmmeldung, damit die Push-Benachrichtigung nicht verzögert wird. Der Scan schaltet das WiFi kurz auf Monitor-Mode um - in dieser Phase (30 Sekunden) ist kein MQTT-Traffic möglich, was ich mit nur einem zusätzlichen ESP32 vorläufig hinnehmen muss. Es ist im Kontext eines Fahrzeugalarms noch akzeptabel. ioBroker triggert den Scan per wohnmobil/alarm/probe_scan = 1 MQTT-Publish, das der ESP32 als MQTT-Subscription empfängt. Da der relevante Code-Pfad kürzer gehalten ist, zeigt der Sketch die Subscription nicht - sie wird mit mqtt.subscribe("wohnmobil/alarm/probe_scan") und einem mqtt.setCallback()-Handler ergänzt.
Zu den erfassten Probe Requests: Die Source-MAC ist bei modernen Android- und iOS-Geräten meist randomisiert - darauf hatte ich schon im Artikel zur Multi-Objekt-Erkennung hingewiesen. Was aber viele Geräte weiterhin preisgeben, sind die SSID-Namen aus der gespeicherten Netzwerkliste (Preferred Network List): Wer zu Hause ein WLAN namens Meier-Heimnetz-5G oder FRITZ!Box 7530 HG betreibt, hinterlässt damit ein sehr individuelles digitales Fingerabdruckfragment. Ermittler können damit den Kreis der Verdächtigen erheblich eingrenzen. Das kann jeder selbst ausprobieren, wenn man sich einen Account auf https://wigle.net eröffnet und nach seinem WLAN-Namen sucht.
ESP32-CAM: Die eingebaute Beweiskamera
Das AI Thinker ESP32-CAM Modul ist nichts anderes als ein Standard-ESP32 mit verlötetem OV2640-Kameramodul auf einer kompakten Platine - für unter 5 Euro und damit die günstigste Möglichkeit, Bildbeweise direkt im Fahrzeug zu sichern. Im Gegensatz zu meinem Wasserzähler-Projekt, wo das Modul mit AI-on-the-Edge betrieben wird, kommt hier ein einfacher HTTP-Snapshot-Server zum Einsatz.
Das ESP32-CAM bekommt ebenfalls einen eigenen WireGuard-Peer (10.6.0.4). Schlüsselgenerierung analog zum Sensor-Hub:
wg genkey | tee cam_private.key | wg pubkey > cam_public.key
Public Key in OPNsense-Peer ESP32-CAM eintragen.
Sketch für ESP32-CAM (AI Thinker)
// womo-alarm-cam.ino – ESP32-CAM Snapshot-Server
// M. Meister – blog.meister-security.de
#include <WiFi.h>
#include <WireGuard-ESP32.h>
#include <WebServer.h>
#include "esp_camera.h"
// AI Thinker ESP32-CAM Pinbelegung
#define CAM_PIN_PWDN 32
#define CAM_PIN_RESET -1
#define CAM_PIN_XCLK 0
#define CAM_PIN_SIOD 26
#define CAM_PIN_SIOC 27
#define CAM_PIN_D7 35
#define CAM_PIN_D6 34
#define CAM_PIN_D5 39
#define CAM_PIN_D4 38
#define CAM_PIN_D3 37
#define CAM_PIN_D2 36
#define CAM_PIN_D1 21
#define CAM_PIN_D0 19
#define CAM_PIN_VSYNC 25
#define CAM_PIN_HREF 23
#define CAM_PIN_PCLK 22
// =============================================================
const char WIFI_SSID[] = "WoMo-Hotspot";
const char WIFI_PASSWORD[] = "sicheres_passwort";
const char WG_PRIVATE_KEY[] = "MEIN_CAM_PRIVATE_KEY_BASE64=";
const IPAddress WG_LOCAL_IP(10, 6, 0, 4);
const char WG_ENDPOINT[] = "home.example.de";
const char WG_PEER_PUBLIC_KEY[] = "OPNSENSE_PUBLIC_KEY_BASE64=";
const int WG_ENDPOINT_PORT = 51820;
// =============================================================
static WireGuard wg;
WebServer server(80);
void initCamera() {
camera_config_t cfg;
cfg.ledc_channel = LEDC_CHANNEL_0;
cfg.ledc_timer = LEDC_TIMER_0;
cfg.pin_d0 = CAM_PIN_D0; cfg.pin_d1 = CAM_PIN_D1;
cfg.pin_d2 = CAM_PIN_D2; cfg.pin_d3 = CAM_PIN_D3;
cfg.pin_d4 = CAM_PIN_D4; cfg.pin_d5 = CAM_PIN_D5;
cfg.pin_d6 = CAM_PIN_D6; cfg.pin_d7 = CAM_PIN_D7;
cfg.pin_xclk = CAM_PIN_XCLK;
cfg.pin_pclk = CAM_PIN_PCLK;
cfg.pin_vsync = CAM_PIN_VSYNC;
cfg.pin_href = CAM_PIN_HREF;
cfg.pin_sscb_sda = CAM_PIN_SIOD;
cfg.pin_sscb_scl = CAM_PIN_SIOC;
cfg.pin_pwdn = CAM_PIN_PWDN;
cfg.pin_reset = CAM_PIN_RESET;
cfg.xclk_freq_hz = 20000000;
cfg.pixel_format = PIXFORMAT_JPEG;
cfg.frame_size = FRAMESIZE_VGA; // 640×480
cfg.jpeg_quality = 12; // 0=beste, 63=schlechteste
cfg.fb_count = 1;
esp_err_t err = esp_camera_init(&cfg);
if (err != ESP_OK)
Serial.printf("Kamera-Init fehlgeschlagen: 0x%x\n", err);
}
void handleCapture() {
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) { server.send(503, "text/plain", "Kamera nicht bereit"); return; }
server.sendHeader("Content-Disposition", "inline; filename=snapshot.jpg");
server.send_P(200, "image/jpeg", (const char*)fb->buf, fb->len);
esp_camera_fb_return(fb);
}
void setup() {
Serial.begin(115200);
initCamera();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (!WiFi.isConnected()) delay(500);
Serial.printf("IP: %s\n", WiFi.localIP().toString().c_str());
configTime(3600, 3600, "pool.ntp.org");
struct tm ti; while (!getLocalTime(&ti)) delay(500);
wg.begin(WG_LOCAL_IP, WG_PRIVATE_KEY,
WG_ENDPOINT, WG_PEER_PUBLIC_KEY, WG_ENDPOINT_PORT);
delay(3000);
server.on("/capture", HTTP_GET, handleCapture);
server.on("/", HTTP_GET, [](){
server.sendHeader("Location", "/capture");
server.send(302);
});
server.begin();
Serial.printf("CAM bereit: http://%s/capture\n",
WG_LOCAL_IP.toString().c_str());
}
void loop() {
server.handleClient();
}
ioBroker erreicht den Snapshot über die WireGuard-Adresse http://10.6.0.4/capture. Wichtig beim Kompilieren: Board-Auswahl in der Arduino IDE auf AI Thinker ESP32-CAM setzen, da die Kamera-Bibliothek esp_camera.h nur mit diesem Framwork korrekt kompiliert.
ioBroker: Alarm, Snapshot und ntfy
Da Telegram schon seit längerem verabschiedet und durch den selbst gehosteten ntfy-Dienst ersetzt wurde, erfolgen alle Push-Benachrichtigungen per HTTP PUT an meinen ntfy-Server. ntfy unterstützt Datei-Anhänge direkt im Request-Body - der Snapshot landet also als eingebettetes Bild in der Notification, ohne Umweg über externe Dienste.

MQTT-Adapter aktivieren
Im ioBroker-Adminpanel den ioBroker.mqtt-Adapter installieren, Port 1883, Authentifizierung aktivieren. Nach dem Neustart erscheinen die ESP32-Topics unter mqtt.0.wohnmobil.sensor.* im Objekt-Browser.
JavaScript-Alarmregel
// womo-alarm.js – ioBroker Alarmlogik mit ntfy und ESP32-CAM
// M. Meister – Version 0.4
'use strict';
const https = require('https');
const http = require('http');
const fs = require('fs');
// --- Konfiguration ---
const NTFY_HOST = 'ntfy.example.de'; // Self-hosted ntfy
const NTFY_TOPIC = 'womo-alarm';
const NTFY_TOKEN = 'tk_xxxxxxxxxxxx'; // ntfy Access-Token
const CAM_WG_IP = '10.6.0.4'; // ESP32-CAM über WireGuard
const CAM_PORT = 80;
const SNAPSHOT = '/tmp/womo_snapshot.jpg';
// Pixel-7-Kamera als Fallback (android-ip-camera)
const PIX_HOST = '10.6.0.2';
const PIX_PORT = 4444;
const PIX_USER = 'user';
const PIX_PASS = 'geheim';
const GAS_THRESHOLD = 600;
const CO_THRESHOLD = 400;
// --- Snapshot von ESP32-CAM holen ---
function captureFromCAM(cb) {
http.get({ hostname: CAM_WG_IP, port: CAM_PORT, path: '/capture',
timeout: 8000 }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
fs.writeFileSync(SNAPSHOT, buf);
cb(SNAPSHOT);
});
}).on('error', (e) => {
log('ESP32-CAM nicht erreichbar, Fallback Pixel 7: ' + e.message, 'warn');
captureFromPixel7(cb);
});
}
// --- Fallback: MJPEG-Frame vom Pixel 7 (android-ip-camera) ---
function captureFromPixel7(cb) {
const req = https.get({
hostname: PIX_HOST, port: PIX_PORT, path: '/stream',
rejectUnauthorized: false,
headers: { Authorization: 'Basic ' +
Buffer.from(PIX_USER + ':' + PIX_PASS).toString('base64') }
}, (res) => {
let buf = Buffer.alloc(0);
res.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
const start = buf.indexOf(Buffer.from([0xFF, 0xD8]));
const end = buf.indexOf(Buffer.from([0xFF, 0xD9]));
if (start !== -1 && end > start) {
fs.writeFileSync(SNAPSHOT, buf.subarray(start, end + 2));
req.destroy();
cb(SNAPSHOT);
}
});
});
req.on('error', (e) => log('Pixel7-Kamera: ' + e.message, 'warn'));
req.setTimeout(10000, () => req.destroy());
}
// --- ntfy-Alarm mit Bild-Anhang (HTTP PUT) ---
function ntfyAlarm(title, tags, imagePath, doneCb) {
const data = fs.readFileSync(imagePath);
const req = https.request({
hostname: NTFY_HOST,
port: 443,
path: '/' + NTFY_TOPIC,
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + NTFY_TOKEN,
'Title': encodeURIComponent(title),
'Priority': 'urgent',
'Tags': tags,
'Filename': 'womo.jpg',
'Content-Type': 'image/jpeg',
'Content-Length': data.length
}
}, (res) => {
log('ntfy HTTP ' + res.statusCode, 'info');
if (doneCb) doneCb(res.statusCode === 200);
});
req.on('error', (e) => log('ntfy-Fehler: ' + e.message, 'warn'));
req.write(data);
req.end();
}
// --- Alarm auslösen ---
function alarm(sensorId, value, title, tags) {
log('ALARM – ' + title + ' | Wert: ' + value, 'warn');
captureFromCAM((img) => ntfyAlarm(title + ' – ' + value, tags, img));
// Probe-Scan beim Einbruchsverdacht auslösen
if (['pir', 'praesenz', 'tuer', 'fenster', 'vibration'].includes(sensorId)) {
setState('mqtt.0.wohnmobil.alarm.probe_scan', '1', true);
}
}
// --- Überwachungsregeln ---
on({id: 'mqtt.0.wohnmobil.sensor.gas_lpg', change: 'any'}, (obj) => {
if (Number(obj.state.val) > GAS_THRESHOLD)
alarm('gas_lpg', obj.state.val, '⚠️ LPG-Gasalarm', 'rotating_light,warning');
});
on({id: 'mqtt.0.wohnmobil.sensor.co', change: 'any'}, (obj) => {
if (Number(obj.state.val) > CO_THRESHOLD)
alarm('co', obj.state.val, '☠️ CO-Alarm', 'skull,rotating_light');
});
on({id: 'mqtt.0.wohnmobil.sensor.pir', change: 'any'}, (obj) => {
if (obj.state.val == '1')
alarm('pir', 'AUSGELÖST', '👣 Bewegung erkannt', 'rotating_light');
});
on({id: 'mqtt.0.wohnmobil.sensor.praesenz', change: 'any'}, (obj) => {
if (obj.state.val == '1')
alarm('praesenz', 'PRÄSENZ', '🧍 Präsenz erkannt (Radar)', 'rotating_light');
});
on({id: 'mqtt.0.wohnmobil.sensor.tuer', change: 'any'}, (obj) => {
if (obj.state.val == '1')
alarm('tuer', 'OFFEN', '🚪 Türalarm', 'door,rotating_light');
});
on({id: 'mqtt.0.wohnmobil.sensor.fenster', change: 'any'}, (obj) => {
if (obj.state.val == '1')
alarm('fenster', 'OFFEN', '🪟 Fensteralarm', 'rotating_light');
});
on({id: 'mqtt.0.wohnmobil.sensor.vibration', change: 'any'}, (obj) => {
if (obj.state.val == '1')
alarm('vibration', 'AUSGELÖST', '📳 Erschütterung', 'rotating_light');
});
// --- Probe-Ergebnisse protokollieren ---
on({id: 'mqtt.0.wohnmobil.sensor.probe_requests', change: 'any'}, (obj) => {
log('📡 Probe-Requests erfasst: ' + obj.state.val, 'warn');
// JSON-Array wird im Log gespeichert – für spätere forensische Auswertung
ntfyAlarm('📡 WiFi-Umgebung bei Alarm', 'mag', SNAPSHOT,
() => log('Probe-Daten an ntfy gesendet'));
});
Das Einsammeln der Probe-Requests muss noch verbessert werden, sodass diese auf jeden Fall aus dem Fahrzeug geschafft werden. Aber das ist ja auch nur ein Anfang.
Der ESP32-CAM-Snapshot steht als primäre Quelle, der MJPEG-Frame vom Pixel 7 als automatischer Fallback. Ist die ESP32-CAM-Verbindung nicht erreichbar (etwa weil das Board gerade bootet), greift ioBroker transparent auf die android-ip-camera zurück. Wobei das natürlich auch zusätzlich erfolgen kann.
Schwellenwerte kalibrieren
MQ-2 und MQ-7 benötigen eine Vorwärmzeit von 24–48 Stunden für stabile Messwerte. Meine Empfehlung aus verschiedenen Beobachtungen: Werte über 48 Stunden per History-Adapter aufzeichnen, Ruhewert als Baseline nehmen, Alarm-Schwellenwert auf Baseline × 1,5 setzen. Der DGUV-Grenzwert für Propan liegt am Arbeitsplatz bei 1000 ppm - im ADC-Rohwert typisch zwischen 400 und 800, je nach Sensorexemplar.
Fazit
Ich bin an der neuen Struktur erst drei Tage. Es ist daher noch nicht vollständig entwickelt. Der beschriebene Stack liefert eine beeindruckende, cloud-freie Wohnmobil-Alarmanlage zu einem Bruchteil der Kosten kommerzieller Systeme: ESP32-Sensor-Hub mit Radar-Präsenzerkennung, ESP32-CAM für lokale Beweissicherung, WiFi-Probe-Sniffer für forensische Tätereingrenzung und ntfy als selbst gehosteter Push-Dienst. Der Gas- und CO-Sensor zeigt, dass eine Alarmanlage, die nur auf Einbrüche reagiert, das Hab und Gut schützt - eine, die auch auf unsichtbare Gefährdungen achtet, schützt das Leben.


