Selaa lähdekoodia

Enhance application configuration and error logging

- Added php_errors.log to .gitignore and README for error tracking
- Updated .htaccess to set default charset to UTF-8
- Improved error handling in readings.php and added logging for exceptions
- Enhanced admin panel to allow direct editing of JSON configuration
- Updated various text strings for consistency and correctness
- Added CSS styles for better textarea presentation
Medowar 1 kuukausi sitten
vanhempi
sitoutus
e03c0452a7
19 muutettua tiedostoa jossa 284 lisäystä ja 194 poistoa
  1. 2 1
      .gitignore
  2. 1 0
      .htaccess
  3. 54 50
      README.md
  4. 103 45
      admin/index.php
  5. 1 1
      api-docs/index.html
  6. 1 0
      api/v1/readings.php
  7. 2 2
      app.js
  8. 4 4
      data/config.json
  9. 13 13
      docs/API.md
  10. 27 27
      docs/CONFIG.md
  11. 38 29
      docs/DEPLOYMENT.md
  12. 8 6
      index.php
  13. 6 6
      openapi.yaml
  14. 3 3
      src/AlertService.php
  15. 1 1
      src/ConfigRepository.php
  16. 3 3
      src/JsonFile.php
  17. 2 2
      src/MonitorService.php
  18. 8 1
      src/bootstrap.php
  19. 7 0
      styles.css

+ 2 - 1
.gitignore

@@ -1 +1,2 @@
-.codex
+.codex
+data/php_errors.log

+ 1 - 0
.htaccess

@@ -1,5 +1,6 @@
 Options -Indexes
 DirectoryIndex index.php
+AddDefaultCharset UTF-8
 
 <IfModule mod_rewrite.c>
     RewriteEngine On

+ 54 - 50
README.md

@@ -1,6 +1,6 @@
-# Getraenkeautomat Monitor
+# Getränkeautomat 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.
+Getränkeautomat Monitor ist eine kleine PHP/HTML/JS-Anwendung zur Überwachung von Füllständen in Getränkeautomaten. Ein ESP32 oder ein anderer Sensor-Client sendet Messwerte an eine einfache REST-nahe API. Die Anwendung berechnet daraus den geschätzten Bestand pro Fach, visualisiert den aktuellen Status im Browser und löst bei kritischen Schwellwerten Alarme aus.
 
 Die Anwendung ist bewusst simpel gehalten:
 
@@ -19,65 +19,69 @@ Die Anwendung ist bewusst simpel gehalten:
 ## Funktionsumfang
 
 - mehrere Automaten in einer Instanz
-- mehrere Faecher pro Automat
-- REST-nahe Eingangs-API fuer Einzelmessungen
-- Dashboard mit visueller Fuellstandsanzeige
+- mehrere Fächer pro Automat
+- REST-nahe Eingangs-API für Einzelmessungen
+- Dashboard mit visueller Füllstandsanzeige
 - Alarmierung per Webhook und per Email
 - Adminpanel zur Bearbeitung der JSON-Config
 - statischer Admin-Login mit bcrypt-Hash
 
-## Architektur im Ueberblick
+## Architektur im Überblick
 
 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.
+2. Die Anwendung sucht das passende Fach über `machine_id` und `sensor_id`.
+3. Aus `full_distance_mm`, `empty_distance_mm` und `distance_per_unit` werden Füllstand 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`.
+5. Wenn sich der Status von `ok` nach `critical` oder von `critical` nach `ok` ändert, wird ein Alarmereignis erzeugt.
+6. Dashboard und Adminpanel lesen den aktuellen Zustand über `GET /api/v1/status.php`.
 
 ## Projektstruktur
 
 Die Anwendung ist jetzt so aufgebaut, dass der gesamte Projektordner direkt als Apache-Unterordner deployt werden kann.
 
 - `index.php`, `admin/`, `api/`, `api-docs/`, `app.js`, `styles.css`, `openapi.yaml`
-  - oeffentliche Runtime-Dateien
+  - öffentliche Runtime-Dateien
 - `src/`
   - interne PHP-Kernlogik
   - wird auf Apache-Hosting per `.htaccess` gesperrt
 - `data/`
-  - JSON-Dateien fuer Konfiguration, aktuellen Zustand und Alarmhistorie
+  - JSON-Dateien für Konfiguration, aktuellen Zustand und Alarmhistorie
+  - enthält zusätzlich `php_errors.log` für PHP-Fehler
   - liegt im selben Ordner wie die App, ist aber per `.htaccess` gesperrt
 - `docs/`
-  - Markdown-Dokumentation fuer Entwickler
+  - Markdown-Dokumentation für Entwickler
   - wird auf Apache-Hosting ebenfalls per `.htaccess` gesperrt
 
 Wichtige Dateien:
 
 - `index.php`: Dashboard
 - `admin/index.php`: Login und Adminpanel
-- `api/v1/readings.php`: API fuer eingehende Sensorwerte
-- `api/v1/status.php`: Status-API fuer Dashboard und Adminpanel
+- `api/v1/readings.php`: API für eingehende Sensorwerte
+- `api/v1/status.php`: Status-API für Dashboard und Adminpanel
 - `api-docs/index.html`: Swagger UI
 - `openapi.yaml`: OpenAPI-Spec
-- `src/MonitorService.php`: zentrale Orchestrierung fuer Lesen, Berechnen und Status
-- `src/InventoryService.php`: Berechnung von Fuellgrad und Bestand
+- `src/MonitorService.php`: zentrale Orchestrierung für Lesen, Berechnen und Status
+- `src/InventoryService.php`: Berechnung von Füllgrad 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
+- `data/php_errors.log`: PHP-Fehlerlog der Anwendung
 
 ## Voraussetzungen
 
-Fuer den Betrieb wird auf dem Zielsystem benoetigt:
+Für den Betrieb wird auf dem Zielsystem benötigt:
 
 - PHP 8.1 oder neuer empfohlen
 - Schreibrechte auf `data/`
-- Apache mit aktivem `.htaccess`-Support fuer die Produktionsbereitstellung
-- funktionierende Mail-Konfiguration, falls `mail()` fuer Email-Alarme genutzt werden soll
+- Apache mit aktivem `.htaccess`-Support für die Produktionsbereitstellung
+- funktionierende Mail-Konfiguration, falls `mail()` für Email-Alarme genutzt werden soll
 - Webserver oder PHP Built-in Server
 
+PHP schreibt Laufzeitfehler in `data/php_errors.log`.
+
 ## Schnellstart
 
 Sobald PHP auf dem Zielsystem installiert ist:
@@ -94,7 +98,7 @@ Danach ist die Anwendung erreichbar unter:
 - Swagger UI: `http://localhost:8000/api-docs/`
 - OpenAPI-Spec: `http://localhost:8000/openapi.yaml`
 
