Josef Straßl 1 miesiąc temu
commit
0fed6302d7

+ 275 - 0
README.md

@@ -0,0 +1,275 @@
+# Getraenkeautomat Monitor
+
+Getraenkeautomat Monitor ist eine kleine PHP/HTML/JS-Anwendung zur Ueberwachung von Fuellstaenden in Getraenkeautomaten. Ein ESP32 oder ein anderer Sensor-Client sendet Messwerte an eine einfache REST-nahe API. Die Anwendung berechnet daraus den geschaetzten Bestand pro Fach, visualisiert den aktuellen Status im Browser und loest bei kritischen Schwellwerten Alarme aus.
+
+Die Anwendung ist bewusst simpel gehalten:
+
+- Backend mit plain PHP
+- Frontend mit HTML, CSS und Vanilla JavaScript
+- Persistenz nur in JSON-Dateien
+- keine Datenbank
+- kein Framework
+
+## Funktionsumfang
+
+- mehrere Automaten in einer Instanz
+- mehrere Faecher pro Automat
+- REST-nahe Eingangs-API fuer Einzelmessungen
+- Dashboard mit visueller Fuellstandsanzeige
+- Alarmierung per Webhook und per Email
+- Adminpanel zur Bearbeitung der JSON-Config
+- statischer Admin-Login mit bcrypt-Hash
+
+## Architektur im Ueberblick
+
+Der Datenfluss ist einfach:
+
+1. Ein Sensor-Client sendet einen Messwert in Millimetern an `POST /api/v1/readings.php`.
+2. Die Anwendung sucht das passende Fach ueber `machine_id` und `sensor_id`.
+3. Aus `full_distance_mm`, `empty_distance_mm` und `distance_per_unit` werden Fuellstand und Flaschenanzahl berechnet.
+4. Der letzte bekannte Zustand wird in `data/state.json` gespeichert.
+5. Wenn sich der Status von `ok` nach `critical` oder von `critical` nach `ok` aendert, wird ein Alarmereignis erzeugt.
+6. Dashboard und Adminpanel lesen den aktuellen Zustand ueber `GET /api/v1/status.php`.
+
+## Projektstruktur
+
+- `public/`
+  - Webroot der Anwendung
+  - enthaelt Dashboard, Adminpanel, CSS, JavaScript und API-Endpunkte
+- `src/`
+  - Kernlogik der Anwendung
+  - Konfigurationszugriff, Zustandsberechnung, Alarmierung, Authentifizierung
+- `data/`
+  - JSON-Dateien fuer Konfiguration, aktuellen Zustand und Alarmhistorie
+
+Wichtige Dateien:
+
+- `public/index.php`: Dashboard
+- `public/admin/index.php`: Login und Adminpanel
+- `public/api/v1/readings.php`: API fuer eingehende Sensorwerte
+- `public/api/v1/status.php`: Status-API fuer Dashboard und Adminpanel
+- `src/MonitorService.php`: zentrale Orchestrierung fuer Lesen, Berechnen und Status
+- `src/InventoryService.php`: Berechnung von Fuellgrad und Bestand
+- `src/AlertService.php`: Webhook- und Email-Alarmierung
+- `data/config.json`: Hauptkonfiguration
+- `data/state.json`: letzter bekannter Zustand je Fach
+- `data/alert_log.json`: Alarm- und Entwarnungsereignisse
+
+## Voraussetzungen
+
+Fuer den Betrieb wird auf dem Zielsystem benoetigt:
+
+- PHP 8.1 oder neuer empfohlen
+- Schreibrechte auf `data/`
+- funktionierende Mail-Konfiguration, falls `mail()` fuer Email-Alarme genutzt werden soll
+- Webserver oder PHP Built-in Server
+
+## Schnellstart
+
+Sobald PHP auf dem Zielsystem installiert ist:
+
+```bash
+php -S localhost:8000 -t public
+```
+
+Danach ist die Anwendung erreichbar unter:
+
+- Dashboard: `http://localhost:8000/`
+- Adminpanel: `http://localhost:8000/admin/`
+- Status-API: `http://localhost:8000/api/v1/status.php`
+
+## Default-Zugangsdaten
+
+Die Beispiel-Konfiguration bringt absichtlich einfache Startwerte mit:
+
+- Admin-Benutzer: `admin`
+- Admin-Passwort: `admin123`
+- API-Bearer-Token: `demo-esp32-token`
+
+Diese Werte sollten direkt nach dem ersten Login angepasst werden.
+
+## Dashboard
+
+Das Dashboard ist die oeffentliche Visualisierung der Anlage. Es zeigt:
+
+- Anzahl Automaten, Faecher und aktuell kritische Faecher
+- Filter nach Automat
+- pro Fach:
+  - Label
+  - Produktname
+  - visuellen Fuellstand
+  - geschaetzte Anzahl Flaschen
+  - Alarmgrenze
+  - letzten Messwert in Millimetern
+  - letzten Messzeitpunkt
+- letzte Alarm- und Entwarnungsereignisse
+
+Die Daten werden regelmaessig per Polling ueber `status.php` aktualisiert. Das Intervall wird in `config.json` ueber `app.dashboard_refresh_seconds` gesteuert.
+
+## Adminpanel
+
+Das Adminpanel ist ueber `/admin/` erreichbar und erlaubt:
+
+- Aendern des App-Namens und des Refresh-Intervalls
+- Aendern des API-Bearer-Tokens
+- Aendern von Admin-Benutzername und Passwort
+- Verwalten von Webhooks
+- Verwalten von Email-Empfaengern
+- Verwalten von Automaten und ihren Faechern
+
+Beim Speichern des Admin-Passworts wird immer ein bcrypt-Hash erzeugt. Das Passwort selbst wird nicht im Klartext gespeichert.
+
+## Alarmverhalten
+
+Alarme werden nicht bei jeder eingehenden kritischen Messung ausgeloest, sondern nur bei Zustandswechseln:
+
+- `ok -> critical`: Alarm
+- `critical -> ok`: Entwarnung
+- `critical -> critical`: kein weiterer Alarm
+- `ok -> ok`: kein Alarm
+
+Dadurch werden doppelte Daueralarme vermieden.
+
+Ein Fach gilt als kritisch, wenn gilt:
+
+```text
+units_estimated < alert_below_units
+```
+
+Ein Fach mit `alert_below_units = 2` loest also erst dann Alarm aus, wenn nur noch `1` oder `0` Einheiten geschaetzt werden.
+
+## Wie die Bestandsberechnung funktioniert
+
+Jedes Fach hat drei wichtige Kalibrierwerte:
+
+- `full_distance_mm`
+- `empty_distance_mm`
+- `distance_per_unit`
+
+Die App behandelt `full_distance_mm` als 100 Prozent und `empty_distance_mm` als 0 Prozent. Dabei ist es egal, ob kleinere oder groessere Zahlen "voll" bedeuten, weil die Berechnung die Sensororientierung automatisch beruecksichtigt.
+
+Die grobe Logik ist:
+
+```text
+fill_ratio = (distance_mm - empty_distance_mm) / (full_distance_mm - empty_distance_mm)
+fill_ratio wird auf 0..1 begrenzt
+max_units = abs(full_distance_mm - empty_distance_mm) / distance_per_unit
+units_estimated = round(fill_ratio * max_units)
+```
+
+Damit repraesentiert `distance_per_unit` die Aenderung des Messwerts pro Flasche oder Einheit.
+
+## API-Referenz
+
+Die kurze Uebersicht steht hier. Eine ausfuehrlichere Referenz liegt in [docs/API.md](docs/API.md).
+
+### `POST /api/v1/readings.php`
+
+Nimmt genau einen Messwert entgegen.
+
+Header:
+
+- `Authorization: Bearer <token>`
+- `Content-Type: application/json`
+
+Body:
+
+```json
+{
+  "machine_id": "automat-lobby",
+  "sensor_id": "fach-a1",
+  "distance_mm": 184,
+  "measured_at": "2026-04-15T19:20:00Z"
+}
+```
+
+`measured_at` ist optional. Wenn der Wert fehlt, setzt der Server die aktuelle Zeit.
+
+Beispiel:
+
+```bash
+curl -X POST http://localhost:8000/api/v1/readings.php \
+  -H 'Authorization: Bearer demo-esp32-token' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "machine_id": "automat-lobby",
+    "sensor_id": "fach-a1",
+    "distance_mm": 184,
+    "measured_at": "2026-04-15T19:20:00Z"
+  }'
+```
+
+Erfolg:
+
+```json
+{
+  "ok": true,
+  "machine_id": "automat-lobby",
+  "sensor_id": "fach-a1",
+  "slot_label": "A1",
+  "units_estimated": 4,
+  "fill_percent": 63,
+  "state": "ok"
+}
+```
+
+### `GET /api/v1/status.php`
+
+Liefert den aktuellen aggregierten Zustand aller Automaten und Faecher inklusive letzter Alarmereignisse.
+
+## Konfiguration
+
+Die Konfiguration liegt in `data/config.json`. Eine detaillierte Beschreibung aller Felder steht in [docs/CONFIG.md](docs/CONFIG.md).
+
+Die obersten Bereiche sind:
+
+- `app`: Name, Zeitzone, UI-Refresh, Email-Absender
+- `api`: Bearer-Token fuer Sensor-Clients
+- `admin`: Benutzername und Passwort-Hash
+- `alerts`: wiederverwendbare Webhooks und Email-Empfaenger
+- `machines`: Automaten mit ihren Faechern
+
+## Zustandsdateien
+
+### `data/state.json`
+
+Diese Datei enthaelt den letzten bekannten Zustand jedes Fachs. Sie wird von der API bei jeder gueltigen Messung aktualisiert.
+
+Typische Inhalte:
+
+- letzter Messwert
+- letzter Messzeitpunkt
+- geschaetzter Bestand
+- maximaler Bestand
+- aktueller Status
+- verknuepfte Alarmkanal-IDs
+
+### `data/alert_log.json`
+
+Diese Datei enthaelt die zuletzt ausgeloesten Alarmereignisse und Entwarnungen inklusive Lieferstatus fuer Webhooks und Emails.
+
+## JSON-Persistenz und Dateisperren
+
+Die Anwendung nutzt fuer Schreibzugriffe Dateisperren ueber `flock()`. Das reduziert das Risiko beschaedigter JSON-Dateien bei parallelen Requests, zum Beispiel wenn mehrere Sensoren fast gleichzeitig Messwerte senden.
+
+## Betriebshinweise
+
+- `data/` sollte nicht direkt oeffentlich ueber den Webserver auslieferbar sein.
+- Das Bearer-Token sollte nur an bekannte Sensor-Clients verteilt werden.
+- Die Beispiel-Zugangsdaten sollten in echten Umgebungen sofort ersetzt werden.
+- Webhooks sollten mit HTTPS betrieben werden.
+- Email funktioniert nur, wenn `mail()` auf dem Zielsystem korrekt eingerichtet ist.
+
+## Typische Erweiterungen fuer spaetere Versionen
+
+- Batch-Endpoint fuer mehrere Sensorwerte in einem Request
+- Langzeithistorie mit Zeitreihen oder Charts
+- Benutzerverwaltung statt statischem Admin-Login
+- Retry-Mechanismus fuer fehlgeschlagene Webhooks
+- Healthcheck-Endpunkte
+- CSV- oder PDF-Export
+
+## Weitere Dokumentation
+
+- [docs/API.md](docs/API.md)
+- [docs/CONFIG.md](docs/CONFIG.md)

+ 3 - 0
data/alert_log.json

@@ -0,0 +1,3 @@
+{
+    "events": []
+}

+ 93 - 0
data/config.json

