Dieses Projekt implementiert einen WLAN-fähigen Füllstandssensor für Salzbehälter auf Basis eines ESP32-C3 mit HC-SR04-Ultraschallsensor. Die Messwerte werden über eine Svelte-Web-UI dargestellt, per WebSocket in Echtzeit übertragen und über MQTT (inkl. Home Assistant Auto-Discovery) bereitgestellt.
Salzstand ist ein ESP32-C3-Projekt zur kontinuierlichen Füllstandsmessung von Salzbehältern mit Ultraschall. Es kombiniert Firmware, Web-Dashboard (Svelte), MQTT-Telemetrie und Home-Assistant-Discovery in einem System.
Highlights:
Tech-Stack: ESP32-C3, Arduino, PlatformIO, Svelte, PubSubClient, ESPAsyncWebServer, ArduinoJson
| Komponente | Details |
|---|---|
| Mikrocontroller | ESP32-C3 DevKitM-1, 160 MHz, 320 KB RAM, 4 MB Flash |
| Sensor | HC-SR04 Ultraschall-Abstandssensor |
| Trigger-Pin | GPIO 4 |
| Echo-Pin | GPIO 5 |
ping()-Implementierung über pulseIn() (keine externe Bibliothek)loop()-Durchlauf aktualisiert| Wert | Berechnung |
|---|---|
| Rohdistanz (m) | Direkt aus ping_us / 2 / 29.1 / 100 |
| Salzstand (cm) | (behaelterhoehe + offset) - gemessene_cm |
| Füllstand (%) | salzstand_cm / behaelterhoehe * 100 |
| Ping-Zeit (µs) | Roher pulseIn-Wert |
| Messung gültig | true wenn Ping-Zeit im Gültigkeitsbereich |
Die persistente Ablage ist auf drei Flash-Bereiche aufgeteilt:
| Bereich | Partition | Inhalt |
|---|---|---|
| Konfiguration | nvs |
WiFi-, MQTT-, Sensor- und Push-Einstellungen |
| Messverlauf | histnvs |
Historienpunkte im Namespace history |
| Web-UI | littlefs |
Gebaute Svelte-Dateien aus data/ |
Alle Einstellungen werden persistent im NVS-Flash gespeichert (Namespace config, JSON-Format).
| Feld | Typ | Beschreibung |
|---|---|---|
wifi.ssid |
String | WLAN-Netzwerkname |
wifi.password |
String | WLAN-Passwort |
wifi.deviceName |
String | Gerätename für Hostname und mDNS |
staticIp.ip |
String | Statische IP (leer = DHCP) |
staticIp.gateway |
String | Gateway |
staticIp.subnet |
String | Subnetzmaske |
staticIp.dns |
String | DNS-Server |
mqtt.server |
String | MQTT-Broker-Adresse |
mqtt.port |
uint16 | MQTT-Port (Standard: 1883) |
mqtt.user |
String | MQTT-Benutzername |
mqtt.password |
String | MQTT-Passwort |
mqtt.discovery |
bool | Home Assistant Auto-Discovery aktivieren |
push.* |
Objekt | SMTP-, Trigger- und Vorlagen-Konfiguration für E-Mail-Benachrichtigungen |
behaelterhoehe |
float | Innenhöhe des Behälters in cm |
offset |
float | Korrekturwert in cm (für Sensorposition) |
sampleIntervalSeconds |
uint32 | Abtastrate des Sensors in Sekunden |
Der Messverlauf wird getrennt davon in der dedizierten Partition histnvs gespeichert. Dadurch bleiben Konfiguration und Historie auch bei upload, uploadfs und OTA-Updates erhalten, ohne die kleine Default-NVS für Konfigurationsdaten zu überladen.
Erster Start / keine SSID konfiguriert ODER WLAN-Verbindung schlägt fehl
→ SETUP_MODE: Access Point "Salzstand-Setup" (kein Passwort)
→ Webseite auf 192.168.4.1: WiFi-Konfigurationsformular
Normaler Betrieb
→ NORMAL_MODE: Verbindet mit WLAN, startet Dashboard + MQTT
Die UI ist als Svelte-Frontend mit Vite gebaut, wird aus LittleFS ausgeliefert und kommuniziert über WebSocket (Echtzeit) sowie REST-API.
Anzeigefelder:
| Karte | Inhalt |
|---|---|
| Aktuelle Distanz | Rohdistanz in m (Ultraschall-Messwert) |
| Salzstand | Füllstand in cm und Prozent |
| WiFi-Signal | RSSI in dBm |
| Uptime | Betriebszeit in Stunden und Minuten |
| Netzwerk | IP-Adresse, SSID, BSSID |
Schnelleinstellungen:
POST /api/configWeitere Aktionen:
Neustart-Button → POST /api/restartCtrl+D → öffnet/schließt das Debug-OverlaySensor:
POST /api/configWiFi:
name.localPOST /api/wifi (Neustart erforderlich)MQTT & HA:
POST /api/mqtt (löst sofortigen Reconnect aus)Push Nachricht:
Update:
Alle Endpunkte laufen auf Port 80.
| Methode | Endpoint | Beschreibung |
|---|---|---|
GET |
/api/config |
Sensor-Konfiguration lesen (behaelterhoehe, offset) |
POST |
/api/config |
Sensor-Konfiguration speichern |
GET |
/api/wifi |
WiFi-Konfiguration lesen (Passwort wird nicht zurückgegeben) |
POST |
/api/wifi |
WiFi-Konfiguration speichern |
GET |
/api/mqtt |
MQTT-Konfiguration lesen (Passwort als *** maskiert) |
POST |
/api/mqtt |
MQTT-Konfiguration speichern + sofortiger Reconnect |
GET |
/api/mqtt/status |
MQTT-Verbindungsstatus und Geräte-ID lesen |
POST |
/api/mqtt/reconnect |
MQTT-Verbindung manuell neu aufbauen |
GET |
/api/push |
Push-/SMTP-Konfiguration lesen |
POST |
/api/push |
Push-/SMTP-Konfiguration speichern |
POST |
/api/push/test |
Test-E-Mail versenden |
POST |
/api/push/smtp-check |
SMTP-Diagnose ausführen |
GET |
/api/update/status |
OTA-Status und aktive Versionsdaten lesen |
GET |
/api/update/manifest |
OTA-Manifest aus GitHub Releases lesen |
POST |
/api/update/repo |
Repo-OTA aus GitHub Release starten |
POST |
/api/update/upload/app |
App-BIN lokal hochladen |
POST |
/api/update/upload/webui |
Web-UI-BIN lokal hochladen |
GET |
/api/history |
Gespeicherte Historienpunkte lesen |
DELETE |
/api/history |
Gespeicherten Messverlauf löschen |
GET |
/api/export |
Konfiguration und Historie als Backup exportieren |
POST |
/api/import |
Konfiguration und Historie aus Backup importieren |
POST |
/api/factory-reset |
Konfiguration, Historie und WiFi-Credentials löschen; Neustart auslösen |
GET |
/api/nvs |
Gesamte NVS-Konfiguration als JSON (Passwörter maskiert) |
POST |
/api/restart |
ESP32 neu starten |
GET |
/* |
Statische Dateien aus LittleFS (Svelte-UI) |
/ws)Broadcast-Intervalle aus dem loop():
| Typ | Intervall | Felder |
|---|---|---|
sensor |
5 s | ping_us, valid, rohdistanz, salzstandCm, salzstandPercent |
wifi |
5 s | signal (dBm), ip, ssid, bssid |
uptime |
5 s | uptime (Sekunden) |
log |
bei jedem Log-Eintrag | level, timestamp, message |
Broker-Verbindung über PubSubClient 2.8.
Reconnect-Versuch alle 5 Sekunden bei Verbindungsverlust.
Keepalive: 30 Sekunden, Buffer: 1024 Bytes.
Geräte-ID: salzstand_ + MAC-Adresse ohne Doppelpunkte (z. B. salzstand_A4CF121E3B00).
| Topic | Richtung | Retain | Beschreibung |
|---|---|---|---|
salzstand/status |
pub | ✓ | online / offline (LWT) |
salzstand/sensor/state |
pub | ✓ | Alle Messwerte als JSON (alle 30 s) |
salzstand/config/behaelterhoehe/state |
pub | ✓ | Aktuelle Behälterhöhe in cm |
salzstand/config/behaelterhoehe/set |
sub | – | Neue Behälterhöhe setzen (1–1000 cm) |
salzstand/config/offset/state |
pub | ✓ | Aktueller Offset-Wert |
salzstand/config/offset/set |
sub | – | Neuen Offset setzen (–500 bis +500 cm) |
salzstand/config/sampleinterval/state |
pub | ✓ | Aktuelle Abtastrate in Sekunden |
salzstand/config/sampleinterval/set |
sub | – | Neue Abtastrate setzen (mind. 5 s) |
salzstand/system/state |
pub | ✓ | WiFi, Uptime & ESP32-Systemdaten (alle 30 s) |
salzstand/update/state |
pub | ✓ | OTA-Status und Versionsinformationen |
salzstand/update/install |
sub | – | OTA-Installation auslösen |
salzstand/sensor/state){
"fill_level": 73.5,
"distance_cm": 69.8,
"raw_distance_m": 0.2520,
"ping_us": 1468,
"valid": true,
"status": "ok"
}
status-Werte:
| Wert | Bedeutung |
|---|---|
ok |
Messung gültig |
timeout |
Kein Echo empfangen (Ping-Zeit = 0) |
out_of_range |
Echo außerhalb des Gültigkeitsbereichs |
salzstand/system/state){
"ip": "192.168.1.100",
"ssid": "MyNetwork",
"rssi": -65,
"uptime_s": 3600,
"free_heap": 245000,
"min_free_heap": 220000,
"cpu_freq_mhz": 160,
"flash_size_kb": 4096,
"sketch_size_kb": 980,
"chip_rev": 3
}
Über /set-Topics können Konfigurationswerte live geändert werden:
SensorManager sofort übernommen/state-Topic veröffentlichtWenn mqtt.discovery = true, werden beim Connect 14 Entitäten veröffentlicht:
| HA-Entität | Domain | Object-ID | Einheit | Kategorie |
|---|---|---|---|---|
| Füllstand | sensor |
fill_level |
% | – |
| Salzstand | sensor |
distance_cm |
cm | – |
| Rohdistanz | sensor |
raw_distance |
m | diagnostic |
| Ultraschall Pingzeit | sensor |
ping_us |
µs | diagnostic |
| Behälterhöhe | number |
behaelterhoehe |
cm | config |
| Sensor Offset | number |
offset |
cm | config |
| Abtastrate Ultraschall | number |
sample_interval |
s | config |
| WiFi Signal | sensor |
rssi |
dBm | diagnostic |
| IP-Adresse | sensor |
ip_address |
– | diagnostic |
| SSID | sensor |
ssid |
– | diagnostic |
| Betriebszeit | sensor |
uptime |
s | diagnostic |
| Freier Heap | sensor |
free_heap |
B | diagnostic |
| CPU-Frequenz | sensor |
cpu_freq |
MHz | diagnostic |
| OTA Update | update |
ota |
– | – |
Jede Entität enthält:
unique_id (Geräte-ID + Feldname) für stabile HA-Identifikationavailability_topic → salzstand/statusSalzstand, Manufacturer DIY, Model ESP32-C3Discovery-Topic-Schema:
homeassistant/{domain}/{deviceId}/{objectId}/config
main.cpp
├── SystemStateManager → Bestimmt Boot-Modus (SETUP / NORMAL)
├── WifiManager → Verbindungsverwaltung, exponentielle Backoff-Wiederverbindung
├── WebServerDashboard → REST-API, WebSocket, LittleFS-Dateiserving
├── WebServerSetup → Captive-Portal-artige WiFi-Erstkonfiguration (AP-Modus)
├── SensorManager → Ultraschall-Messung, Median-Filter, Kalkulation
├── MqttManager → PubSubClient-Wrapper, Discovery, Subscribe/Publish
├── PushNotificationManager → SMTP-Versand, Triggerlogik und SMTP-Diagnose
├── ConfigStore → NVS-Persistenz (ArduinoJson, Preferences)
├── EventBus → Pub/Sub-System für interne Ereignisse
└── DebugLogger → Log-Weiterleitung an Serial + WebSocket
Singleton-Muster für alle Manager — Zugriff immer via XManager::getInstance().
EventBus-Ereignisse:
| EventType | Ausgelöst von | Beschreibung |
|---|---|---|
WIFI_CONNECTED |
WifiManager | WLAN-Verbindung hergestellt |
WIFI_DISCONNECTED |
WifiManager | WLAN-Verbindung getrennt |
SYSTEM_MQTT_CONNECTED |
MqttManager | MQTT-Verbindung hergestellt |
SYSTEM_MQTT_DISCONNECTED |
MqttManager | MQTT-Verbindung getrennt |
SENSOR_TIMEOUT |
SensorManager | Sensor antwortet nicht |
SENSOR_OUT_OF_RANGE |
SensorManager | Messwert außerhalb Bereich |
CONFIG_SAVED |
ConfigStore | Konfiguration gespeichert |
| Bibliothek | Version |
|---|---|
| ESP32Async/ESPAsyncWebServer | ^3.6.0 |
| ESP32Async/AsyncTCP | ^3.3.2 |
| bblanchon/ArduinoJson | ^6.21.2 |
| knolleary/PubSubClient | ^2.8 |
cd ui
npm install
npm run build
Die gebauten Dateien werden per extra_scripts automatisch nach data/ geschrieben.
# Dateisystem-Image erstellen und flashen
pio run --target uploadfs
# Firmware kompilieren und flashen
pio run --target upload
Hinweis zur Speicheraufteilung auf ESP32-C3:
partitions.csv.nvs: 0x5000 für Konfigurationapp0 / app1: jeweils 0x180000histnvs: 0x050000 für Historielittlefs: 0x0A0000 ab Adresse 0x360000Das OTA-Manifest wird kryptografisch signiert (ECDSA P-256, SHA-256). Die Firmware verifiziert diese Signatur vor jedem Repo-OTA-Update mit einem fest eingebetteten Public Key. Zusaetzlich werden Manifest- und Asset-Downloads jetzt per TLS-Zertifikatskette (Root-CA-Pruefung) validiert.
Einmalig Schluessel erzeugen:
node scripts/generate-release-signing-keys.js
Ergebnis:
signing/release_private.pem (bleibt lokal, ist per .gitignore ausgeschlossen)signing/release_public.peminclude/ReleaseSigningPublicKey.hSigniertes Release erzeugen:
# optional: eigener Pfad zum privaten Schluessel
set RELEASE_SIGNING_PRIVATE_KEY=C:\path\to\release_private.pem
node scripts/prepare-release.js
prepare-release.js bricht ab, wenn kein privater Signatur-Schluessel vorhanden ist.
Wichtiger Workflow-Hinweis:
scripts/run-release.ps1 baut Firmware und Web-UI nicht neu, sondern verpackt die vorhandenen Artefakte aus .pio/build.scripts/run-push.ps1 sollte deshalb immer scripts/run-deploy.ps1 oder mindestens ein frischer Build des gepushten Stands erfolgt sein, bevor das GitHub-Release erstellt wird.pio device monitor --baud 115200
http://192.168.4.1http://<IP>/ aufrufen| Eigenschaft | Wert |
|---|---|
| Flash-Nutzung | ~74,8 % (4 MB) |
| RAM-Nutzung | ~12,3 % (320 KB) |
| Sensor-Messintervall | konfigurierbar, mindestens 5 Sekunden |
| MQTT-Publish-Intervall | 30 Sekunden |
| WebSocket-Broadcast | 5 Sekunden |
| MQTT-Reconnect-Prüfung | 5 Sekunden |
| Serial-Diagnose | 10 Sekunden |
| MQTT Keepalive | 30 Sekunden |
| MQTT Buffer | 1024 Bytes |