-Hinweis: Der PHP Built-in Server wertet `.htaccess` nicht aus. Die Verzeichnisse `src/`, `data/` und `docs/` werden auf Shared Hosting erst durch Apache geschuetzt.
+Hinweis: Der PHP Built-in Server wertet `.htaccess` nicht aus. Die Verzeichnisse `src/`, `data/` und `docs/` werden auf Shared Hosting erst durch Apache geschützt.
 
 ## Default-Zugangsdaten
 
@@ -108,38 +112,38 @@ Diese Werte sollten direkt nach dem ersten Login angepasst werden.
 
 ## Dashboard
 
-Das Dashboard ist die oeffentliche Visualisierung der Anlage. Es zeigt:
+Das Dashboard ist die öffentliche Visualisierung der Anlage. Es zeigt:
 
-- Anzahl Automaten, Faecher und aktuell kritische Faecher
+- Anzahl Automaten, Fächer und aktuell kritische Fächer
 - Filter nach Automat
 - pro Fach:
   - Label
   - Produktname
-  - visuellen Fuellstand
-  - geschaetzte Anzahl Flaschen
+  - visuellen Füllstand
+  - geschätzte 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.
+Die Daten werden regelmäßig per Polling über `status.php` aktualisiert. Das Intervall wird in `config.json` über `app.dashboard_refresh_seconds` gesteuert.
 
 ## Adminpanel
 
-Das Adminpanel ist ueber `/admin/` erreichbar und erlaubt:
+Das Adminpanel ist über `/admin/` erreichbar und erlaubt:
 
-- Aendern des App-Namens und des Refresh-Intervalls
-- Aendern des API-Bearer-Tokens
-- Aendern von Admin-Benutzername und Passwort
+- Ändern des App-Namens und des Refresh-Intervalls
+- Ändern des API-Bearer-Tokens
+- Ändern von Admin-Benutzername und Passwort
 - Verwalten von Webhooks
-- Verwalten von Email-Empfaengern
-- Verwalten von Automaten und ihren Faechern
+- Verwalten von Email-Empfängern
+- Verwalten von Automaten und ihren Fächern
 
 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:
+Alarme werden nicht bei jeder eingehenden kritischen Messung ausgelöst, sondern nur bei Zustandswechseln:
 
 - `ok -> critical`: Alarm
 - `critical -> ok`: Entwarnung
@@ -154,7 +158,7 @@ Ein Fach gilt als kritisch, wenn gilt:
 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.
+Ein Fach mit `alert_below_units = 2` löst also erst dann Alarm aus, wenn nur noch `1` oder `0` Einheiten geschätzt werden.
 
 ## Wie die Bestandsberechnung funktioniert
 
@@ -164,7 +168,7 @@ Jedes Fach hat drei wichtige Kalibrierwerte:
 - `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 App behandelt `full_distance_mm` als 100 Prozent und `empty_distance_mm` als 0 Prozent. Dabei ist es egal, ob kleinere oder größere Zahlen "voll" bedeuten, weil die Berechnung die Sensororientierung automatisch berücksichtigt.
 
 Die grobe Logik ist:
 
@@ -175,7 +179,7 @@ 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.
+Damit repräsentiert `distance_per_unit` die Änderung des Messwerts pro Flasche oder Einheit.
 
 ## API-Referenz
 
@@ -183,7 +187,7 @@ Die API ist jetzt auf drei Ebenen dokumentiert:
 
 - Interaktive Swagger UI unter `http://localhost:8000/api-docs/`
 - Maschinenlesbare OpenAPI-Spec unter `http://localhost:8000/openapi.yaml`
-- Erlaeuternde Referenz in [docs/API.md](docs/API.md)
+- Erläuternde Referenz in [docs/API.md](docs/API.md)
 
 ### `POST /api/v1/readings.php`
 
@@ -237,7 +241,7 @@ Erfolg:
 
 ### `GET /api/v1/status.php`
 
-Liefert den aktuellen aggregierten Zustand aller Automaten und Faecher inklusive letzter Alarmereignisse.
+Liefert den aktuellen aggregierten Zustand aller Automaten und Fächer inklusive letzter Alarmereignisse.
 
 ## Konfiguration
 
@@ -246,48 +250,48 @@ Die Konfiguration liegt in `data/config.json`. Eine detaillierte Beschreibung al
 Die obersten Bereiche sind:
 
 - `app`: Name, Zeitzone, UI-Refresh, Email-Absender
-- `api`: Bearer-Token fuer Sensor-Clients
+- `api`: Bearer-Token für Sensor-Clients
 - `admin`: Benutzername und Passwort-Hash
-- `alerts`: wiederverwendbare Webhooks und Email-Empfaenger
-- `machines`: Automaten mit ihren Faechern
+- `alerts`: wiederverwendbare Webhooks und Email-Empfänger
+- `machines`: Automaten mit ihren Fächern
 
 ## Zustandsdateien
 
 ### `data/state.json`
 
-Diese Datei enthaelt den letzten bekannten Zustand jedes Fachs. Sie wird von der API bei jeder gueltigen Messung aktualisiert.
+Diese Datei enthält den letzten bekannten Zustand jedes Fachs. Sie wird von der API bei jeder gültigen Messung aktualisiert.
 
 Typische Inhalte:
 
 - letzter Messwert
 - letzter Messzeitpunkt
-- geschaetzter Bestand
+- geschätzter Bestand
 - maximaler Bestand
 - aktueller Status
-- verknuepfte Alarmkanal-IDs
+- verknüpfte Alarmkanal-IDs
 
 ### `data/alert_log.json`
 
-Diese Datei enthaelt die zuletzt ausgeloesten Alarmereignisse und Entwarnungen inklusive Lieferstatus fuer Webhooks und Emails.
+Diese Datei enthält die zuletzt ausgelösten Alarmereignisse und Entwarnungen inklusive Lieferstatus für 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.
+Die Anwendung nutzt für Schreibzugriffe Dateisperren über `flock()`. Das reduziert das Risiko beschädigter 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.
+- `data/` sollte nicht direkt öffentlich über 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
+## Typische Erweiterungen für spätere Versionen
 
-- Batch-Endpoint fuer mehrere Sensorwerte in einem Request
+- Batch-Endpoint für mehrere Sensorwerte in einem Request
 - Langzeithistorie mit Zeitreihen oder Charts
 - Benutzerverwaltung statt statischem Admin-Login
-- Retry-Mechanismus fuer fehlgeschlagene Webhooks
+- Retry-Mechanismus für fehlgeschlagene Webhooks
 - Healthcheck-Endpunkte
 - CSV- oder PDF-Export
 