@@ -0,0 +1,93 @@
+{
+    "app": {
+        "name": "Getraenkeautomat Monitor",
+        "timezone": "Europe/Berlin",
+        "dashboard_refresh_seconds": 15,
+        "default_from_email": "monitor@example.local"
+    },
+    "api": {
+        "bearer_token": "demo-esp32-token"
+    },
+    "admin": {
+        "username": "admin",
+        "password_hash": "$2y$10$LEpUKAxrLjJ3NOWridmw0.1D5JxnKvNONi9IkU5y4e6fPvO85/0em"
+    },
+    "alerts": {
+        "webhooks": [
+            {
+                "id": "lager-webhook",
+                "name": "Lager Webhook",
+                "url": "https://example.com/hooks/lager",
+                "enabled": false,
+                "headers": {
+                    "X-App-Token": "replace-me"
+                }
+            }
+        ],
+        "emails": [
+            {
+                "id": "lager-team",
+                "name": "Lager Team",
+                "address": "lager@example.com",
+                "enabled": true
+            }
+        ]
+    },
+    "machines": [
+        {
+            "id": "automat-lobby",
+            "name": "Lobby Automat",
+            "location": "Erdgeschoss",
+            "slots": [
+                {
+                    "sensor_id": "fach-a1",
+                    "label": "A1",
+                    "product_name": "Cola 0,5l",
+                    "full_distance_mm": 80,
+                    "empty_distance_mm": 360,
+                    "distance_per_unit": 40,
+                    "alert_below_units": 2,
+                    "webhook_ids": [
+                        "lager-webhook"
+                    ],
+                    "email_ids": [
+                        "lager-team"
+                    ]
+                },
+                {
+                    "sensor_id": "fach-a2",
+                    "label": "A2",
+                    "product_name": "Wasser still",
+                    "full_distance_mm": 75,
+                    "empty_distance_mm": 355,
+                    "distance_per_unit": 35,
+                    "alert_below_units": 3,
+                    "webhook_ids": [],
+                    "email_ids": [
+                        "lager-team"
+                    ]
+                }
+            ]
+        },
+        {
+            "id": "automat-kantine",
+            "name": "Kantinen Automat",
+            "location": "1. Stock",
+            "slots": [
+                {
+                    "sensor_id": "fach-b1",
+                    "label": "B1",
+                    "product_name": "Apfelschorle",
+                    "full_distance_mm": 90,
+                    "empty_distance_mm": 390,
+                    "distance_per_unit": 43,
+                    "alert_below_units": 2,
+                    "webhook_ids": [
+                        "lager-webhook"
+                    ],
+                    "email_ids": []
+                }
+            ]
+        }
+    ]
+}

+ 3 - 0
data/state.json

@@ -0,0 +1,3 @@
+{
+    "slots": {}
+}

+ 213 - 0
docs/API.md

@@ -0,0 +1,213 @@
+# API Reference
+
+Diese Datei beschreibt die HTTP-Schnittstellen der Anwendung.
+
+## Basis
+
+- API-Stil: REST-nah
+- Datenformat: JSON
+- Authentifizierung fuer eingehende Messwerte: Bearer-Token
+- Charset: UTF-8
+
+## Endpunkt: `POST /api/v1/readings.php`
+
+Nimmt genau einen Sensorwert fuer genau ein Fach entgegen.
+
+### Request-Header
+
+- `Authorization: Bearer <token>`
+- `Content-Type: application/json`
+
+### Request-Body
+
+```json
+{
+  "machine_id": "automat-lobby",
+  "sensor_id": "fach-a1",
+  "distance_mm": 184,
+  "measured_at": "2026-04-15T19:20:00Z"
+}
+```
+
+### Felder
+
+- `machine_id`
+  - Typ: String
+  - Pflichtfeld: ja
+  - Bedeutung: ID des Automaten aus `config.json`
+- `sensor_id`
+  - Typ: String
+  - Pflichtfeld: ja
+  - Bedeutung: ID des Sensors bzw. Fachs innerhalb des Automaten
+- `distance_mm`
+  - Typ: Zahl
+  - Pflichtfeld: ja
+  - Bedeutung: gemessene Entfernung in Millimetern
+- `measured_at`
+  - Typ: String
+  - Pflichtfeld: nein
+  - Bedeutung: Zeitstempel der Messung
+  - Format: ISO-8601-kompatibel empfohlen
+
+### Verhalten
+
+- Der Endpunkt akzeptiert nur `POST`.
+- Bei fehlendem oder falschem Bearer-Token antwortet die API mit `401`.
+- Bei ungueltigen Feldern antwortet die API mit `422`.
+- Wenn `machine_id` oder `sensor_id` nicht bekannt sind, antwortet die API mit `404`.
+- Bei erfolgreicher Verarbeitung wird `state.json` aktualisiert.
+- Bei einem Statuswechsel wird zusaetzlich Alarmierung ausgeloest.
+
+### Erfolgsantwort
+
+Status: `200 OK`
+
+```json
+{
+  "ok": true,
+  "machine_id": "automat-lobby",
+  "sensor_id": "fach-a1",
+  "slot_label": "A1",
+  "units_estimated": 4,
+  "fill_percent": 63,
+  "state": "ok"
+}
+```
+
+### Fehlerantworten
+
+#### Nicht autorisiert
+
+Status: `401 Unauthorized`
+
+```json
+{
+  "ok": false,
+  "error": "Nicht autorisiert."
+}
+```
+
+#### Unbekannter Sensor oder Automat
+
+Status: `404 Not Found`
+
+```json
+{
+  "ok": false,
+  "error": "Unbekannter Automat oder Sensor."
+}
+```
+
+#### Validierungsfehler
+
+Status: `422 Unprocessable Entity`
+
+```json
+{
+  "ok": false,
+  "error": "distance_mm muss numerisch sein."
+}
+```
+
+#### Falsche Methode
+
+Status: `405 Method Not Allowed`
+
+```json
+{
+  "ok": false,
+  "error": "Nur POST ist erlaubt."
+}
+```
+
+### Beispiel mit `curl`
+
+```bash
+curl -X POST http://localhost:8000/api/v1/readings.php \
+  -H 'Authorization: Bearer demo-esp32-token' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "machine_id": "automat-lobby",
+    "sensor_id": "fach-a1",
+    "distance_mm": 184
+  }'
+```
+
+## Endpunkt: `GET /api/v1/status.php`
+
+Liefert den aktuellen Zustand der gesamten Anwendung.
+
+### Verhalten
+
+- Der Endpunkt akzeptiert nur `GET`.
+- Es ist keine Authentifizierung implementiert.
+- Der Endpunkt wird vom Dashboard fuer die Auto-Aktualisierung genutzt.
+
+### Erfolgsantwort
+
+Status: `200 OK`
+
+Beispielstruktur:
+
+```json
+{
+  "ok": true,
+  "generated_at": "2026-04-15T20:10:00+00:00",
+  "app": {
+    "name": "Getraenkeautomat Monitor",
+    "dashboard_refresh_seconds": 15
+  },
+  "summary": {
+    "machine_count": 2,
+    "slot_count": 3,
+    "critical_count": 1
+  },
+  "machines": [
+    {
+      "id": "automat-lobby",
+      "name": "Lobby Automat",
+      "location": "Erdgeschoss",
+      "slots": []
+    }
+  ],
+  "alerts": []
+}
+```
+
+### Wichtige Felder in `machines[].slots[]`
+
+- `machine_id`
+- `machine_name`
+- `sensor_id`
+- `slot_label`
+- `product_name`
+- `fill_percent`
+- `units_estimated`
+- `max_units`
+- `distance_mm`
+- `state`
+- `measured_at`
+- `updated_at`
+- `alert_below_units`
+- `webhook_ids`
+- `email_ids`
+
+### Falsche Methode
+
+Status: `405 Method Not Allowed`
+
+```json
+{
+  "ok": false,
+  "error": "Nur GET ist erlaubt."
+}
+```
+
+## CORS und Preflight
+
+`readings.php` beantwortet `OPTIONS` mit `204 No Content` und gibt folgende Header aus:
+
+- `Access-Control-Allow-Methods: POST, OPTIONS`
+- `Access-Control-Allow-Headers: Authorization, Content-Type`
+
+Falls Sensor-Clients aus einem anderen Netzsegment oder ueber Browser-Tools senden, ist das hilfreich. Eine vollstaendige CORS-Konfiguration mit `Access-Control-Allow-Origin` ist aktuell noch nicht Teil der App.

+ 232 - 0
docs/CONFIG.md

@@ -0,0 +1,232 @@
+# Configuration Reference
+
+Die Anwendung wird ueber `data/config.json` konfiguriert.
+
+## Gesamtstruktur
+
+```json
+{
+  "app": {},
+  "api": {},
+  "admin": {},
+  "alerts": {
+    "webhooks": [],
+    "emails": []
+  },
+  "machines": []
+}
+```
+
+## Bereich `app`
+
+Beispiel:
+
+```json
+{
+  "name": "Getraenkeautomat Monitor",
+  "timezone": "Europe/Berlin",
+  "dashboard_refresh_seconds": 15,
+  "default_from_email": "monitor@example.local"
+}
+```
+
+Felder:
+
+- `name`
+  - Anzeigename der Anwendung im Dashboard
+- `timezone`
+  - Standardzeitzone fuer die Anwendung
+- `dashboard_refresh_seconds`
+  - Polling-Intervall fuer das Dashboard
+- `default_from_email`
+  - Absenderadresse fuer `mail()`
+
+## Bereich `api`
+
+Beispiel:
+
+```json
+{
+  "bearer_token": "demo-esp32-token"
+}
+```
+
+Felder:
+
+- `bearer_token`
+  - statisches Token fuer die Sensor-API
+  - wird von Clients im Authorization-Header gesendet
+
+## Bereich `admin`
+
+Beispiel:
+
+```json
+{
+  "username": "admin",
+  "password_hash": "$2y$10$..."
+}
+```
+
+Felder:
+
+- `username`
+  - Admin-Benutzername
+- `password_hash`
+  - bcrypt-Hash des Passworts
+  - wird vom Adminpanel automatisch erzeugt, wenn ein neues Passwort gespeichert wird
+
+## Bereich `alerts.webhooks`
+
+Beispiel:
+
+```json
+[
+  {
+    "id": "lager-webhook",
+    "name": "Lager Webhook",
+    "url": "https://example.com/hooks/lager",
+    "enabled": true,
+    "headers": {
+      "X-App-Token": "replace-me"
+    }
+  }
+]
+```
+
+Felder:
+
+- `id`
+  - technische Kennung
+  - wird von Slots referenziert
+- `name`
+  - Anzeigename im Adminpanel
+- `url`
+  - Zieladresse fuer den POST-Webhook
+- `enabled`
+  - aktiviert oder deaktiviert den Versand
+- `headers`
+  - optionales JSON-Objekt fuer zusaetzliche HTTP-Header
+
+## Bereich `alerts.emails`
+
+Beispiel:
+
+```json
+[
+  {
+    "id": "lager-team",
+    "name": "Lager Team",
+    "address": "lager@example.com",
+    "enabled": true
+  }
+]
+```
+
+Felder:
+
+- `id`
+  - technische Kennung
+  - wird von Slots referenziert
+- `name`
+  - Anzeigename im Adminpanel
+- `address`
+  - Zieladresse fuer Alarmmails
+- `enabled`
+  - aktiviert oder deaktiviert den Versand
+
+## Bereich `machines`
+
+Hier werden alle Automaten definiert.
+
+Beispiel:
+
+```json
+[
+  {
+    "id": "automat-lobby",
+    "name": "Lobby Automat",
+    "location": "Erdgeschoss",
+    "slots": []
+  }
+]
+```
+
+Felder:
+
+- `id`
+  - eindeutige Kennung des Automaten
+- `name`
+  - Anzeigename im Dashboard
+- `location`
+  - optionaler Standorttext
+- `slots`
+  - Liste der Faecher dieses Automaten
+
+## Bereich `machines[].slots`
+
+Jedes Fach repraesentiert einen Sensor und eine zugeordnete Produktposition.
+
+Beispiel:
+
+```json
+{
+  "sensor_id": "fach-a1",
+  "label": "A1",
+  "product_name": "Cola 0,5l",
+  "full_distance_mm": 80,
+  "empty_distance_mm": 360,
+  "distance_per_unit": 40,
+  "alert_below_units": 2,
+  "webhook_ids": ["lager-webhook"],
+  "email_ids": ["lager-team"]
+}
+```
+
+Felder:
+
+- `sensor_id`
+  - eindeutige Kennung des Sensors innerhalb des Automaten
+- `label`
+  - Kurzlabel fuer Anzeige und Alarmtexte
+- `product_name`
+  - Klartextbezeichnung des Produkts
+- `full_distance_mm`
+  - Sensorwert, der als "voll" gilt
+- `empty_distance_mm`
+  - Sensorwert, der als "leer" gilt
+- `distance_per_unit`
+  - Differenz im Messwert pro Flasche oder Einheit
+- `alert_below_units`
+  - kritische Schwelle
+- `webhook_ids`
+  - Liste von Webhook-IDs aus `alerts.webhooks`
+- `email_ids`
+  - Liste von Email-IDs aus `alerts.emails`
+
+## Kalibrierung eines Fachs
+
+Fuer eine brauchbare Bestandschaetzung sollten pro Fach mindestens diese Werte sauber kalibriert werden:
+
+1. Sensorwert bei vollem Fach messen und als `full_distance_mm` eintragen.
+2. Sensorwert bei leerem Fach messen und als `empty_distance_mm` eintragen.
+3. Mehrere Messungen zwischen zwei benachbarten Fuellstaenden machen.
+4. Die durchschnittliche Differenz pro Flasche als `distance_per_unit` eintragen.
+5. Einen passenden Schwellwert fuer `alert_below_units` festlegen.
+
+## Referenzen zwischen Bereichen
+
+Slots referenzieren Alarmziele nicht direkt ueber URLs oder Email-Adressen, sondern ueber IDs:
+
+- `webhook_ids` referenziert `alerts.webhooks[].id`
+- `email_ids` referenziert `alerts.emails[].id`
+
+Das macht die Konfiguration wartbarer, weil ein Alarmziel zentral geaendert werden kann.
+
+## Tipps fuer produktive Nutzung
+
+- IDs nur aus stabilen ASCII-Zeichen vergeben
+- `machine_id` und `sensor_id` niemals nachtraeglich leichtfertig aendern
+- fuer jeden neuen Sensor zuerst Kalibrierwerte eintragen
+- Testalarme mit einem absichtlich kritischen Messwert pruefen
+- Beispiel-Tokens und Default-Zugangsdaten vor dem Live-Betrieb ersetzen