+ 103 - 45
admin/index.php

@@ -4,11 +4,14 @@ declare(strict_types=1);
 
 require_once dirname(__DIR__) . '/src/bootstrap.php';
 
+header('Content-Type: text/html; charset=UTF-8');
+
 $auth = app_admin_auth();
 $auth->start();
 $configRepository = app_config_repository();
 $message = null;
 $messageType = 'success';
+$rawConfigText = null;
 
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     $action = $_POST['action'] ?? '';
@@ -23,49 +26,74 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
             app_redirect(app_url('/admin/'));
         }
 
-        $message = 'Login fehlgeschlagen. Bitte Zugangsdaten pruefen.';
+        $message = 'Login fehlgeschlagen. Bitte Zugangsdaten prüfen.';
         $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')),
-            'base_path' => app_normalize_base_path((string) ($_POST['base_path'] ?? '')),
-        ];
-        $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'] ?? []);
+        try {
+            $config = $configRepository->getConfig();
+            $newPassword = trim((string) ($_POST['admin_password'] ?? ''));
+            $config['app'] = [
+                'name' => trim((string) ($_POST['app_name'] ?? 'Getränkeautomat 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')),
+                'base_path' => app_normalize_base_path((string) ($_POST['base_path'] ?? '')),
+            ];
+            $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';
+        } catch (Throwable $exception) {
+            $message = 'Konfiguration konnte nicht gespeichert werden: ' . $exception->getMessage();
+            $messageType = 'error';
+        }
+    }
+
+    if ($action === 'save_raw_config' && $auth->isAuthenticated()) {
+        $rawConfigText = (string) ($_POST['raw_config'] ?? '');
 
-        $configRepository->saveConfig($config);
-        $message = 'Konfiguration gespeichert.';
-        $messageType = 'success';
+        try {
+            $decoded = json_decode($rawConfigText, true, 512, JSON_THROW_ON_ERROR);
+            if (!is_array($decoded) || array_is_list($decoded)) {
+                throw new JsonException('Die JSON-Config muss ein JSON-Objekt sein.');
+            }
+
+            $configRepository->saveConfig($decoded);
+            $message = 'JSON-Config gespeichert.';
+            $messageType = 'success';
+            $rawConfigText = encodeConfigForEditor($decoded);
+        } catch (Throwable $exception) {
+            $message = 'JSON-Config ist ungültig: ' . $exception->getMessage();
+            $messageType = 'error';
+        }
     }
 }
 
 $config = $configRepository->getConfig();
+$rawConfigText ??= encodeConfigForEditor($config);
 
 if (!$auth->isAuthenticated()) {
     renderLogin($message, $messageType);
     exit;
 }
 
-renderAdmin($config, $message, $messageType);
+renderAdmin($config, $rawConfigText, $message, $messageType);
 
 function renderLogin(?string $message, string $messageType): void
 {
@@ -98,7 +126,7 @@ function renderLogin(?string $message, string $messageType): void
                         <input type="password" name="password" required>
                     </label>
                     <button class="button button--primary" type="submit">Einloggen</button>
-                    <a class="button button--ghost" href="<?= htmlspecialchars(app_url('/'), ENT_QUOTES) ?>">Zurueck zum Dashboard</a>
+                    <a class="button button--ghost" href="<?= htmlspecialchars(app_url('/'), ENT_QUOTES) ?>">Zurück zum Dashboard</a>
                 </form>
             </section>
         </main>
@@ -107,7 +135,7 @@ function renderLogin(?string $message, string $messageType): void
     <?php
 }
 
-function renderAdmin(array $config, ?string $message, string $messageType): void
+function renderAdmin(array $config, string $rawConfigText, ?string $message, string $messageType): void
 {
     ?>
     <!DOCTYPE html>
@@ -125,7 +153,7 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                     <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>
+                        <p>Hier werden API-Token, Zugangsdaten, Fächer und Alarmwege direkt in der JSON-Config gepflegt.</p>
                     </div>
                     <div class="inline-actions">
                         <a class="button button--ghost" href="<?= htmlspecialchars(app_url('/'), ENT_QUOTES) ?>">Dashboard</a>
@@ -137,6 +165,25 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                     <div class="message message--<?= htmlspecialchars($messageType, ENT_QUOTES) ?>"><?= htmlspecialchars($message, ENT_QUOTES) ?></div>
                 <?php endif; ?>
 
+                <section class="form-section">
+                    <div class="panel__header">
+                        <div>
+                            <h2>JSON-Config direkt bearbeiten</h2>
+                            <p>Die komplette Konfiguration kann hier als Klartext angezeigt und direkt als JSON gespeichert werden.</p>
+                        </div>
+                    </div>
+                    <form method="post" class="admin-form">
+                        <input type="hidden" name="action" value="save_raw_config">
+                        <label>
+                            Konfiguration als JSON
+                            <textarea class="config-textarea" name="raw_config" spellcheck="false"><?= htmlspecialchars($rawConfigText, ENT_QUOTES) ?></textarea>
+                        </label>
+                        <div class="section-actions">
+                            <button class="button button--primary" type="submit">JSON speichern</button>
+                        </div>
+                    </form>
+                </section>
+
                 <form method="post" class="admin-form" id="admin-form">
                     <input type="hidden" name="action" value="save_config">
 
@@ -156,7 +203,7 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                                 <input type="number" min="5" name="dashboard_refresh_seconds" value="<?= (int) ($config['app']['dashboard_refresh_seconds'] ?? 15) ?>" required>
                             </label>
                             <label>
-                                Absender fuer Email
+                                Absender für Email
                                 <input type="email" name="default_from_email" value="<?= htmlspecialchars((string) ($config['app']['default_from_email'] ?? ''), ENT_QUOTES) ?>">
                             </label>
                             <label>
@@ -164,7 +211,7 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                                 <input type="text" name="base_path" value="<?= htmlspecialchars((string) ($config['app']['base_path'] ?? ''), ENT_QUOTES) ?>" placeholder="/auswertung">
                             </label>
                         </div>
-                        <p class="field-help">Leer lassen fuer den Domain-Root. Fuer Unterordner z. B. <code>/auswertung</code> eintragen.</p>
+                        <p class="field-help">Leer lassen für den Domain-Root. Für Unterordner z. B. <code>/auswertung</code> eintragen.</p>
                     </section>
 
                     <section class="admin-grid">
@@ -186,7 +233,7 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                                 </label>
                                 <label>
                                     Neues Passwort
-                                    <input type="password" name="admin_password" placeholder="Leer lassen, um es nicht zu aendern">
+                                    <input type="password" name="admin_password" placeholder="Leer lassen, um es nicht zu ändern">
                                 </label>
                             </div>
                         </section>
@@ -196,9 +243,10 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                         <div class="panel__header">
                             <div>
                                 <h2>Webhooks</h2>
-                                <p>Mehrere Ziele sind moeglich. Header koennen als JSON hinterlegt werden.</p>
+                                <p>Mehrere Ziele sind möglich. Header können als JSON hinterlegt werden.</p>
+                                <p>Webhooks sind Alert-Ziele, wenn eine Alarmstufe bei einem Fach erreicht wurde</p>
                             </div>
-                            <button class="button button--secondary" type="button" data-add="webhook">Webhook hinzufuegen</button>
+                            <button class="button button--secondary" type="button" data-add="webhook">Webhook hinzufügen</button>
                         </div>
                         <div class="repeat-stack" id="webhook-stack">
                             <?php foreach (($config['alerts']['webhooks'] ?? []) as $index => $webhook): ?>
@@ -210,10 +258,10 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                     <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>
+                                <h2>Email-Empfänger</h2>
+                                <p>Emails werden über die Serverkonfiguration von <code>mail()</code> versendet.</p>
                             </div>
-                            <button class="button button--secondary" type="button" data-add="email">Email-Empfaenger hinzufuegen</button>
+                            <button class="button button--secondary" type="button" data-add="email">Email-Empfänger hinzufügen</button>
                         </div>
                         <div class="repeat-stack" id="email-stack">
                             <?php foreach (($config['alerts']['emails'] ?? []) as $index => $email): ?>
@@ -225,10 +273,10 @@ function renderAdmin(array $config, ?string $message, string $messageType): void
                     <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>
+                                <h2>Automaten und Fächer</h2>
+                                <p>Jeder Automat enthält beliebig viele Fächer, die jeweils genau einem Sensor zugeordnet sind.</p>
                             </div>
-                            <button class="button button--secondary" type="button" data-add="machine">Automat hinzufuegen</button>
+                            <button class="button button--secondary" type="button" data-add="machine">Automat hinzufügen</button>
                         </div>
                         <div class="repeat-stack" id="machine-stack">
                             <?php foreach (($config['machines'] ?? []) as $machineIndex => $machine): ?>
@@ -354,7 +402,7 @@ function renderEmailRow(int|string $index, array $email): string
     ?>
     <div class="repeat-card" data-email-row>
         <div class="panel__header">
-            <h4>Email-Empfaenger</h4>
+            <h4>Email-Empfänger</h4>
             <button class="button button--danger" type="button" data-remove="email">Entfernen</button>
         </div>
         <div class="field-grid">
@@ -391,10 +439,10 @@ function renderMachineRow(int|string $machineIndex, array $machine): string
         <div class="panel__header">
             <div>
                 <h4>Automat</h4>
-                <p>Faecher koennen direkt darunter verwaltet werden.</p>
+                <p>Fächer können 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--secondary" type="button" data-add="slot" data-machine-index="<?= htmlspecialchars((string) $machineIndex, ENT_QUOTES) ?>">Fach hinzufügen</button>
                 <button class="button button--danger" type="button" data-remove="machine">Automat entfernen</button>
             </div>
         </div>
@@ -567,3 +615,13 @@ function parseIdList(string $value): array
     $parts = array_map('trim', explode(',', $value));
     return array_values(array_filter($parts, static fn (string $item): bool => $item !== ''));
 }
+
+function encodeConfigForEditor(array $config): string
+{
+    $json = json_encode(
+        $config,
+        JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
+    );
+
+    return $json === false ? '{}' : $json;
+}

+ 1 - 1
api-docs/index.html

@@ -3,7 +3,7 @@
 <head>
   <meta charset="utf-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>Getraenkeautomat Monitor API Docs</title>