+ 563 - 0
public/admin/index.php

@@ -0,0 +1,563 @@
+<?php
+
+declare(strict_types=1);
+
+require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
+
+$auth = app_admin_auth();
+$auth->start();
+$configRepository = app_config_repository();
+$message = null;
+$messageType = 'success';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+    $action = $_POST['action'] ?? '';
+
+    if ($action === 'login') {
+        $success = $auth->login(
+            trim((string) ($_POST['username'] ?? '')),
+            (string) ($_POST['password'] ?? '')
+        );
+
+        if ($success) {
+            app_redirect('/admin/');
+        }
+
+        $message = 'Login fehlgeschlagen. Bitte Zugangsdaten pruefen.';
+        $messageType = 'error';
+    }
+
+    if ($action === 'save_config' && $auth->isAuthenticated()) {
+        $config = $configRepository->getConfig();
+        $newPassword = trim((string) ($_POST['admin_password'] ?? ''));
+        $config['app'] = [
+            'name' => trim((string) ($_POST['app_name'] ?? 'Getraenkeautomat Monitor')),
+            'timezone' => trim((string) ($_POST['timezone'] ?? 'Europe/Berlin')),
+            'dashboard_refresh_seconds' => max(5, (int) ($_POST['dashboard_refresh_seconds'] ?? 15)),
+            'default_from_email' => trim((string) ($_POST['default_from_email'] ?? 'monitor@example.local')),
+        ];
+        $config['api'] = [
+            'bearer_token' => trim((string) ($_POST['bearer_token'] ?? 'change-me-token')),
+        ];
+        $config['admin'] = [
+            'username' => trim((string) ($_POST['admin_username'] ?? 'admin')),
+            'password_hash' => $newPassword !== ''
+                ? password_hash($newPassword, PASSWORD_BCRYPT)
+                : (string) ($config['admin']['password_hash'] ?? ''),
+        ];
+        $config['alerts'] = [
+            'webhooks' => normalizeWebhooks($_POST['webhooks'] ?? []),
+            'emails' => normalizeEmails($_POST['emails'] ?? []),
+        ];
+        $config['machines'] = normalizeMachines($_POST['machines'] ?? []);
+
+        $configRepository->saveConfig($config);
+        $message = 'Konfiguration gespeichert.';
+        $messageType = 'success';
+    }
+}
+
+$config = $configRepository->getConfig();
+
+if (!$auth->isAuthenticated()) {
+    renderLogin($message, $messageType);
+    exit;
+}
+
+renderAdmin($config, $message, $messageType);
+
+function renderLogin(?string $message, string $messageType): void
+{
+    ?>
+    <!DOCTYPE html>
+    <html lang="de">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>Adminpanel Login</title>
+        <link rel="stylesheet" href="/styles.css">
+    </head>
+    <body>
+        <main class="auth-page">
+            <section class="auth-card">
+                <p class="eyebrow">Adminbereich</p>
+                <h1>Konfiguration sichern</h1>
+                <p>Logge dich mit den statischen Zugangsdaten aus der JSON-Config ein.</p>
+                <?php if ($message !== null): ?>
+                    <div class="message message--<?= htmlspecialchars($messageType, ENT_QUOTES) ?>"><?= htmlspecialchars($message, ENT_QUOTES) ?></div>
+                <?php endif; ?>
+                <form method="post">
+                    <input type="hidden" name="action" value="login">
+                    <label>
+                        Benutzername
+                        <input type="text" name="username" required>
+                    </label>
+                    <label>
+                        Passwort
+                        <input type="password" name="password" required>
+                    </label>
+                    <button class="button button--primary" type="submit">Einloggen</button>
+                    <a class="button button--ghost" href="/">Zurueck zum Dashboard</a>
+                </form>
+            </section>
+        </main>
+    </body>
+    </html>
+    <?php
+}
+
+function renderAdmin(array $config, ?string $message, string $messageType): void
+{
+    ?>
+    <!DOCTYPE html>
+    <html lang="de">
+    <head>
+        <meta charset="UTF-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <title>Adminpanel</title>
+        <link rel="stylesheet" href="/styles.css">
+    </head>
+    <body>
+        <main class="admin-page">
+            <section class="admin-shell">
+                <div class="admin-header">
+                    <div>
+                        <p class="eyebrow">Adminpanel</p>
+                        <h1>Konfiguration der Automaten</h1>
+                        <p>Hier werden API-Token, Zugangsdaten, Faecher und Alarmwege direkt in der JSON-Config gepflegt.</p>
+                    </div>
+                    <div class="inline-actions">
+                        <a class="button button--ghost" href="/">Dashboard</a>
+                        <a class="button button--secondary" href="/admin/logout.php">Logout</a>
+                    </div>
+                </div>
+
+                <?php if ($message !== null): ?>
+                    <div class="message message--<?= htmlspecialchars($messageType, ENT_QUOTES) ?>"><?= htmlspecialchars($message, ENT_QUOTES) ?></div>
+                <?php endif; ?>
+
+                <form method="post" class="admin-form" id="admin-form">
+                    <input type="hidden" name="action" value="save_config">
+
+                    <section class="form-section">
+                        <h2>Allgemein</h2>
+                        <div class="field-grid">
+                            <label>
+                                App-Name
+                                <input type="text" name="app_name" value="<?= htmlspecialchars((string) ($config['app']['name'] ?? ''), ENT_QUOTES) ?>" required>
+                            </label>
+                            <label>
+                                Zeitzone
+                                <input type="text" name="timezone" value="<?= htmlspecialchars((string) ($config['app']['timezone'] ?? 'Europe/Berlin'), ENT_QUOTES) ?>" required>
+                            </label>
+                            <label>
+                                Auto-Refresh Sekunden
+                                <input type="number" min="5" name="dashboard_refresh_seconds" value="<?= (int) ($config['app']['dashboard_refresh_seconds'] ?? 15) ?>" required>
+                            </label>
+                            <label>
+                                Absender fuer Email
+                                <input type="email" name="default_from_email" value="<?= htmlspecialchars((string) ($config['app']['default_from_email'] ?? ''), ENT_QUOTES) ?>">
+                            </label>
+                        </div>
+                    </section>
+
+                    <section class="admin-grid">
+                        <section class="form-section">
+                            <h2>API</h2>
+                            <label>
+                                Bearer-Token
+                                <input type="text" name="bearer_token" value="<?= htmlspecialchars((string) ($config['api']['bearer_token'] ?? ''), ENT_QUOTES) ?>" required>
+                            </label>
+                            <p class="field-help">ESP32-Clients senden dieses Token im Header <code>Authorization: Bearer ...</code>.</p>
+                        </section>
+
+                        <section class="form-section">
+                            <h2>Adminzugang</h2>
+                            <div class="field-grid">
+                                <label>
+                                    Benutzername
+                                    <input type="text" name="admin_username" value="<?= htmlspecialchars((string) ($config['admin']['username'] ?? ''), ENT_QUOTES) ?>" required>
+                                </label>
+                                <label>
+                                    Neues Passwort
+                                    <input type="password" name="admin_password" placeholder="Leer lassen, um es nicht zu aendern">
+                                </label>
+                            </div>
+                        </section>
+                    </section>
+
+                    <section class="form-section">
+                        <div class="panel__header">
+                            <div>
+                                <h2>Webhooks</h2>
+                                <p>Mehrere Ziele sind moeglich. Header koennen als JSON hinterlegt werden.</p>
+                            </div>
+                            <button class="button button--secondary" type="button" data-add="webhook">Webhook hinzufuegen</button>
+                        </div>
+                        <div class="repeat-stack" id="webhook-stack">
+                            <?php foreach (($config['alerts']['webhooks'] ?? []) as $index => $webhook): ?>
+                                <?= renderWebhookRow((int) $index, $webhook) ?>
+                            <?php endforeach; ?>
+                        </div>
+                    </section>
+
+                    <section class="form-section">
+                        <div class="panel__header">
+                            <div>
+                                <h2>Email-Empfaenger</h2>
+                                <p>Emails werden ueber die Serverkonfiguration von <code>mail()</code> versendet.</p>
+                            </div>
+                            <button class="button button--secondary" type="button" data-add="email">Email-Empfaenger hinzufuegen</button>
+                        </div>
+                        <div class="repeat-stack" id="email-stack">
+                            <?php foreach (($config['alerts']['emails'] ?? []) as $index => $email): ?>
+                                <?= renderEmailRow((int) $index, $email) ?>
+                            <?php endforeach; ?>
+                        </div>
+                    </section>
+
+                    <section class="form-section">
+                        <div class="panel__header">
+                            <div>
+                                <h2>Automaten und Faecher</h2>
+                                <p>Jeder Automat enthaelt beliebig viele Faecher, die jeweils genau einem Sensor zugeordnet sind.</p>
+                            </div>
+                            <button class="button button--secondary" type="button" data-add="machine">Automat hinzufuegen</button>
+                        </div>
+                        <div class="repeat-stack" id="machine-stack">
+                            <?php foreach (($config['machines'] ?? []) as $machineIndex => $machine): ?>
+                                <?= renderMachineRow((int) $machineIndex, $machine) ?>
+                            <?php endforeach; ?>
+                        </div>
+                    </section>
+
+                    <div class="section-actions">
+                        <button class="button button--primary" type="submit">Konfiguration speichern</button>
+                    </div>
+                </form>
+            </section>
+        </main>
+
+        <template id="tpl-webhook"><?= renderWebhookRow('__INDEX__', []) ?></template>
+        <template id="tpl-email"><?= renderEmailRow('__INDEX__', []) ?></template>
+        <template id="tpl-machine"><?= renderMachineRow('__MACHINE__', []) ?></template>
+        <template id="tpl-slot"><?= renderSlotRow('__MACHINE__', '__SLOT__', []) ?></template>
+
+        <script>
+            const webhookStack = document.getElementById('webhook-stack');
+            const emailStack = document.getElementById('email-stack');
+            const machineStack = document.getElementById('machine-stack');
+
+            const renderTemplate = (id, replacements) => {
+              let html = document.getElementById(id).innerHTML;
+              Object.entries(replacements).forEach(([needle, value]) => {
+                html = html.replaceAll(needle, value);
+              });
+              return html;
+            };
+
+            const countChildren = (node, selector) => node.querySelectorAll(selector).length;
+
+            document.addEventListener('click', (event) => {
+              const addType = event.target.getAttribute('data-add');
+              const removeType = event.target.getAttribute('data-remove');
+
+              if (addType === 'webhook') {
+                const index = countChildren(webhookStack, '[data-webhook-row]');
+                webhookStack.insertAdjacentHTML('beforeend', renderTemplate('tpl-webhook', { '__INDEX__': index }));
+              }
+
+              if (addType === 'email') {
+                const index = countChildren(emailStack, '[data-email-row]');
+                emailStack.insertAdjacentHTML('beforeend', renderTemplate('tpl-email', { '__INDEX__': index }));
+              }
+
+              if (addType === 'machine') {
+                const index = countChildren(machineStack, '[data-machine-row]');
+                machineStack.insertAdjacentHTML('beforeend', renderTemplate('tpl-machine', { '__MACHINE__': index, '__SLOT__': 0 }));
+              }
+
+              if (addType === 'slot') {
+                const machineIndex = event.target.getAttribute('data-machine-index');
+                const container = document.querySelector(`[data-slot-stack="${machineIndex}"]`);
+                const slotIndex = countChildren(container, '[data-slot-row]');
+                container.insertAdjacentHTML('beforeend', renderTemplate('tpl-slot', {
+                  '__MACHINE__': machineIndex,
+                  '__SLOT__': slotIndex
+                }));
+              }
+
+              if (removeType) {
+                const row = event.target.closest(`[data-${removeType}-row]`);
+                if (row) {
+                  row.remove();
+                }
+              }
+            });
+        </script>
+    </body>
+    </html>
+    <?php
+}
+
+function renderWebhookRow(int|string $index, array $webhook): string
+{
+    $headers = $webhook['headers'] ?? [];
+    $headerJson = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+    ob_start();
+    ?>
+    <div class="repeat-card" data-webhook-row>
+        <div class="panel__header">
+            <h4>Webhook</h4>
+            <button class="button button--danger" type="button" data-remove="webhook">Entfernen</button>
+        </div>
+        <div class="field-grid">
+            <label>
+                ID
+                <input type="text" name="webhooks[<?= $index ?>][id]" value="<?= htmlspecialchars((string) ($webhook['id'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Name
+                <input type="text" name="webhooks[<?= $index ?>][name]" value="<?= htmlspecialchars((string) ($webhook['name'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                URL
+                <input type="url" name="webhooks[<?= $index ?>][url]" value="<?= htmlspecialchars((string) ($webhook['url'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Aktiv
+                <select name="webhooks[<?= $index ?>][enabled]">
+                    <option value="1" <?= !empty($webhook['enabled']) ? 'selected' : '' ?>>Ja</option>
+                    <option value="0" <?= empty($webhook['enabled']) ? 'selected' : '' ?>>Nein</option>
+                </select>
+            </label>
+        </div>
+        <label>
+            Header als JSON-Objekt
+            <textarea name="webhooks[<?= $index ?>][headers_json]"><?= htmlspecialchars((string) $headerJson, ENT_QUOTES) ?></textarea>
+        </label>
+    </div>
+    <?php
+    return (string) ob_get_clean();
+}
+
+function renderEmailRow(int|string $index, array $email): string
+{
+    ob_start();
+    ?>
+    <div class="repeat-card" data-email-row>
+        <div class="panel__header">
+            <h4>Email-Empfaenger</h4>
+            <button class="button button--danger" type="button" data-remove="email">Entfernen</button>
+        </div>
+        <div class="field-grid">
+            <label>
+                ID
+                <input type="text" name="emails[<?= $index ?>][id]" value="<?= htmlspecialchars((string) ($email['id'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Name
+                <input type="text" name="emails[<?= $index ?>][name]" value="<?= htmlspecialchars((string) ($email['name'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Adresse
+                <input type="email" name="emails[<?= $index ?>][address]" value="<?= htmlspecialchars((string) ($email['address'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Aktiv
+                <select name="emails[<?= $index ?>][enabled]">
+                    <option value="1" <?= !empty($email['enabled']) ? 'selected' : '' ?>>Ja</option>
+                    <option value="0" <?= empty($email['enabled']) ? 'selected' : '' ?>>Nein</option>
+                </select>
+            </label>
+        </div>
+    </div>
+    <?php
+    return (string) ob_get_clean();
+}
+
+function renderMachineRow(int|string $machineIndex, array $machine): string
+{
+    ob_start();
+    ?>
+    <div class="repeat-card" data-machine-row>
+        <div class="panel__header">
+            <div>
+                <h4>Automat</h4>
+                <p>Faecher koennen direkt darunter verwaltet werden.</p>
+            </div>
+            <div class="inline-actions">
+                <button class="button button--secondary" type="button" data-add="slot" data-machine-index="<?= htmlspecialchars((string) $machineIndex, ENT_QUOTES) ?>">Fach hinzufuegen</button>
+                <button class="button button--danger" type="button" data-remove="machine">Automat entfernen</button>
+            </div>
+        </div>
+        <div class="field-grid">
+            <label>
+                ID
+                <input type="text" name="machines[<?= $machineIndex ?>][id]" value="<?= htmlspecialchars((string) ($machine['id'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Name
+                <input type="text" name="machines[<?= $machineIndex ?>][name]" value="<?= htmlspecialchars((string) ($machine['name'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Standort
+                <input type="text" name="machines[<?= $machineIndex ?>][location]" value="<?= htmlspecialchars((string) ($machine['location'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+        </div>
+        <div class="repeat-stack" data-slot-stack="<?= htmlspecialchars((string) $machineIndex, ENT_QUOTES) ?>">
+            <?php foreach (($machine['slots'] ?? []) as $slotIndex => $slot): ?>
+                <?= renderSlotRow($machineIndex, (int) $slotIndex, $slot) ?>
+            <?php endforeach; ?>
+        </div>
+    </div>
+    <?php
+    return (string) ob_get_clean();
+}
+
+function renderSlotRow(int|string $machineIndex, int|string $slotIndex, array $slot): string
+{
+    $webhookIds = implode(',', $slot['webhook_ids'] ?? []);
+    $emailIds = implode(',', $slot['email_ids'] ?? []);
+
+    ob_start();
+    ?>
+    <div class="slot-editor" data-slot-row>
+        <div class="panel__header">
+            <h5>Fach</h5>
+            <button class="button button--danger" type="button" data-remove="slot">Fach entfernen</button>
+        </div>
+        <div class="field-grid">
+            <label>
+                Sensor-ID
+                <input type="text" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][sensor_id]" value="<?= htmlspecialchars((string) ($slot['sensor_id'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Label
+                <input type="text" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][label]" value="<?= htmlspecialchars((string) ($slot['label'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Produktname
+                <input type="text" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][product_name]" value="<?= htmlspecialchars((string) ($slot['product_name'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Voll-Distanz in mm
+                <input type="number" step="0.01" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][full_distance_mm]" value="<?= htmlspecialchars((string) ($slot['full_distance_mm'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Leer-Distanz in mm
+                <input type="number" step="0.01" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][empty_distance_mm]" value="<?= htmlspecialchars((string) ($slot['empty_distance_mm'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Distanz pro Flasche
+                <input type="number" step="0.01" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][distance_per_unit]" value="<?= htmlspecialchars((string) ($slot['distance_per_unit'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Alarm unter Bestand
+                <input type="number" step="1" min="0" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][alert_below_units]" value="<?= htmlspecialchars((string) ($slot['alert_below_units'] ?? ''), ENT_QUOTES) ?>">
+            </label>
+            <label>
+                Webhook-IDs
+                <input type="text" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][webhook_ids]" value="<?= htmlspecialchars((string) $webhookIds, ENT_QUOTES) ?>" placeholder="ops,lager">
+            </label>
+            <label>
+                Email-IDs
+                <input type="text" name="machines[<?= $machineIndex ?>][slots][<?= $slotIndex ?>][email_ids]" value="<?= htmlspecialchars((string) $emailIds, ENT_QUOTES) ?>" placeholder="lager,service">
+            </label>
+        </div>
+    </div>
+    <?php
+    return (string) ob_get_clean();
+}
+
+function normalizeWebhooks(array $rows): array
+{
+    $items = [];
+    foreach ($rows as $row) {
+        $id = trim((string) ($row['id'] ?? ''));
+        $url = trim((string) ($row['url'] ?? ''));
+        if ($id === '' && $url === '') {
+            continue;
+        }
+
+        $headers = json_decode((string) ($row['headers_json'] ?? '{}'), true);
+        $items[] = [
+            'id' => $id,
+            'name' => trim((string) ($row['name'] ?? '')),
+            'url' => $url,
+            'enabled' => (string) ($row['enabled'] ?? '1') === '1',
+            'headers' => is_array($headers) ? $headers : [],
+        ];
+    }
+
+    return array_values($items);
+}
+
+function normalizeEmails(array $rows): array
+{
+    $items = [];
+    foreach ($rows as $row) {
+        $id = trim((string) ($row['id'] ?? ''));
+        $address = trim((string) ($row['address'] ?? ''));
+        if ($id === '' && $address === '') {
+            continue;
+        }
+
+        $items[] = [
+            'id' => $id,
+            'name' => trim((string) ($row['name'] ?? '')),
+            'address' => $address,
+            'enabled' => (string) ($row['enabled'] ?? '1') === '1',
+        ];
+    }
+
+    return array_values($items);
+}
+
+function normalizeMachines(array $rows): array
+{
+    $items = [];
+    foreach ($rows as $row) {
+        $id = trim((string) ($row['id'] ?? ''));
+        $name = trim((string) ($row['name'] ?? ''));
+        if ($id === '' && $name === '') {
+            continue;
+        }
+
+        $slots = [];
+        foreach (($row['slots'] ?? []) as $slot) {
+            $sensorId = trim((string) ($slot['sensor_id'] ?? ''));
+            if ($sensorId === '') {
+                continue;
+            }
+
+            $slots[] = [
+                'sensor_id' => $sensorId,
+                'label' => trim((string) ($slot['label'] ?? $sensorId)),
+                'product_name' => trim((string) ($slot['product_name'] ?? '')),
+                'full_distance_mm' => (float) ($slot['full_distance_mm'] ?? 0),
+                'empty_distance_mm' => (float) ($slot['empty_distance_mm'] ?? 0),
+                'distance_per_unit' => max(0.01, (float) ($slot['distance_per_unit'] ?? 1)),
+                'alert_below_units' => max(0, (int) ($slot['alert_below_units'] ?? 0)),
+                'webhook_ids' => parseIdList((string) ($slot['webhook_ids'] ?? '')),
+                'email_ids' => parseIdList((string) ($slot['email_ids'] ?? '')),
+            ];
+        }
+
+        $items[] = [
+            'id' => $id,
+            'name' => $name,
+            'location' => trim((string) ($row['location'] ?? '')),
+            'slots' => $slots,
+        ];
+    }
+
+    return array_values($items);
+}
+
+function parseIdList(string $value): array
+{
+    $parts = array_map('trim', explode(',', $value));
+    return array_values(array_filter($parts, static fn (string $item): bool => $item !== ''));
+}

+ 8 - 0
public/admin/logout.php

@@ -0,0 +1,8 @@
+<?php
+
+declare(strict_types=1);
+
+require_once dirname(__DIR__, 2) . '/src/bootstrap.php';
+
+app_admin_auth()->logout();
+app_redirect('/admin/');

+ 61 - 0
public/api/v1/readings.php

@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+require_once dirname(__DIR__, 3) . '/src/bootstrap.php';
+
+header('Access-Control-Allow-Methods: POST, OPTIONS');
+header('Access-Control-Allow-Headers: Authorization, Content-Type');
+
+if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
+    http_response_code(204);
+    exit;
+}
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    app_json_response([
+        'ok' => false,
+        'error' => 'Nur POST ist erlaubt.',
+    ], 405);
+}
+
+$config = app_config_repository()->getConfig();
+$expectedToken = (string) ($config['api']['bearer_token'] ?? '');
+$authHeader = $_SERVER['HTTP_AUTHORIZATION']
+    ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
+    ?? '';
+
+if ($authHeader === '' && function_exists('getallheaders')) {
+    $headers = getallheaders();
+    $authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
+}
+
+if (preg_match('/Bearer\s+(.+)/i', $authHeader, $matches) !== 1 || !hash_equals($expectedToken, trim($matches[1]))) {
+    app_json_response([
+        'ok' => false,
+        'error' => 'Nicht autorisiert.',
+    ], 401);
+}
+
+try {
+    $payload = app_read_json_body();
+    $result = app_monitor_service()->processReading($payload);
+    app_json_response($result);
+} catch (InvalidArgumentException $exception) {
+    $message = $exception->getMessage();
+    $status = $message === 'Unbekannter Automat oder Sensor.' ? 404 : 422;
+    app_json_response([
+        'ok' => false,
+        'error' => $message,
+    ], $status);
+} catch (RuntimeException $exception) {
+    app_json_response([
+        'ok' => false,
+        'error' => $exception->getMessage(),
+    ], 400);
+} catch (Throwable $exception) {
+    app_json_response([
+        'ok' => false,
+        'error' => 'Interner Fehler.',
+    ], 500);
+}

+ 14 - 0
public/api/v1/status.php

@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+require_once dirname(__DIR__, 3) . '/src/bootstrap.php';
+
+if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+    app_json_response([
+        'ok' => false,
+        'error' => 'Nur GET ist erlaubt.',
+    ], 405);
+}
+
+app_json_response(app_monitor_service()->getStatus());

+ 193 - 0
public/app.js

@@ -0,0 +1,193 @@
+const dataNode = document.getElementById('initial-status');
+
+if (dataNode) {
+  let currentStatus = JSON.parse(dataNode.textContent || '{}');
+  let activeMachine = 'all';
+  const gridNode = document.getElementById('machine-grid');
+  const filterNode = document.getElementById('machine-filter');
+  const alertNode = document.getElementById('alert-list');
+  const generatedAtNode = document.getElementById('generated-at');
+
+  const escapeHtml = (value) =>
+    String(value)
+      .replaceAll('&', '&amp;')
+      .replaceAll('<', '&lt;')
+      .replaceAll('>', '&gt;')
+      .replaceAll('"', '&quot;')
+      .replaceAll("'", '&#039;');
+
+  const formatTime = (isoValue) => {
+    if (!isoValue) {
+      return 'Noch keine Messung';
+    }
+
+    const parsed = new Date(isoValue);
+    if (Number.isNaN(parsed.getTime())) {
+      return isoValue;
+    }
+
+    return parsed.toLocaleString('de-DE', {
+      day: '2-digit',
+      month: '2-digit',
+      year: 'numeric',
+      hour: '2-digit',
+      minute: '2-digit',
+    });
+  };
+
+  const renderFilters = () => {
+    const machines = currentStatus.machines || [];
+    const buttons = [
+      `<button class="chip ${activeMachine === 'all' ? 'chip--active' : ''}" data-machine="all">Alle</button>`,
+      ...machines.map(
+        (machine) =>
+          `<button class="chip ${activeMachine === machine.id ? 'chip--active' : ''}" data-machine="${escapeHtml(
+            machine.id
+          )}">${escapeHtml(machine.name)}</button>`
+      ),
+    ];
+
+    filterNode.innerHTML = buttons.join('');
+  };
+
+  const slotCard = (slot) => {
+    const fillPercent = slot.fill_percent ?? 0;
+    const units = slot.units_estimated ?? '–';
+    const maxUnits = slot.max_units ?? '–';
+    const state = slot.state || 'unknown';
+    const stateLabel =
+      state === 'critical' ? 'Kritisch' : state === 'ok' ? 'Stabil' : 'Unbekannt';
+
+    return `
+      <article class="slot-card slot-card--${escapeHtml(state)}">
+        <div class="slot-card__head">
+          <div>
+            <p class="slot-card__label">${escapeHtml(slot.slot_label || slot.sensor_id)}</p>
+            <h3>${escapeHtml(slot.product_name || 'Nicht zugeordnet')}</h3>
+          </div>
+          <span class="status-pill status-pill--${escapeHtml(state)}">${stateLabel}</span>
+        </div>
+        <div class="slot-card__body">
+          <div class="fill-tube" style="--fill:${fillPercent}%">
+            <div class="fill-tube__liquid" style="height:${fillPercent}%"></div>
+            <div class="fill-tube__gloss"></div>
+          </div>
+          <div class="slot-card__metrics">
+            <p><strong>${fillPercent}%</strong> Fuellstand</p>
+            <p><strong>${units}</strong> / ${maxUnits} Flaschen</p>
+            <p>Alarm unter <strong>${slot.alert_below_units ?? 0}</strong></p>
+            <p>Messwert: <strong>${slot.distance_mm ?? '–'} mm</strong></p>
+            <p>Update: <strong>${formatTime(slot.measured_at)}</strong></p>
+          </div>
+        </div>
+      </article>
+    `;
+  };
+
+  const renderMachines = () => {
+    const machines = (currentStatus.machines || []).filter(
+      (machine) => activeMachine === 'all' || machine.id === activeMachine
+    );
+
+    if (!machines.length) {
+      gridNode.innerHTML = '<p class="empty-state">Keine Automaten fuer die aktuelle Auswahl gefunden.</p>';
+      return;
+    }
+
+    gridNode.innerHTML = machines
+      .map(
+        (machine) => `
+          <section class="machine-panel">
+            <div class="machine-panel__head">
+              <div>
+                <p class="eyebrow">Automat</p>
+                <h2>${escapeHtml(machine.name)}</h2>
+              </div>
+              <p>${escapeHtml(machine.location || 'Kein Standort hinterlegt')}</p>
+            </div>
+            <div class="slot-grid">
+              ${(machine.slots || []).map(slotCard).join('')}
+            </div>
+          </section>
+        `
+      )
+      .join('');
+  };
+
+  const renderAlerts = () => {
+    const alerts = currentStatus.alerts || [];
+    if (!alerts.length) {
+      alertNode.innerHTML =
+        '<p class="empty-state">Noch keine Zustandswechsel registriert.</p>';
+      return;
+    }
+
+    alertNode.innerHTML = alerts
+      .slice(0, 12)
+      .map((entry) => {
+        const payload = entry.payload || {};
+        const stateClass = payload.event === 'critical' ? 'critical' : 'ok';
+        const stateText = payload.event === 'critical' ? 'Alarm' : 'Entwarnung';
+        return `
+          <article class="alert-entry alert-entry--${escapeHtml(stateClass)}">
+            <div>
+              <p class="alert-entry__title">${stateText}: ${escapeHtml(
+                payload.machine_name || payload.machine_id || 'Automat'
+              )} / ${escapeHtml(payload.slot_label || payload.sensor_id || 'Fach')}</p>
+              <p>${escapeHtml(payload.product_name || 'Ohne Produktname')} • Bestand ${
+                payload.units_estimated ?? '–'
+              } / ${payload.max_units ?? '–'} • ${payload.fill_percent ?? '–'}%</p>
+            </div>
+            <time>${formatTime(entry.created_at)}</time>
+          </article>
+        `;
+      })
+      .join('');
+  };
+
+  const updateSummary = () => {
+    const summary = currentStatus.summary || {};
+    Object.entries(summary).forEach(([key, value]) => {
+      const node = document.querySelector(`[data-summary="${key}"]`);
+      if (node) {
+        node.textContent = value;
+      }
+    });
+    if (generatedAtNode) {
+      generatedAtNode.textContent = formatTime(currentStatus.generated_at);
+    }
+  };
+
+  const render = () => {
+    renderFilters();
+    renderMachines();
+    renderAlerts();
+    updateSummary();
+  };
+
+  filterNode.addEventListener('click', (event) => {
+    const target = event.target.closest('[data-machine]');
+    if (!target) {
+      return;
+    }
+    activeMachine = target.dataset.machine || 'all';
+    render();
+  });
+
+  const refresh = async () => {
+    try {
+      const response = await fetch('/api/v1/status.php', { cache: 'no-store' });
+      if (!response.ok) {
+        return;
+      }
+      currentStatus = await response.json();
+      render();
+    } catch (error) {
+      console.error('Status-Aktualisierung fehlgeschlagen', error);
+    }
+  };
+
+  render();
+  const refreshSeconds = currentStatus.app?.dashboard_refresh_seconds || 15;
+  window.setInterval(refresh, refreshSeconds * 1000);
+}

+ 83 - 0
public/index.php

@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+require_once dirname(__DIR__) . '/src/bootstrap.php';
+
+$status = app_monitor_service()->getStatus();
+$appName = $status['app']['name'] ?? 'Getraenkeautomat Monitor';
+?>
+<!DOCTYPE html>
+<html lang="de">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title><?= htmlspecialchars($appName, ENT_QUOTES) ?></title>
+    <link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+    <div class="page-shell">
+        <header class="hero">
+            <div class="hero__copy">
+                <p class="eyebrow">Fuellstand live im Blick</p>
+                <h1><?= htmlspecialchars($appName, ENT_QUOTES) ?></h1>
+                <p class="hero__lede">
+                    Ueberwache Sensorwerte, erkenne Leerstaende fruehzeitig und springe direkt ins Adminpanel, wenn sich Grenzwerte oder Alarmwege aendern sollen.
+                </p>
+            </div>
+            <div class="hero__stats">
+                <div class="stat-card">
+                    <span>Automaten</span>
+                    <strong data-summary="machine_count"><?= (int) ($status['summary']['machine_count'] ?? 0) ?></strong>
+                </div>
+                <div class="stat-card">
+                    <span>Faecher</span>
+                    <strong data-summary="slot_count"><?= (int) ($status['summary']['slot_count'] ?? 0) ?></strong>
+                </div>
+                <div class="stat-card stat-card--alert">
+                    <span>Kritisch</span>
+                    <strong data-summary="critical_count"><?= (int) ($status['summary']['critical_count'] ?? 0) ?></strong>
+                </div>
+            </div>
+            <div class="hero__actions">
+                <a class="button button--primary" href="/admin/">Adminpanel</a>
+                <a class="button button--ghost" href="/api/v1/status.php" target="_blank" rel="noreferrer">Status JSON</a>
+            </div>
+        </header>
+
+        <main class="dashboard">
+            <section class="panel panel--controls">
+                <div>
+                    <h2>Automatenansicht</h2>
+                    <p>Filtere einzelne Automaten oder beobachte den Gesamtzustand auf einen Blick.</p>
+                </div>
+                <div class="chip-row" id="machine-filter"></div>
+            </section>
+
+            <section class="panel">
+                <div class="panel__header">
+                    <div>
+                        <h2>Fuellstand nach Fach</h2>
+                        <p>Die Karten zeigen Messwert, geschaetzten Bestand und den Alarmstatus pro Sensor.</p>
+                    </div>
+                    <p class="timestamp">Letzte Aktualisierung: <span id="generated-at"><?= htmlspecialchars($status['generated_at'], ENT_QUOTES) ?></span></p>
+                </div>
+                <div id="machine-grid" class="machine-grid"></div>
+            </section>
+
+            <section class="panel">
+                <div class="panel__header">
+                    <div>
+                        <h2>Letzte Alarmereignisse</h2>
+                        <p>Es werden nur Zustandswechsel geloggt, keine wiederholten Daueralarme.</p>
+                    </div>
+                </div>
+                <div id="alert-list" class="alert-list"></div>
+            </section>
+        </main>
+    </div>
+
+    <script id="initial-status" type="application/json"><?= json_encode($status, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?></script>
+    <script src="/app.js" defer></script>
+</body>
+</html>

+ 546 - 0
public/styles.css

@@ -0,0 +1,546 @@
+:root {
+  --ink: #1d1e1a;
+  --paper: #f6f1e8;
+  --sand: #d8c6a5;
+  --amber: #e0a33b;
+  --amber-deep: #b86915;
+  --teal: #0f6c78;
+  --teal-soft: #8fd4d2;
+  --alert: #b3362d;
+  --ok: #2f7d4d;
+  --panel: rgba(255, 248, 237, 0.84);
+  --line: rgba(29, 30, 26, 0.12);
+  --shadow: 0 24px 60px rgba(60, 42, 12, 0.18);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  color: var(--ink);
+  font-family: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif;
+  background:
+    radial-gradient(circle at top left, rgba(224, 163, 59, 0.32), transparent 28%),
+    radial-gradient(circle at right 20%, rgba(15, 108, 120, 0.18), transparent 24%),
+    linear-gradient(180deg, #efe4cf 0%, #f7f2ea 38%, #f3ebdf 100%);
+}
+
+body::before {
+  content: "";
+  position: fixed;
+  inset: 0;
+  pointer-events: none;
+  background-image:
+    linear-gradient(rgba(29, 30, 26, 0.03) 1px, transparent 1px),
+    linear-gradient(90deg, rgba(29, 30, 26, 0.03) 1px, transparent 1px);
+  background-size: 28px 28px;
+  mask-image: radial-gradient(circle at center, black 45%, transparent 100%);
+}
+
+a {
+  color: inherit;
+}
+
+.page-shell {
+  width: min(1240px, calc(100% - 2rem));
+  margin: 0 auto;
+  padding: 2rem 0 3rem;
+}
+
+.hero,
+.panel,
+.machine-panel,
+.auth-card,
+.admin-shell {
+  position: relative;
+  overflow: hidden;
+  background: var(--panel);
+  border: 1px solid rgba(255, 255, 255, 0.55);
+  border-radius: 28px;
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(16px);
+}
+
+.hero {
+  display: grid;
+  grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
+  gap: 1.5rem;
+  padding: 2rem;
+  margin-bottom: 1.5rem;
+}
+
+.hero__actions {
+  grid-column: 1 / -1;
+  display: flex;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+}
+
+.hero__lede,
+.panel p,
+.slot-card__metrics p,
+.admin-shell p,
+.field-help,
+.auth-card p {
+  color: rgba(29, 30, 26, 0.72);
+  line-height: 1.55;
+}
+
+.eyebrow {
+  margin: 0 0 0.35rem;
+  letter-spacing: 0.14em;
+  text-transform: uppercase;
+  font-size: 0.75rem;
+  color: rgba(29, 30, 26, 0.52);
+}
+
+h1,
+h2,
+h3 {
+  margin: 0;
+  font-family: "Gill Sans", "Trebuchet MS", sans-serif;
+  font-weight: 700;
+}
+
+h1 {
+  font-size: clamp(2.3rem, 5vw, 4.4rem);
+  line-height: 0.95;
+  max-width: 11ch;
+}
+
+h2 {
+  font-size: 1.45rem;
+}
+
+.hero__stats {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 0.75rem;
+  align-content: start;
+}
+
+.stat-card {
+  padding: 1rem;
+  border-radius: 22px;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.35));
+  border: 1px solid rgba(29, 30, 26, 0.08);
+}
+
+.stat-card strong {
+  display: block;
+  margin-top: 0.35rem;
+  font-size: 2rem;
+}
+
+.stat-card--alert strong {
+  color: var(--alert);
+}
+
+.button,
+button {
+  appearance: none;
+  border: 0;
+  cursor: pointer;
+  text-decoration: none;
+  font: inherit;
+}
+
+.button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 46px;
+  padding: 0.8rem 1.15rem;
+  border-radius: 999px;
+  transition: transform 140ms ease, box-shadow 140ms ease, background 140ms ease;
+}
+
+.button:hover,
+button:hover {
+  transform: translateY(-1px);
+}
+
+.button--primary {
+  color: white;
+  background: linear-gradient(135deg, var(--amber-deep), var(--amber));
+  box-shadow: 0 12px 22px rgba(184, 105, 21, 0.28);
+}
+
+.button--ghost {
+  background: rgba(255, 255, 255, 0.52);
+  border: 1px solid rgba(29, 30, 26, 0.08);
+}
+
+.dashboard {
+  display: grid;
+  gap: 1.5rem;
+}
+
+.panel {
+  padding: 1.5rem;
+}
+
+.panel__header,
+.machine-panel__head,
+.admin-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 1rem;
+}
+
+.panel--controls {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 1rem;
+  flex-wrap: wrap;
+}
+
+.chip-row {
+  display: flex;
+  gap: 0.6rem;
+  flex-wrap: wrap;
+}
+
+.chip {
+  min-height: 40px;
+  padding: 0.65rem 0.95rem;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.68);
+  color: rgba(29, 30, 26, 0.72);
+  border: 1px solid rgba(29, 30, 26, 0.08);
+}
+
+.chip--active {
+  background: linear-gradient(135deg, var(--teal), #124f58);
+  color: white;
+}
+
+.machine-grid,
+.slot-grid {
+  display: grid;
+  gap: 1rem;
+}
+
+.machine-grid {
+  grid-template-columns: 1fr;
+}
+
+.machine-panel {
+  padding: 1.4rem;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(239, 228, 207, 0.55));
+}
+
+.slot-grid {
+  margin-top: 1rem;
+  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
+}
+
+.slot-card {
+  display: grid;
+  gap: 1rem;
+  padding: 1rem;
+  border-radius: 24px;
+  border: 1px solid var(--line);
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.slot-card--critical {
+  background: linear-gradient(180deg, rgba(255, 234, 233, 0.96), rgba(255, 246, 238, 0.9));
+  border-color: rgba(179, 54, 45, 0.18);
+}
+
+.slot-card--ok {
+  background: linear-gradient(180deg, rgba(237, 252, 245, 0.92), rgba(255, 255, 255, 0.78));
+  border-color: rgba(47, 125, 77, 0.14);
+}
+
+.slot-card__head,
+.slot-card__body {
+  display: flex;
+  gap: 1rem;
+  justify-content: space-between;
+}
+
+.slot-card__label {
+  margin: 0 0 0.25rem;
+  font-size: 0.76rem;
+  text-transform: uppercase;
+  letter-spacing: 0.12em;
+  color: rgba(29, 30, 26, 0.5);
+}
+
+.status-pill {
+  display: inline-flex;
+  align-items: center;
+  height: fit-content;
+  padding: 0.4rem 0.7rem;
+  border-radius: 999px;
+  font-size: 0.85rem;
+}
+
+.status-pill--critical {
+  background: rgba(179, 54, 45, 0.14);
+  color: var(--alert);
+}
+
+.status-pill--ok {
+  background: rgba(47, 125, 77, 0.14);
+  color: var(--ok);
+}
+
+.status-pill--unknown {
+  background: rgba(29, 30, 26, 0.08);
+  color: rgba(29, 30, 26, 0.62);
+}
+
+.fill-tube {
+  position: relative;
+  width: 82px;
+  min-width: 82px;
+  height: 190px;
+  border-radius: 32px;
+  padding: 8px;
+  background: linear-gradient(180deg, rgba(29, 30, 26, 0.08), rgba(255, 255, 255, 0.72));
+  border: 1px solid rgba(29, 30, 26, 0.08);
+  overflow: hidden;
+}
+
+.fill-tube__liquid {
+  position: absolute;
+  inset: auto 8px 8px;
+  border-radius: 24px;
+  background: linear-gradient(180deg, var(--amber), #f2d785 60%, #fee6b7);
+  box-shadow: inset 0 12px 24px rgba(255, 255, 255, 0.34);
+}
+
+.slot-card--critical .fill-tube__liquid {
+  background: linear-gradient(180deg, #d55b51, #f4a38e 62%, #ffd8d1);
+}
+
+.slot-card--ok .fill-tube__liquid {
+  background: linear-gradient(180deg, var(--teal), var(--teal-soft) 62%, #e2f7f5);
+}
+
+.fill-tube__gloss {
+  position: absolute;
+  top: 14px;
+  left: 16px;
+  width: 18px;
+  height: 120px;
+  border-radius: 999px;
+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.75), transparent);
+}
+
+.slot-card__metrics {
+  display: grid;
+  gap: 0.25rem;
+  align-content: start;
+}
+
+.slot-card__metrics strong,
+.timestamp {
+  color: var(--ink);
+}
+
+.alert-list {
+  display: grid;
+  gap: 0.8rem;
+}
+
+.alert-entry {
+  display: flex;
+  justify-content: space-between;
+  gap: 1rem;
+  align-items: center;
+  padding: 1rem 1.15rem;
+  border-radius: 20px;
+  background: rgba(255, 255, 255, 0.64);
+  border: 1px solid var(--line);
+}
+
+.alert-entry--critical {
+  border-color: rgba(179, 54, 45, 0.2);
+}
+
+.alert-entry--ok {
+  border-color: rgba(47, 125, 77, 0.18);
+}
+
+.alert-entry__title {
+  margin: 0 0 0.2rem;
+  font-weight: 700;
+}
+
+.empty-state {
+  margin: 0;
+  padding: 1rem;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.55);
+}
+
+.auth-page,
+.admin-page {
+  width: min(1180px, calc(100% - 2rem));
+  margin: 0 auto;
+  padding: 2rem 0 3rem;
+}
+
+.auth-card {
+  width: min(520px, 100%);
+  margin: 8vh auto 0;
+  padding: 2rem;
+}
+
+.auth-card form,
+.admin-form {
+  display: grid;
+  gap: 1rem;
+}
+
+.admin-shell {
+  padding: 1.5rem;
+  display: grid;
+  gap: 1.5rem;
+}
+
+.admin-grid {
+  display: grid;
+  gap: 1rem;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.form-section {
+  padding: 1.2rem;
+  border-radius: 22px;
+  background: rgba(255, 255, 255, 0.64);
+  border: 1px solid var(--line);
+}
+
+.field-grid {
+  display: grid;
+  gap: 0.9rem;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+}
+
+label {
+  display: grid;
+  gap: 0.35rem;
+  font-weight: 600;
+  font-size: 0.95rem;
+}
+
+input,
+textarea,
+select {
+  width: 100%;
+  min-height: 44px;
+  padding: 0.7rem 0.85rem;
+  border-radius: 14px;
+  border: 1px solid rgba(29, 30, 26, 0.14);
+  background: rgba(255, 255, 255, 0.85);
+  color: var(--ink);
+  font: inherit;
+}
+
+textarea {
+  min-height: 90px;
+  resize: vertical;
+}
+
+.inline-actions,
+.section-actions {
+  display: flex;
+  gap: 0.75rem;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.button--secondary {
+  background: rgba(15, 108, 120, 0.14);
+  color: var(--teal);
+}
+
+.button--danger {
+  background: rgba(179, 54, 45, 0.12);
+  color: var(--alert);
+}
+
+.message {
+  padding: 0.9rem 1rem;
+  border-radius: 18px;
+  font-weight: 600;
+}
+
+.message--success {
+  background: rgba(47, 125, 77, 0.12);
+  color: var(--ok);
+}
+
+.message--error {
+  background: rgba(179, 54, 45, 0.12);
+  color: var(--alert);
+}
+
+.repeat-stack {
+  display: grid;
+  gap: 1rem;
+}
+
+.repeat-card,
+.slot-editor {
+  padding: 1rem;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.76);
+  border: 1px solid var(--line);
+}
+
+.repeat-card h4,
+.slot-editor h5 {
+  margin: 0 0 0.85rem;
+  font-family: "Gill Sans", "Trebuchet MS", sans-serif;
+}
+
+.slot-editor h5 {
+  font-size: 1rem;
+}
+
+.field-help code {
+  font-family: "Courier New", monospace;
+}
+
+@media (max-width: 820px) {
+  .hero,
+  .panel__header,
+  .machine-panel__head,
+  .admin-header,
+  .alert-entry,
+  .slot-card__body {
+    grid-template-columns: 1fr;
+    flex-direction: column;
+  }
+
+  .hero__stats {
+    grid-template-columns: 1fr;
+  }
+
+  .fill-tube {
+    width: 100%;
+    height: 42px;
+    min-width: 0;
+  }
+
+  .fill-tube__liquid {
+    inset: 8px auto 8px 8px;
+    height: auto !important;
+    width: max(0px, calc(var(--fill, 0%) - 16px));
+  }
+
+  .fill-tube__gloss {
+    display: none;
+  }
+}

+ 48 - 0
src/AdminAuth.php

@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+final class AdminAuth
+{
+    public function __construct(private readonly ConfigRepository $configRepository)
+    {
+    }
+
+    public function start(): void
+    {
+        if (session_status() === PHP_SESSION_NONE) {
+            session_name('drink_monitor_admin');
+            session_start();
+        }
+    }
+
+    public function login(string $username, string $password): bool
+    {
+        $this->start();
+        $config = $this->configRepository->getConfig();
+        $validUser = hash_equals((string) ($config['admin']['username'] ?? ''), $username);
+        $validPassword = password_verify($password, (string) ($config['admin']['password_hash'] ?? ''));
+
+        if ($validUser && $validPassword) {
+            $_SESSION['admin_authenticated'] = true;
+            return true;
+        }
+
+        return false;
+    }
+
+    public function isAuthenticated(): bool
+    {
+        $this->start();
+        return (bool) ($_SESSION['admin_authenticated'] ?? false);
+    }
+
+    public function logout(): void
+    {
+        $this->start();
+        $_SESSION = [];
+        session_destroy();
+    }
+}

+ 184 - 0
src/AlertService.php

@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+final class AlertService
+{
+    private JsonFile $logStorage;
+
+    public function __construct(
+        private readonly ConfigRepository $configRepository,
+        string $logPath
+    ) {
+        $this->logStorage = new JsonFile($logPath, [
+            'events' => [],
+        ]);
+    }
+
+    public function handleTransition(
+        array $machine,
+        array $slot,
+        array $slotState,
+        ?string $previousState
+    ): void {
+        $currentState = $slotState['state'] ?? 'unknown';
+        if ($previousState === $currentState) {
+            return;
+        }
+
+        if ($currentState === 'critical' && $previousState !== 'critical') {
+            $eventType = 'critical';
+        } elseif ($currentState === 'ok' && $previousState === 'critical') {
+            $eventType = 'recovered';
+        } else {
+            return;
+        }
+
+        $payload = [
+            'event' => $eventType,
+            'machine_id' => $machine['id'] ?? '',
+            'machine_name' => $machine['name'] ?? '',
+            'sensor_id' => $slot['sensor_id'] ?? '',
+            'slot_label' => $slot['label'] ?? '',
+            'product_name' => $slot['product_name'] ?? '',
+            'distance_mm' => $slotState['distance_mm'] ?? null,
+            'units_estimated' => $slotState['units_estimated'] ?? null,
+            'max_units' => $slotState['max_units'] ?? null,
+            'fill_percent' => $slotState['fill_percent'] ?? null,
+            'state' => $currentState,
+            'previous_state' => $previousState,
+            'measured_at' => $slotState['measured_at'] ?? null,
+        ];
+
+        $deliveryResults = [
+            'webhooks' => $this->sendWebhooks($slot, $payload),
+            'emails' => $this->sendEmails($slot, $payload),
+        ];
+
+        $log = $this->logStorage->read();
+        $events = $log['events'] ?? [];
+        array_unshift($events, [
+            'id' => uniqid('alert_', true),
+            'created_at' => gmdate(DATE_ATOM),
+            'payload' => $payload,
+            'deliveries' => $deliveryResults,
+        ]);
+        $log['events'] = array_slice($events, 0, 200);
+        $this->logStorage->write($log);
+    }
+
+    public function getRecentEvents(int $limit = 25): array
+    {
+        $log = $this->logStorage->read();
+        return array_slice($log['events'] ?? [], 0, $limit);
+    }
+
+    private function sendWebhooks(array $slot, array $payload): array
+    {
+        $results = [];
+
+        foreach ($slot['webhook_ids'] ?? [] as $webhookId) {
+            $webhook = $this->configRepository->getWebhookById((string) $webhookId);
+            if ($webhook === null || !($webhook['enabled'] ?? false) || empty($webhook['url'])) {
+                $results[] = [
+                    'id' => $webhookId,
+                    'success' => false,
+                    'message' => 'Webhook nicht gefunden oder deaktiviert.',
+                ];
+                continue;
+            }
+
+            $headers = [
+                'Content-Type: application/json',
+            ];
+
+            foreach (($webhook['headers'] ?? []) as $headerName => $headerValue) {
+                if ($headerName === '' || $headerValue === '') {
+                    continue;
+                }
+                $headers[] = $headerName . ': ' . $headerValue;
+            }
+
+            $context = stream_context_create([
+                'http' => [
+                    'method' => 'POST',
+                    'header' => implode("\r\n", $headers),
+                    'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
+                    'timeout' => 5,
+                    'ignore_errors' => true,
+                ],
+            ]);
+
+            $response = @file_get_contents((string) $webhook['url'], false, $context);
+            $success = $response !== false;
+            $results[] = [
+                'id' => $webhookId,
+                'success' => $success,
+                'message' => $success ? 'Webhook gesendet.' : 'Webhook fehlgeschlagen.',
+            ];
+        }
+
+        return $results;
+    }
+
+    private function sendEmails(array $slot, array $payload): array
+    {
+        $config = $this->configRepository->getConfig();
+        $appName = $config['app']['name'] ?? 'Getraenkeautomat Monitor';
+        $from = $config['app']['default_from_email'] ?? 'monitor@example.local';
+        $results = [];
+
+        foreach ($slot['email_ids'] ?? [] as $emailId) {
+            $recipient = $this->configRepository->getEmailById((string) $emailId);
+            if ($recipient === null || !($recipient['enabled'] ?? false) || empty($recipient['address'])) {
+                $results[] = [
+                    'id' => $emailId,
+                    'success' => false,
+                    'message' => 'Email-Empfaenger nicht gefunden oder deaktiviert.',
+                ];
+                continue;
+            }
+
+            $subject = sprintf(
+                '[%s] %s %s / %s',
+                $appName,
+                $payload['event'] === 'critical' ? 'Alarm' : 'Entwarnung',
+                $payload['machine_name'],
+                $payload['slot_label']
+            );
+
+            $body = implode("\n", [
+                'Ereignis: ' . $payload['event'],
+                'Automat: ' . $payload['machine_name'] . ' (' . $payload['machine_id'] . ')',
+                'Fach: ' . $payload['slot_label'] . ' / ' . $payload['sensor_id'],
+                'Produkt: ' . ($payload['product_name'] ?: 'Unbekannt'),
+                'Bestand: ' . $payload['units_estimated'] . ' von ' . $payload['max_units'],
+                'Fuellstand: ' . $payload['fill_percent'] . '%',
+                'Messwert: ' . $payload['distance_mm'] . ' mm',
+                'Zeitpunkt: ' . $payload['measured_at'],
+            ]);
+
+            $headers = [
+                'From: ' . $from,
+                'Content-Type: text/plain; charset=UTF-8',
+            ];
+
+            $success = @mail(
+                (string) $recipient['address'],
+                $subject,
+                $body,
+                implode("\r\n", $headers)
+            );
+
+            $results[] = [
+                'id' => $emailId,
+                'success' => $success,
+                'message' => $success ? 'Email versendet.' : 'Email-Versand fehlgeschlagen.',
+            ];
+        }
+
+        return $results;
+    }
+}

+ 100 - 0
src/ConfigRepository.php

@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+final class ConfigRepository
+{
+    private JsonFile $storage;
+
+    public function __construct(string $path)
+    {
+        $this->storage = new JsonFile($path, [
+            'app' => [
+                'name' => 'Getraenkeautomat Monitor',
+                'timezone' => 'Europe/Berlin',
+                'dashboard_refresh_seconds' => 15,
+                'default_from_email' => 'monitor@example.local',
+            ],
+            'api' => [
+                'bearer_token' => 'change-me-token',
+            ],
+            'admin' => [
+                'username' => 'admin',
+                'password_hash' => '',
+            ],
+            'alerts' => [
+                'webhooks' => [],
+                'emails' => [],
+            ],
+            'machines' => [],
+        ]);
+    }
+
+    public function getConfig(): array
+    {
+        $config = $this->storage->read();
+        $timezone = $config['app']['timezone'] ?? 'Europe/Berlin';
+        date_default_timezone_set((string) $timezone);
+
+        return $config;
+    }
+
+    public function saveConfig(array $config): void
+    {
+        $this->storage->write($config);
+    }
+
+    public function getMachineById(string $machineId): ?array
+    {
+        foreach ($this->getConfig()['machines'] ?? [] as $machine) {
+            if (($machine['id'] ?? '') === $machineId) {
+                return $machine;
+            }
+        }
+
+        return null;
+    }
+
+    public function findSlot(string $machineId, string $sensorId): ?array
+    {
+        $machine = $this->getMachineById($machineId);
+        if ($machine === null) {
+            return null;
+        }
+
+        foreach ($machine['slots'] ?? [] as $slot) {
+            if (($slot['sensor_id'] ?? '') === $sensorId) {
+                return [
+                    'machine' => $machine,
+                    'slot' => $slot,
+                ];
+            }
+        }
+
+        return null;
+    }
+
+    public function getWebhookById(string $id): ?array
+    {
+        foreach ($this->getConfig()['alerts']['webhooks'] ?? [] as $webhook) {
+            if (($webhook['id'] ?? '') === $id) {
+                return $webhook;
+            }
+        }
+
+        return null;
+    }
+
+    public function getEmailById(string $id): ?array
+    {
+        foreach ($this->getConfig()['alerts']['emails'] ?? [] as $email) {
+            if (($email['id'] ?? '') === $id) {
+                return $email;
+            }
+        }
+
+        return null;
+    }
+}

+ 35 - 0
src/InventoryService.php

@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+final class InventoryService
+{
+    public function calculate(array $slot, float $distanceMm): array
+    {
+        $full = (float) ($slot['full_distance_mm'] ?? 0);
+        $empty = (float) ($slot['empty_distance_mm'] ?? 0);
+        $perUnit = max(0.01, (float) ($slot['distance_per_unit'] ?? 1));
+
+        $denominator = $full - $empty;
+        $ratio = $denominator === 0.0 ? 0.0 : (($distanceMm - $empty) / $denominator);
+        $ratio = max(0.0, min(1.0, $ratio));
+
+        $maxUnits = (int) max(0, round(abs($full - $empty) / $perUnit));
+        $unitsEstimated = (int) max(0, min($maxUnits, round($ratio * $maxUnits)));
+        $fillPercent = (int) round($ratio * 100);
+        $alertBelowUnits = (int) ($slot['alert_below_units'] ?? 0);
+        $state = $unitsEstimated < $alertBelowUnits ? 'critical' : 'ok';
+
+        return [
+            'distance_mm' => round($distanceMm, 2),
+            'fill_ratio' => $ratio,
+            'fill_percent' => $fillPercent,
+            'units_estimated' => $unitsEstimated,
+            'max_units' => $maxUnits,
+            'alert_below_units' => $alertBelowUnits,
+            'state' => $state,
+        ];
+    }
+}

+ 95 - 0
src/JsonFile.php

@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+use RuntimeException;
+
+final class JsonFile
+{
+    public function __construct(
+        private readonly string $path,
+        private readonly array $defaultData = []
+    ) {
+    }
+
+    public function read(): array
+    {
+        $this->ensureFile();
+
+        $handle = fopen($this->path, 'c+');
+        if ($handle === false) {
+            throw new RuntimeException('JSON-Datei konnte nicht geoeffnet werden: ' . $this->path);
+        }
+
+        try {
+            if (!flock($handle, LOCK_SH)) {
+                throw new RuntimeException('JSON-Datei konnte nicht gesperrt werden: ' . $this->path);
+            }
+
+            rewind($handle);
+            $contents = stream_get_contents($handle);
+            flock($handle, LOCK_UN);
+        } finally {
+            fclose($handle);
+        }
+
+        if ($contents === false || trim($contents) === '') {
+            return $this->defaultData;
+        }
+
+        $decoded = json_decode($contents, true);
+        if (!is_array($decoded)) {
+            throw new RuntimeException('JSON-Datei ist ungueltig: ' . $this->path);
+        }
+
+        return $decoded;
+    }
+
+    public function write(array $data): void
+    {
+        $dir = dirname($this->path);
+        if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
+            throw new RuntimeException('Verzeichnis konnte nicht angelegt werden: ' . $dir);
+        }
+
+        $handle = fopen($this->path, 'c+');
+        if ($handle === false) {
+            throw new RuntimeException('JSON-Datei konnte nicht geoeffnet werden: ' . $this->path);
+        }
+
+        $json = json_encode(
+            $data,
+            JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
+        );
+
+        if ($json === false) {
+            fclose($handle);
+            throw new RuntimeException('JSON-Daten konnten nicht kodiert werden.');
+        }
+
+        try {
+            if (!flock($handle, LOCK_EX)) {
+                throw new RuntimeException('JSON-Datei konnte nicht exklusiv gesperrt werden: ' . $this->path);
+            }
+
+            ftruncate($handle, 0);
+            rewind($handle);
+            fwrite($handle, $json);
+            fflush($handle);
+            flock($handle, LOCK_UN);
+        } finally {
+            fclose($handle);
+        }
+    }
+
+    private function ensureFile(): void
+    {
+        if (is_file($this->path)) {
+            return;
+        }
+
+        $this->write($this->defaultData);
+    }
+}

+ 164 - 0
src/MonitorService.php

@@ -0,0 +1,164 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+use InvalidArgumentException;
+
+final class MonitorService
+{
+    private InventoryService $inventoryService;
+
+    public function __construct(
+        private readonly ConfigRepository $configRepository,
+        private readonly StateRepository $stateRepository,
+        private readonly AlertService $alertService
+    ) {
+        $this->inventoryService = new InventoryService();
+    }
+
+    public function getStatus(): array
+    {
+        $config = $this->configRepository->getConfig();
+        $machines = [];
+        $criticalCount = 0;
+        $slotCount = 0;
+
+        foreach ($config['machines'] ?? [] as $machine) {
+            $slots = [];
+
+            foreach ($machine['slots'] ?? [] as $slot) {
+                $slotCount++;
+                $slotState = $this->buildSlotStatus($machine, $slot);
+                if (($slotState['state'] ?? 'unknown') === 'critical') {
+                    $criticalCount++;
+                }
+                $slots[] = $slotState;
+            }
+
+            $machines[] = [
+                'id' => $machine['id'] ?? '',
+                'name' => $machine['name'] ?? '',
+                'location' => $machine['location'] ?? '',
+                'slots' => $slots,
+            ];
+        }
+
+        return [
+            'ok' => true,
+            'generated_at' => gmdate(DATE_ATOM),
+            'app' => [
+                'name' => $config['app']['name'] ?? 'Getraenkeautomat Monitor',
+                'dashboard_refresh_seconds' => (int) ($config['app']['dashboard_refresh_seconds'] ?? 15),
+            ],
+            'summary' => [
+                'machine_count' => count($machines),
+                'slot_count' => $slotCount,
+                'critical_count' => $criticalCount,
+            ],
+            'machines' => $machines,
+            'alerts' => $this->alertService->getRecentEvents(),
+        ];
+    }
+
+    public function processReading(array $payload): array
+    {
+        $machineId = trim((string) ($payload['machine_id'] ?? ''));
+        $sensorId = trim((string) ($payload['sensor_id'] ?? ''));
+
+        if ($machineId === '' || $sensorId === '') {
+            throw new InvalidArgumentException('machine_id und sensor_id sind erforderlich.');
+        }
+
+        if (!isset($payload['distance_mm']) || !is_numeric($payload['distance_mm'])) {
+            throw new InvalidArgumentException('distance_mm muss numerisch sein.');
+        }
+
+        $slotMatch = $this->configRepository->findSlot($machineId, $sensorId);
+        if ($slotMatch === null) {
+            throw new InvalidArgumentException('Unbekannter Automat oder Sensor.');
+        }
+
+        $machine = $slotMatch['machine'];
+        $slot = $slotMatch['slot'];
+        $distanceMm = (float) $payload['distance_mm'];
+        $measuredAt = $this->normalizeMeasuredAt($payload['measured_at'] ?? null);
+        $previous = $this->stateRepository->getSlotState($machineId, $sensorId);
+        $metrics = $this->inventoryService->calculate($slot, $distanceMm);
+
+        $slotState = array_merge($metrics, [
+            'machine_id' => $machineId,
+            'machine_name' => $machine['name'] ?? '',
+            'sensor_id' => $sensorId,
+            'slot_label' => $slot['label'] ?? $sensorId,
+            'product_name' => $slot['product_name'] ?? '',
+            'measured_at' => $measuredAt,
+            'updated_at' => gmdate(DATE_ATOM),
+            'webhook_ids' => array_values($slot['webhook_ids'] ?? []),
+            'email_ids' => array_values($slot['email_ids'] ?? []),
+        ]);
+
+        $this->stateRepository->setSlotState($machineId, $sensorId, $slotState);
+        $this->alertService->handleTransition($machine, $slot, $slotState, $previous['state'] ?? null);
+
+        return [
+            'ok' => true,
+            'machine_id' => $machineId,
+            'sensor_id' => $sensorId,
+            'slot_label' => $slotState['slot_label'],
+            'units_estimated' => $slotState['units_estimated'],
+            'fill_percent' => $slotState['fill_percent'],
+            'state' => $slotState['state'],
+        ];
+    }
+
+    private function buildSlotStatus(array $machine, array $slot): array
+    {
+        $sensorId = (string) ($slot['sensor_id'] ?? '');
+        $machineId = (string) ($machine['id'] ?? '');
+        $existingState = $this->stateRepository->getSlotState($machineId, $sensorId);
+        $base = [
+            'machine_id' => $machineId,
+            'machine_name' => $machine['name'] ?? '',
+            'sensor_id' => $sensorId,
+            'slot_label' => $slot['label'] ?? $sensorId,
+            'product_name' => $slot['product_name'] ?? '',
+            'fill_percent' => null,
+            'units_estimated' => null,
+            'max_units' => (int) round(
+                abs(
+                    ((float) ($slot['full_distance_mm'] ?? 0)) -
+                    ((float) ($slot['empty_distance_mm'] ?? 0))
+                ) / max(0.01, (float) ($slot['distance_per_unit'] ?? 1))
+            ),
+            'distance_mm' => null,
+            'state' => 'unknown',
+            'measured_at' => null,
+            'updated_at' => null,
+            'alert_below_units' => (int) ($slot['alert_below_units'] ?? 0),
+            'webhook_ids' => array_values($slot['webhook_ids'] ?? []),
+            'email_ids' => array_values($slot['email_ids'] ?? []),
+        ];
+
+        if ($existingState === null) {
+            return $base;
+        }
+
+        return array_merge($base, $existingState);
+    }
+
+    private function normalizeMeasuredAt(mixed $value): string
+    {
+        if (!is_string($value) || trim($value) === '') {
+            return gmdate(DATE_ATOM);
+        }
+
+        $timestamp = strtotime($value);
+        if ($timestamp === false) {
+            throw new InvalidArgumentException('measured_at ist kein gueltiger ISO-Zeitstempel.');
+        }
+
+        return gmdate(DATE_ATOM, $timestamp);
+    }
+}

+ 42 - 0
src/StateRepository.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App;
+
+final class StateRepository
+{
+    private JsonFile $storage;
+
+    public function __construct(string $path)
+    {
+        $this->storage = new JsonFile($path, [
+            'slots' => [],
+        ]);
+    }
+
+    public function getState(): array
+    {
+        return $this->storage->read();
+    }
+
+    public function getSlotState(string $machineId, string $sensorId): ?array
+    {
+        $state = $this->getState();
+        $key = $this->buildKey($machineId, $sensorId);
+
+        return $state['slots'][$key] ?? null;
+    }
+
+    public function setSlotState(string $machineId, string $sensorId, array $slotState): void
+    {
+        $state = $this->getState();
+        $state['slots'][$this->buildKey($machineId, $sensorId)] = $slotState;
+        $this->storage->write($state);
+    }
+
+    private function buildKey(string $machineId, string $sensorId): string
+    {
+        return $machineId . '::' . $sensorId;
+    }
+}

+ 110 - 0
src/bootstrap.php

@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+define('APP_ROOT', dirname(__DIR__));
+define('APP_DATA', APP_ROOT . '/data');
+
+spl_autoload_register(static function (string $class): void {
+    $prefix = 'App\\';
+    if (!str_starts_with($class, $prefix)) {
+        return;
+    }
+
+    $relative = substr($class, strlen($prefix));
+    $path = APP_ROOT . '/src/' . str_replace('\\', '/', $relative) . '.php';
+
+    if (is_file($path)) {
+        require_once $path;
+    }
+});
+
+date_default_timezone_set('Europe/Berlin');
+
+function app_config_repository(): App\ConfigRepository
+{
+    static $repository = null;
+
+    if ($repository === null) {
+        $repository = new App\ConfigRepository(APP_DATA . '/config.json');
+    }
+
+    return $repository;
+}
+
+function app_state_repository(): App\StateRepository
+{
+    static $repository = null;
+
+    if ($repository === null) {
+        $repository = new App\StateRepository(APP_DATA . '/state.json');
+    }
+
+    return $repository;
+}
+
+function app_alert_service(): App\AlertService
+{
+    static $service = null;
+
+    if ($service === null) {
+        $service = new App\AlertService(app_config_repository(), APP_DATA . '/alert_log.json');
+    }
+
+    return $service;
+}
+
+function app_monitor_service(): App\MonitorService
+{
+    static $service = null;
+
+    if ($service === null) {
+        $service = new App\MonitorService(
+            app_config_repository(),
+            app_state_repository(),
+            app_alert_service()
+        );
+    }
+
+    return $service;
+}
+
+function app_admin_auth(): App\AdminAuth
+{
+    static $auth = null;
+
+    if ($auth === null) {
+        $auth = new App\AdminAuth(app_config_repository());
+    }
+
+    return $auth;
+}
+
+function app_json_response(array $payload, int $statusCode = 200): never
+{
+    http_response_code($statusCode);
+    header('Content-Type: application/json; charset=utf-8');
+    echo json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+    exit;
+}
+
+function app_redirect(string $location): never
+{
+    header('Location: ' . $location);
+    exit;
+}
+
+function app_read_json_body(): array
+{
+    $raw = file_get_contents('php://input');
+    if ($raw === false || trim($raw) === '') {
+        return [];
+    }
+
+    $decoded = json_decode($raw, true);
+    if (!is_array($decoded)) {
+        throw new RuntimeException('Ungueltiger JSON-Body.');
+    }
+
+    return $decoded;
+}