+  <title>Getränkeautomat Monitor API Docs</title>
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
   <style>
     html {

+ 1 - 0
api/v1/readings.php

@@ -54,6 +54,7 @@ try {
         'error' => $exception->getMessage(),
     ], 400);
 } catch (Throwable $exception) {
+    error_log((string) $exception);
     app_json_response([
         'ok' => false,
         'error' => 'Interner Fehler.',

+ 2 - 2
app.js

@@ -76,7 +76,7 @@ if (dataNode) {
             <div class="fill-tube__gloss"></div>
           </div>
           <div class="slot-card__metrics">
-            <p><strong>${fillPercent}%</strong> Fuellstand</p>
+            <p><strong>${fillPercent}%</strong> Füllstand</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>
@@ -93,7 +93,7 @@ if (dataNode) {
     );
 
     if (!machines.length) {
-      gridNode.innerHTML = '<p class="empty-state">Keine Automaten fuer die aktuelle Auswahl gefunden.</p>';
+      gridNode.innerHTML = '<p class="empty-state">Keine Automaten für die aktuelle Auswahl gefunden.</p>';
       return;
     }
 

+ 4 - 4
data/config.json

@@ -1,17 +1,17 @@
 {
     "app": {
-        "name": "Getraenkeautomat Monitor",
+        "name": "Getränkeautomat Feuerwehr Monitor",
         "timezone": "Europe/Berlin",
         "dashboard_refresh_seconds": 15,
-        "default_from_email": "monitor@example.local",
-        "base_path": ""
+        "default_from_email": "automat@med0.de",
+        "base_path": "/automat/"
     },
     "api": {
         "bearer_token": "demo-esp32-token"
     },
     "admin": {
         "username": "admin",
-        "password_hash": "$2y$10$LEpUKAxrLjJ3NOWridmw0.1D5JxnKvNONi9IkU5y4e6fPvO85/0em"
+        "password_hash": "$2b$10$ZhLCvESv2BmGzGd185Je2.kNU3d9hxQzmSbR1gR5uKxBMC2hVL40m"
     },
     "alerts": {
         "webhooks": [

+ 13 - 13
docs/API.md

@@ -4,7 +4,7 @@ Diese Datei beschreibt die HTTP-Schnittstellen der Anwendung.
 
 ## Swagger / OpenAPI
 
-Zusatzlich zur textuellen Referenz gibt es jetzt zwei API-Dokumentationsziele in der laufenden Anwendung:
+Zusätzlich zur textuellen Referenz gibt es jetzt zwei API-Dokumentationsziele in der laufenden Anwendung:
 
 - Swagger UI: `/api-docs/`
 - OpenAPI-Spec: `/openapi.yaml`
@@ -14,18 +14,18 @@ Beim lokalen Start mit `php -S localhost:8000` sind die URLs:
 - `http://localhost:8000/api-docs/`
 - `http://localhost:8000/openapi.yaml`
 
-Die OpenAPI-Datei ist handgeschrieben und dient als maschinenlesbare Quelle fuer die Swagger UI.
+Die OpenAPI-Datei ist handgeschrieben und dient als maschinenlesbare Quelle für die Swagger UI.
 
 ## Basis
 
 - API-Stil: REST-nah
 - Datenformat: JSON
-- Authentifizierung fuer eingehende Messwerte: Bearer-Token
+- Authentifizierung für eingehende Messwerte: Bearer-Token
 - Charset: UTF-8
 
 ## Endpunkt: `POST /api/v1/readings.php`
 
-Nimmt genau einen Sensorwert fuer genau ein Fach entgegen.
+Nimmt genau einen Sensorwert für genau ein Fach entgegen.
 
 ### Request-Header
 
@@ -66,13 +66,13 @@ Nimmt genau einen Sensorwert fuer genau ein Fach entgegen.
 ### Verhalten
 
 - Der Endpunkt akzeptiert nur `POST`.
-- Der Endpunkt beantwortet `OPTIONS` fuer Preflight-Requests mit `204 No Content`.
+- Der Endpunkt beantwortet `OPTIONS` für Preflight-Requests mit `204 No Content`.
 - Bei fehlendem oder falschem Bearer-Token antwortet die API mit `401`.
-- Bei ungueltigem JSON antwortet die API mit `400`.
-- Bei ungueltigen Feldern antwortet die API mit `422`.
+- Bei ungültigem JSON antwortet die API mit `400`.
+- Bei ungültigen 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.
+- Bei einem Statuswechsel wird zusätzlich Alarmierung ausgelöst.
 
 ### Erfolgsantwort
 
@@ -136,14 +136,14 @@ Status: `405 Method Not Allowed`
 }
 ```
 
-#### Ungueltiger JSON-Body
+#### Ungültiger JSON-Body
 
 Status: `400 Bad Request`
 
 ```json
 {
   "ok": false,
-  "error": "Ungueltiger JSON-Body."
+  "error": "Ungültiger JSON-Body."
 }
 ```
 
@@ -185,7 +185,7 @@ Liefert den aktuellen Zustand der gesamten Anwendung.
 
 - Der Endpunkt akzeptiert nur `GET`.
 - Es ist keine Authentifizierung implementiert.
-- Der Endpunkt wird vom Dashboard fuer die Auto-Aktualisierung genutzt.
+- Der Endpunkt wird vom Dashboard für die Auto-Aktualisierung genutzt.
 
 ### Erfolgsantwort
 
@@ -198,7 +198,7 @@ Beispielstruktur:
   "ok": true,
   "generated_at": "2026-04-15T20:10:00+00:00",
   "app": {
-    "name": "Getraenkeautomat Monitor",
+    "name": "Getränkeautomat Monitor",
     "dashboard_refresh_seconds": 15
   },
   "summary": {
@@ -280,4 +280,4 @@ Status: `405 Method Not Allowed`
 - `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.
+Falls Sensor-Clients aus einem anderen Netzsegment oder über Browser-Tools senden, ist das hilfreich. Eine vollständige CORS-Konfiguration mit `Access-Control-Allow-Origin` ist aktuell noch nicht Teil der App.

+ 27 - 27
docs/CONFIG.md

@@ -1,6 +1,6 @@
 # Configuration Reference
 
-Die Anwendung wird ueber `data/config.json` konfiguriert.
+Die Anwendung wird über `data/config.json` konfiguriert.
 
 ## Gesamtstruktur
 
@@ -23,7 +23,7 @@ Beispiel:
 
 ```json
 {
-  "name": "Getraenkeautomat Monitor",
+  "name": "Getränkeautomat Monitor",
   "timezone": "Europe/Berlin",
   "dashboard_refresh_seconds": 15,
   "default_from_email": "monitor@example.local",
@@ -36,22 +36,22 @@ Felder:
 - `name`
   - Anzeigename der Anwendung im Dashboard
 - `timezone`
-  - Standardzeitzone fuer die Anwendung
+  - Standardzeitzone für die Anwendung
 - `dashboard_refresh_seconds`
-  - Polling-Intervall fuer das Dashboard
+  - Polling-Intervall für das Dashboard
 - `default_from_email`
-  - Absenderadresse fuer `mail()`
+  - Absenderadresse für `mail()`
 - `base_path`
-  - optionaler URL-Pfad fuer Deployments unterhalb der Domain
-  - leer lassen fuer Betrieb direkt unter `/`
-  - Beispiel fuer `domain.de/auswertung/`: `"/auswertung"`
-  - fuehrende und abschliessende Slashes werden beim Speichern normalisiert
+  - optionaler URL-Pfad für Deployments unterhalb der Domain
+  - leer lassen für Betrieb direkt unter `/`
+  - Beispiel für `domain.de/auswertung/`: `"/auswertung"`
+  - führende und abschließende Slashes werden beim Speichern normalisiert
 
-Beispiel fuer einen Betrieb unter `https://domain.de/auswertung/`:
+Beispiel für einen Betrieb unter `https://domain.de/auswertung/`:
 
 ```json
 {
-  "name": "Getraenkeautomat Monitor",
+  "name": "Getränkeautomat Monitor",
   "timezone": "Europe/Berlin",
   "dashboard_refresh_seconds": 15,
   "default_from_email": "monitor@example.local",
@@ -72,7 +72,7 @@ Beispiel:
 Felder:
 
 - `bearer_token`
-  - statisches Token fuer die Sensor-API
+  - statisches Token für die Sensor-API
   - wird von Clients im Authorization-Header gesendet
 
 ## Bereich `admin`
@@ -120,11 +120,11 @@ Felder:
 - `name`
   - Anzeigename im Adminpanel
 - `url`
-  - Zieladresse fuer den POST-Webhook
+  - Zieladresse für den POST-Webhook
 - `enabled`
   - aktiviert oder deaktiviert den Versand
 - `headers`
-  - optionales JSON-Objekt fuer zusaetzliche HTTP-Header
+  - optionales JSON-Objekt für zusätzliche HTTP-Header
 
 ## Bereich `alerts.emails`
 
@@ -149,7 +149,7 @@ Felder:
 - `name`
   - Anzeigename im Adminpanel
 - `address`
-  - Zieladresse fuer Alarmmails
+  - Zieladresse für Alarmmails
 - `enabled`
   - aktiviert oder deaktiviert den Versand
 
@@ -179,11 +179,11 @@ Felder:
 - `location`
   - optionaler Standorttext
 - `slots`
-  - Liste der Faecher dieses Automaten
+  - Liste der Fächer dieses Automaten
 
 ## Bereich `machines[].slots`
 
-Jedes Fach repraesentiert einen Sensor und eine zugeordnete Produktposition.
+Jedes Fach repräsentiert einen Sensor und eine zugeordnete Produktposition.
 
 Beispiel:
 
@@ -206,7 +206,7 @@ Felder:
 - `sensor_id`
   - eindeutige Kennung des Sensors innerhalb des Automaten
 - `label`
-  - Kurzlabel fuer Anzeige und Alarmtexte
+  - Kurzlabel für Anzeige und Alarmtexte
 - `product_name`
   - Klartextbezeichnung des Produkts
 - `full_distance_mm`
@@ -224,27 +224,27 @@ Felder:
 
 ## Kalibrierung eines Fachs
 
-Fuer eine brauchbare Bestandschaetzung sollten pro Fach mindestens diese Werte sauber kalibriert werden:
+Für eine brauchbare Bestandsschätzung 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.
+3. Mehrere Messungen zwischen zwei benachbarten Füllständen machen.
 4. Die durchschnittliche Differenz pro Flasche als `distance_per_unit` eintragen.
-5. Einen passenden Schwellwert fuer `alert_below_units` festlegen.
+5. Einen passenden Schwellwert für `alert_below_units` festlegen.
 
 ## Referenzen zwischen Bereichen
 
-Slots referenzieren Alarmziele nicht direkt ueber URLs oder Email-Adressen, sondern ueber IDs:
+Slots referenzieren Alarmziele nicht direkt über URLs oder Email-Adressen, sondern über 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.
+Das macht die Konfiguration wartbarer, weil ein Alarmziel zentral geändert werden kann.
 
-## Tipps fuer produktive Nutzung
+## Tipps für 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
+- `machine_id` und `sensor_id` niemals nachträglich leichtfertig ändern
+- für jeden neuen Sensor zuerst Kalibrierwerte eintragen
+- Testalarme mit einem absichtlich kritischen Messwert prüfen
 - Beispiel-Tokens und Default-Zugangsdaten vor dem Live-Betrieb ersetzen

+ 38 - 29
docs/DEPLOYMENT.md

@@ -1,11 +1,11 @@
 # Deployment Guide
 
-Diese Anwendung ist jetzt fuer klassisches Apache-Shared-Hosting als einzelner App-Ordner vorbereitet.
+Diese Anwendung ist jetzt für klassisches Apache-Shared-Hosting als einzelner App-Ordner vorbereitet.
 
 Zielbild:
 
-- du laedst genau einen Ordner auf den Webspace hoch
-- dieser Ordner liegt direkt im oeffentlichen Webspace, z. B. unter `public_html/monitor/`
+- du lädst genau einen Ordner auf den Webspace hoch
+- dieser Ordner liegt direkt im öffentlichen Webspace, z. B. unter `public_html/monitor/`
 - die App ist dann unter `https://example.com/monitor/` erreichbar
 - die API liegt automatisch unter `https://example.com/monitor/api/v1/`
 - `src/`, `data/` und die Markdown-Doku bleiben im selben Ordner, sind aber per `.htaccess` gesperrt
@@ -17,19 +17,19 @@ Erforderlich:
 - Apache-Webhosting mit aktiviertem `.htaccess`-Support
 - PHP 8.1 oder neuer empfohlen
 - PHP-Sessions aktiviert
-- Schreibrechte fuer PHP auf `data/`
+- Schreibrechte für PHP auf `data/`
 
 Optional:
 
-- funktionierende `mail()`-Konfiguration fuer Email-Alarme
-- ausgehende HTTPS-Verbindungen fuer Webhooks
-- Zugriff auf `cdn.jsdelivr.net` fuer die Swagger UI unter `/api-docs/`
+- funktionierende `mail()`-Konfiguration für Email-Alarme
+- ausgehende HTTPS-Verbindungen für Webhooks
+- Zugriff auf `cdn.jsdelivr.net` für die Swagger UI unter `/api-docs/`
 
 ## Deploy-Modell
 
 Die Anwendung wird nicht mehr in `public/` und private Nachbarordner getrennt deployt. Stattdessen ist der Projektordner selbst das Deploy-Artefakt.
 
-Beispiel fuer eine Installation in einem Unterordner:
+Beispiel für eine Installation in einem Unterordner:
 
 ```text
 /home/account/public_html/
@@ -46,7 +46,8 @@ Beispiel fuer eine Installation in einem Unterordner:
     │   ├── .htaccess
     │   ├── config.json
     │   ├── state.json
-    │   └── alert_log.json
+    │   ├── alert_log.json
+    │   └── php_errors.log
     ├── docs/
     │   ├── .htaccess
     │   ├── API.md
@@ -58,13 +59,13 @@ Beispiel fuer eine Installation in einem Unterordner:
 
 Wichtig:
 
-- `index.php`, `admin/`, `api/`, `api-docs/`, `app.js`, `styles.css` und `openapi.yaml` sind oeffentlich
+- `index.php`, `admin/`, `api/`, `api-docs/`, `app.js`, `styles.css` und `openapi.yaml` sind öffentlich
 - `src/`, `data/` und `docs/` liegen im selben Ordner, werden aber durch mitgelieferte `.htaccess`-Dateien blockiert
-- `README.md` und Dotfiles werden ebenfalls ueber die Root-`.htaccess` geblockt
+- `README.md` und Dotfiles werden ebenfalls über die Root-`.htaccess` geblockt
 
 ## Empfohlene URL-Struktur
 
-Wenn du den Ordner `monitor/` in `public_html/` hochlaedst, setze in `data/config.json` oder im Adminpanel:
+Wenn du den Ordner `monitor/` in `public_html/` hochlädst, setze in `data/config.json` oder im Adminpanel:
 
 ```json
 {
@@ -89,7 +90,7 @@ Wenn du den Inhalt direkt in den Domain-Root legst, bleibt `base_path` leer.
 
 ### 1. Projektordner vorbereiten
 
-Du brauchst fuer das Hosting den kompletten Projektordner inklusive:
+Du brauchst für das Hosting den kompletten Projektordner inklusive:
 
 - Runtime-Dateien im Root
 - `admin/`
@@ -102,7 +103,7 @@ Du brauchst fuer das Hosting den kompletten Projektordner inklusive:
 
 ### 2. Ordner hochladen
 
-Lade den kompletten Ordner in den oeffentlichen Webspace hoch, zum Beispiel:
+Lade den kompletten Ordner in den öffentlichen Webspace hoch, zum Beispiel:
 
 ```text
 lokal:  /mein-projekt/automat
@@ -113,19 +114,23 @@ Danach muss Apache aus genau diesem Ordner ausliefern.
 
 ### 3. Schreibrechte setzen
 
-PHP muss in `data/` schreiben koennen.
+PHP muss in `data/` schreiben können.
 
 Typische Werte:
 
 - Verzeichnisse: `755` oder `775`
 - Dateien: `644` oder `664`
 
-Beschreibbar sein muessen mindestens:
+Beschreibbar sein müssen mindestens:
 
 - `data/config.json`
 - `data/state.json`
 - `data/alert_log.json`
 
+Empfohlen zusätzlich:
+
+- `data/php_errors.log` (wird beim ersten geloggten Fehler angelegt)
+
 ### 4. PHP-Version einstellen
 
 Im Hosting-Panel PHP `8.1` oder neuer aktivieren.
@@ -144,7 +149,7 @@ Das Feld wird im Adminpanel normalisiert. `monitor`, `/monitor` und `/monitor/`
 
 ### 6. Default-Zugangsdaten ersetzen
 
-Nach dem ersten Login sofort aendern:
+Nach dem ersten Login sofort ändern:
 
 - Admin-Benutzername
 - Admin-Passwort
@@ -171,7 +176,7 @@ Wenn dein Hosting `.htaccess` ignoriert, ist dieses Deploy-Modell nicht sicher.
 
 Sensoren senden an den Readings-Endpunkt relativ zum `base_path`.
 
-Beispiel fuer eine Installation unter `/monitor`:
+Beispiel für eine Installation unter `/monitor`:
 
 ```text
 POST https://example.com/monitor/api/v1/readings.php
@@ -189,13 +194,13 @@ Diese Checks sollten direkt funktionieren:
 4. `https://example.com/monitor/api-docs/` zeigt Swagger UI.
 5. `https://example.com/monitor/openapi.yaml` ist abrufbar.
 
-Diese Pfade duerfen nicht oeffentlich lesbar sein:
+Diese Pfade dürfen nicht öffentlich lesbar sein:
 
 1. `https://example.com/monitor/data/config.json`
 2. `https://example.com/monitor/src/bootstrap.php`
 3. `https://example.com/monitor/docs/DEPLOYMENT.md`
 
-Sie sollten `403 Forbidden` oder aehnlich blockiert werden.
+Sie sollten `403 Forbidden` oder ähnlich blockiert werden.
 
 ## Lokale Entwicklung
 
@@ -209,28 +214,32 @@ Wichtig:
 
 - der PHP Built-in Server wertet `.htaccess` nicht aus
 - der Schutz von `src/`, `data/` und `docs/` gilt deshalb lokal nicht
-- fuer den Live-Betrieb ist Apache entscheidend
+- für den Live-Betrieb ist Apache entscheidend
 
 ## Fehlerbehebung
 
 ### `403` oder `404` auf allen App-URLs
 
-Pruefen:
+Prüfen:
 
-- liegt der App-Ordner wirklich im oeffentlichen Webspace
+- liegt der App-Ordner wirklich im öffentlichen Webspace
 - liefert Apache aus diesem Ordner aus
-- ist die PHP-Version fuer diesen Ordner aktiv
+- ist die PHP-Version für diesen Ordner aktiv
 
-### Dashboard laedt, aber CSS oder JS fehlt
+### Dashboard lädt, aber CSS oder JS fehlt
 
-Pruefen:
+Prüfen:
 
-- `app.base_path` stimmt exakt mit dem URL-Unterordner ueberein
+- `app.base_path` stimmt exakt mit dem URL-Unterordner überein
 - `app.js` und `styles.css` liegen im Root des App-Ordners
 
+### PHP-Fehler finden
+
+PHP-Laufzeitfehler werden in `data/php_errors.log` geschrieben. Wenn etwas serverseitig fehlschlägt, zuerst diese Datei prüfen.
+
 ### API funktioniert nicht unter dem Unterordner
 
-Pruefen:
+Prüfen:
 
 - `app.base_path` ist z. B. `"/monitor"`
 - der Sensor postet an `/monitor/api/v1/readings.php`
@@ -240,6 +249,6 @@ Pruefen:
 
 Dann greift `.htaccess` nicht korrekt. In dem Fall:
 
-- Hosting-Konfiguration fuer `.htaccess` pruefen
+- Hosting-Konfiguration für `.htaccess` prüfen
 - `AllowOverride` bzw. das entsprechende Hosting-Feature aktivieren
 - die App nicht produktiv betreiben, bis der Zugriff blockiert ist

+ 8 - 6
index.php

@@ -5,7 +5,9 @@ declare(strict_types=1);
 require_once __DIR__ . '/src/bootstrap.php';
 
 $status = app_monitor_service()->getStatus();
-$appName = $status['app']['name'] ?? 'Getraenkeautomat Monitor';
+$appName = $status['app']['name'] ?? 'Getränkeautomat Monitor';
+
+header('Content-Type: text/html; charset=UTF-8');
 ?>
 <!DOCTYPE html>
 <html lang="de">
@@ -19,10 +21,10 @@ $appName = $status['app']['name'] ?? 'Getraenkeautomat Monitor';
     <div class="page-shell">
         <header class="hero">
             <div class="hero__copy">
-                <p class="eyebrow">Fuellstand live im Blick</p>
+                <p class="eyebrow">Füllstand 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.
+                    Überwache Sensorwerte, erkenne Leerstände frühzeitig und springe direkt ins Adminpanel, wenn sich Grenzwerte oder Alarmwege ändern sollen.
                 </p>
             </div>
             <div class="hero__stats">
@@ -31,7 +33,7 @@ $appName = $status['app']['name'] ?? 'Getraenkeautomat Monitor';
                     <strong data-summary="machine_count"><?= (int) ($status['summary']['machine_count'] ?? 0) ?></strong>
                 </div>
                 <div class="stat-card">
-                    <span>Faecher</span>
+                    <span>Fächer</span>
                     <strong data-summary="slot_count"><?= (int) ($status['summary']['slot_count'] ?? 0) ?></strong>
                 </div>
                 <div class="stat-card stat-card--alert">
@@ -57,8 +59,8 @@ $appName = $status['app']['name'] ?? 'Getraenkeautomat Monitor';
             <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>
+                        <h2>Füllstand nach Fach</h2>
+                        <p>Die Karten zeigen Messwert, geschätzten 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>

+ 6 - 6
openapi.yaml

@@ -1,10 +1,10 @@
 openapi: 3.1.0
 info:
-  title: Getraenkeautomat Monitor API
+  title: Getränkeautomat Monitor API
   version: 1.0.0
   summary: HTTP API for ingesting sensor readings and retrieving vending machine status.
   description: |
-    Handwritten OpenAPI specification for the Getraenkeautomat Monitor application.
+    Handwritten OpenAPI specification for the Getränkeautomat Monitor application.
 
     The API currently exposes two endpoints:
     - `POST /api/v1/readings.php` for ingesting one sensor measurement
@@ -79,7 +79,7 @@ paths:
                 invalidJson:
                   value:
                     ok: false
-                    error: Ungueltiger JSON-Body.
+                    error: Ungültiger JSON-Body.
         '401':
           description: Missing or invalid Bearer token.
           content:
@@ -131,7 +131,7 @@ paths:
                 invalidTimestamp:
                   value:
                     ok: false
-                    error: measured_at ist kein gueltiger ISO-Zeitstempel.
+                    error: measured_at ist kein gültiger ISO-Zeitstempel.
         '500':
           description: Unexpected internal server error.
           content:
@@ -193,7 +193,7 @@ paths:
                     ok: true
                     generated_at: '2026-04-15T20:10:00+00:00'
                     app:
-                      name: Getraenkeautomat Monitor
+                      name: Getränkeautomat Monitor
                       dashboard_refresh_seconds: 15
                     summary:
                       machine_count: 2
@@ -382,7 +382,7 @@ components:
       properties:
         name:
           type: string
-          example: Getraenkeautomat Monitor
+          example: Getränkeautomat Monitor
         dashboard_refresh_seconds:
           type: integer
           minimum: 1

+ 3 - 3
src/AlertService.php

@@ -126,7 +126,7 @@ final class AlertService
     private function sendEmails(array $slot, array $payload): array
     {
         $config = $this->configRepository->getConfig();
-        $appName = $config['app']['name'] ?? 'Getraenkeautomat Monitor';
+        $appName = $config['app']['name'] ?? 'Getränkeautomat Monitor';
         $from = $config['app']['default_from_email'] ?? 'monitor@example.local';
         $results = [];
 
@@ -136,7 +136,7 @@ final class AlertService
                 $results[] = [
                     'id' => $emailId,
                     'success' => false,
-                    'message' => 'Email-Empfaenger nicht gefunden oder deaktiviert.',
+                    'message' => 'Email-Empfänger nicht gefunden oder deaktiviert.',
                 ];
                 continue;
             }
@@ -155,7 +155,7 @@ final class AlertService
                 'Fach: ' . $payload['slot_label'] . ' / ' . $payload['sensor_id'],
                 'Produkt: ' . ($payload['product_name'] ?: 'Unbekannt'),
                 'Bestand: ' . $payload['units_estimated'] . ' von ' . $payload['max_units'],
-                'Fuellstand: ' . $payload['fill_percent'] . '%',
+                'Füllstand: ' . $payload['fill_percent'] . '%',
                 'Messwert: ' . $payload['distance_mm'] . ' mm',
                 'Zeitpunkt: ' . $payload['measured_at'],
             ]);

+ 1 - 1
src/ConfigRepository.php

@@ -12,7 +12,7 @@ final class ConfigRepository
     {
         $this->storage = new JsonFile($path, [
             'app' => [
-                'name' => 'Getraenkeautomat Monitor',
+                'name' => 'Getränkeautomat Monitor',
                 'timezone' => 'Europe/Berlin',
                 'dashboard_refresh_seconds' => 15,
                 'default_from_email' => 'monitor@example.local',

+ 3 - 3
src/JsonFile.php

@@ -20,7 +20,7 @@ final class JsonFile
 
         $handle = fopen($this->path, 'c+');
         if ($handle === false) {
-            throw new RuntimeException('JSON-Datei konnte nicht geoeffnet werden: ' . $this->path);
+            throw new RuntimeException('JSON-Datei konnte nicht geöffnet werden: ' . $this->path);
         }
 
         try {
@@ -41,7 +41,7 @@ final class JsonFile
 
         $decoded = json_decode($contents, true);
         if (!is_array($decoded)) {
-            throw new RuntimeException('JSON-Datei ist ungueltig: ' . $this->path);
+            throw new RuntimeException('JSON-Datei ist ungültig: ' . $this->path);
         }
 
         return $decoded;
@@ -56,7 +56,7 @@ final class JsonFile
 
         $handle = fopen($this->path, 'c+');
         if ($handle === false) {
-            throw new RuntimeException('JSON-Datei konnte nicht geoeffnet werden: ' . $this->path);
+            throw new RuntimeException('JSON-Datei konnte nicht geöffnet werden: ' . $this->path);
         }
 
         $json = json_encode(

+ 2 - 2
src/MonitorService.php

@@ -49,7 +49,7 @@ final class MonitorService
             'ok' => true,
             'generated_at' => gmdate(DATE_ATOM),
             'app' => [
-                'name' => $config['app']['name'] ?? 'Getraenkeautomat Monitor',
+                'name' => $config['app']['name'] ?? 'Getränkeautomat Monitor',
                 'dashboard_refresh_seconds' => (int) ($config['app']['dashboard_refresh_seconds'] ?? 15),
             ],
             'summary' => [
@@ -156,7 +156,7 @@ final class MonitorService
 
         $timestamp = strtotime($value);
         if ($timestamp === false) {
-            throw new InvalidArgumentException('measured_at ist kein gueltiger ISO-Zeitstempel.');
+            throw new InvalidArgumentException('measured_at ist kein gültiger ISO-Zeitstempel.');
         }
 
         return gmdate(DATE_ATOM, $timestamp);

+ 8 - 1
src/bootstrap.php

@@ -5,6 +5,12 @@ declare(strict_types=1);
 define('APP_ROOT', dirname(__DIR__));
 define('APP_DATA', APP_ROOT . '/data');
 
+function app_configure_php_error_logging(): void
+{
+    ini_set('log_errors', '1');
+    ini_set('error_log', APP_DATA . '/php_errors.log');
+}
+
 spl_autoload_register(static function (string $class): void {
     $prefix = 'App\\';
     if (!str_starts_with($class, $prefix)) {
@@ -20,6 +26,7 @@ spl_autoload_register(static function (string $class): void {
 });
 
 date_default_timezone_set('Europe/Berlin');
+app_configure_php_error_logging();
 
 function app_config_repository(): App\ConfigRepository
 {
@@ -139,7 +146,7 @@ function app_read_json_body(): array
 
     $decoded = json_decode($raw, true);
     if (!is_array($decoded)) {
-        throw new RuntimeException('Ungueltiger JSON-Body.');
+        throw new RuntimeException('Ungültiger JSON-Body.');
     }
 
     return $decoded;

+ 7 - 0
styles.css

@@ -452,6 +452,13 @@ textarea {
   resize: vertical;
 }
 
+.config-textarea {
+  min-height: 420px;
+  font-family: "Courier New", "SFMono-Regular", Consolas, monospace;
+  line-height: 1.45;
+  white-space: pre;
+}
+
 .inline-actions,
 .section-actions {
   display: flex;