Parcourir la source

implemented returning -1 as trasitive error, not full slot

Medowar il y a 1 mois
Parent
commit
7d4acc383c
8 fichiers modifiés avec 460 ajouts et 84 suppressions
  1. 3 0
      README.md
  2. 33 1
      app.js
  3. 33 0
      docs/API.md
  4. 110 3
      openapi.yaml
  5. 153 80
      setup_testenv.sh
  6. 65 0
      src/MonitorService.php
  7. 14 0
      src/StateRepository.php
  8. 49 0
      styles.css

+ 3 - 0
README.md

@@ -211,6 +211,8 @@ Body:
 
 `measured_at` ist optional. Wenn der Wert fehlt, setzt der Server die aktuelle Zeit.
 
+Wenn ein Client `distance_mm: -1` sendet, wird das als Messfehler des Clients gespeichert. Der letzte gute Füllstand bleibt erhalten, es wird kein Statuswechsel ausgelöst, und das Dashboard zeigt einen kleinen Fehlerhinweis am betroffenen Fach.
+
 Beispiel:
 
 ```bash
@@ -268,6 +270,7 @@ Typische Inhalte:
 - geschätzter Bestand
 - maximaler Bestand
 - aktueller Status
+- letzter Client-Messfehler (`input_error`), falls der letzte empfangene Wert `-1` war
 - verknüpfte Alarmkanal-IDs
 
 ### `data/alert_log.json`

+ 33 - 1
app.js

@@ -38,6 +38,35 @@ if (dataNode) {
     });
   };
 
+  const inputErrorHint = (slot) => {
+    const error = slot.input_error;
+    if (!error) {
+      return '';
+    }
+
+    const lastGood = error.last_good || null;
+    const lines = [
+      `Client-Messfehler empfangen: ${formatTime(error.received_at)}`,
+      `Gemeldeter Wert: ${error.reported_value ?? '–'}`,
+      lastGood
+        ? `Letzter guter Wert: ${lastGood.distance_mm ?? '–'} mm, ${
+            lastGood.units_estimated ?? '–'
+          } / ${lastGood.max_units ?? '–'} Flaschen, ${lastGood.fill_percent ?? '–'}%, gemessen ${formatTime(
+            lastGood.measured_at
+          )}`
+        : 'Noch kein guter Messwert vorhanden.',
+    ];
+    const popupText = lines.map(escapeHtml).join('<br>');
+    const label = lines.join(' ');
+
+    return `
+      <span class="input-error-hint" tabindex="0" title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">
+        <span aria-hidden="true">❗</span>
+        <span class="input-error-hint__popup">${popupText}</span>
+      </span>
+    `;
+  };
+
   const renderFilters = () => {
     const machines = currentStatus.machines || [];
     const buttons = [
@@ -68,7 +97,10 @@ if (dataNode) {
             <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 class="slot-card__status">
+            ${inputErrorHint(slot)}
+            <span class="status-pill status-pill--${escapeHtml(state)}">${stateLabel}</span>
+          </div>
         </div>
         <div class="slot-card__body">
           <div class="fill-tube" style="--fill:${fillPercent}%">

+ 33 - 0
docs/API.md

@@ -57,6 +57,7 @@ Nimmt genau einen Sensorwert für genau ein Fach entgegen.
   - Typ: Zahl
   - Pflichtfeld: ja
   - Bedeutung: gemessene Entfernung in Millimetern
+  - Sonderwert: `-1` bedeutet, dass der Client einen Messfehler meldet. In diesem Fall bleibt der letzte gute Zustand erhalten und es wird ein `input_error` am Fach gespeichert.
 - `measured_at`
   - Typ: String
   - Pflichtfeld: nein
@@ -72,6 +73,7 @@ Nimmt genau einen Sensorwert für genau ein Fach entgegen.
 - 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 `distance_mm: -1` wird kein neuer Füllstand berechnet und kein Alarmstatuswechsel ausgelöst. Das Dashboard zeigt weiter den letzten guten Wert und markiert das Fach mit einem Fehlerhinweis.
 - Bei einem Statuswechsel wird zusätzlich Alarmierung ausgelöst.
 
 ### Erfolgsantwort
@@ -90,6 +92,36 @@ Status: `200 OK`
 }
 ```
 
+### Erfolgsantwort bei Client-Messfehler
+
+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",
+  "input_error": {
+    "received_at": "2026-04-15T19:22:10+00:00",
+    "measured_at": "2026-04-15T19:22:09+00:00",
+    "reported_value": -1,
+    "message": "Client meldete einen Messfehler.",
+    "last_good": {
+      "distance_mm": 184,
+      "fill_percent": 63,
+      "units_estimated": 4,
+      "max_units": 7,
+      "state": "ok",
+      "measured_at": "2026-04-15T19:20:00+00:00"
+    }
+  }
+}
+```
+
 ### Fehlerantworten
 
 #### Nicht autorisiert
@@ -239,6 +271,7 @@ GET https://example.com/monitor/api/v1/status.php
 - `measured_at`
 - `updated_at`
 - `alert_below_units`
+- `input_error` falls der letzte empfangene Wert `-1` war
 
 ### Wichtige Felder in `alerts[]`
 

+ 110 - 3
openapi.yaml

@@ -33,6 +33,8 @@ paths:
         - Requires a Bearer token in the `Authorization` header
         - Accepts JSON request bodies
         - Updates `data/state.json` on success
+        - Treats `distance_mm: -1` as a client-side measurement error, keeps
+          the last good slot value, and stores input-error metadata for the UI
         - Triggers alerts only when the slot state changes from `ok` to `critical`
           or from `critical` to `ok`
       operationId: submitReading
@@ -52,6 +54,13 @@ paths:
                   sensor_id: fach-a1
                   distance_mm: 184
                   measured_at: '2026-04-15T19:20:00Z'
+              clientError:
+                summary: Client-side measurement error for the A1 slot
+                value:
+                  machine_id: automat-lobby
+                  sensor_id: fach-a1
+                  distance_mm: -1
+                  measured_at: '2026-04-15T19:22:09Z'
       responses:
         '200':
           description: Reading processed successfully.
@@ -69,6 +78,27 @@ paths:
                     units_estimated: 4
                     fill_percent: 63
                     state: ok
+                clientError:
+                  value:
+                    ok: true
+                    machine_id: automat-lobby
+                    sensor_id: fach-a1
+                    slot_label: A1
+                    units_estimated: 4
+                    fill_percent: 63
+                    state: ok
+                    input_error:
+                      received_at: '2026-04-15T19:22:10+00:00'
+                      measured_at: '2026-04-15T19:22:09+00:00'
+                      reported_value: -1
+                      message: Client meldete einen Messfehler.
+                      last_good:
+                        distance_mm: 184
+                        fill_percent: 63
+                        units_estimated: 4
+                        max_units: 7
+                        state: ok
+                        measured_at: '2026-04-15T19:20:00+00:00'
         '400':
           description: Invalid JSON body or malformed request payload.
           content:
@@ -282,7 +312,7 @@ components:
           example: fach-a1
         distance_mm:
           type: number
-          description: Measured distance in millimeters.
+          description: Measured distance in millimeters. The sentinel value `-1` reports a client-side measurement error and does not replace the last good slot value.
           example: 184
         measured_at:
           type:
@@ -319,13 +349,21 @@ components:
           type: string
           example: A1
         units_estimated:
-          type: integer
+          type:
+            - integer
+            - 'null'
           example: 4
         fill_percent:
-          type: integer
+          type:
+            - integer
+            - 'null'
           example: 63
         state:
           $ref: '#/components/schemas/SlotState'
+        input_error:
+          oneOf:
+            - $ref: '#/components/schemas/InputError'
+            - type: 'null'
     ErrorResponse:
       type: object
       additionalProperties: false
@@ -501,6 +539,75 @@ components:
           type: integer
           minimum: 0
           example: 2
+        input_error:
+          oneOf:
+            - $ref: '#/components/schemas/InputError'
+            - type: 'null'
+          description: Present when the most recent submitted value for this slot was `-1`.
+    InputError:
+      type: object
+      additionalProperties: false
+      required:
+        - received_at
+        - measured_at
+        - reported_value
+        - message
+        - last_good
+      properties:
+        received_at:
+          type: string
+          format: date-time
+          description: Server time when the `-1` value was received.
+          example: '2026-04-15T19:22:10+00:00'
+        measured_at:
+          type: string
+          format: date-time
+          description: Timestamp supplied by the client for the failed reading, or the server time when omitted.
+          example: '2026-04-15T19:22:09+00:00'
+        reported_value:
+          type: integer
+          const: -1
+        message:
+          type: string
+          example: Client meldete einen Messfehler.
+        last_good:
+          oneOf:
+            - $ref: '#/components/schemas/LastGoodReading'
+            - type: 'null'
+          description: Last successfully calculated slot data, if any exists.
+    LastGoodReading:
+      type: object
+      additionalProperties: false
+      required:
+        - distance_mm
+        - fill_percent
+        - units_estimated
+        - max_units
+        - state
+        - measured_at
+      properties:
+        distance_mm:
+          type: number
+          example: 184
+        fill_percent:
+          type: integer
+          minimum: 0
+          maximum: 100
+          example: 63
+        units_estimated:
+          type: integer
+          minimum: 0
+          example: 4
+        max_units:
+          type: integer
+          minimum: 0
+          example: 7
+        state:
+          $ref: '#/components/schemas/SlotState'
+        measured_at:
+          type: string
+          format: date-time
+          example: '2026-04-15T19:20:00+00:00'
     SlotState:
       type: string
       enum:

+ 153 - 80
setup_testenv.sh

@@ -1,98 +1,155 @@
 #!/bin/bash
 #
-# setup_testenv.sh — Local test environment setup for Feuerwehr Getränkeautomat Status
-# Usage: sudo bash setup_testenv.sh
-# Prerequisite: Project must already be located under /var/www
+# setup_testenv.sh — General PHP development environment setup for Fedora
+# Usage: bash setup_testenv.sh
+# Sets up Apache (httpd) with PHP to serve /var/www/html on localhost
+# Requires sudo privileges for package installation and service management
+#
+# PRIVILEGE SEPARATION: This script must NOT be run as root.
+# The running user should not have direct rights to install packages
+# (i.e., must use sudo which requires authentication).
 #
 
 set -euo pipefail
 
-# ── Determine project root (directory containing this script) ──────────────
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$SCRIPT_DIR"
-
-# ── 1. Install Apache, PHP, and required modules ────────────────────────────
-echo "→ Installing Apache (httpd), PHP, and required modules..."
+# ── Colors for output ─────────────────────────────────────────────────────
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
 
-sudo dnf install -y httpd php php-json php-mbstring php-xml jq
+# ── Check for privilege separation ─────────────────────────────────────────
+echo "→ Checking user privileges..."
 
-echo "✓ Packages installed"
+CURRENT_USER=$(whoami)
 
-# ── 2. Verify project is under /var/www ───────────────────────────────────
-if [[ "$PROJECT_ROOT" != /var/www* ]]; then
-    echo "ERROR: Project must be located under /var/www"
-    echo "  Current location: $PROJECT_ROOT"
-    echo "  Please move the project to /var/www (e.g. /var/www/feuerwehr-getraenkeautomat-status)"
-    echo "  and run this script again from that location."
+# Check if running as root (violates privilege separation)
+if [[ $EUID -eq 0 ]]; then
+    echo -e "${RED}ERROR: This script should not be run as root directly.${NC}"
+    echo "  Please run as a normal user with sudo privileges."
+    echo "  This ensures proper separation of privileges."
     exit 1
 fi
 
-echo "✓ Project location verified: $PROJECT_ROOT"
-
-# ── 3. Update data/config.json to set base_path to /automat/ ───────────────
-CONFIG_FILE="$PROJECT_ROOT/data/config.json"
+echo -e "${GREEN}✓${NC} Not running as root (good for privilege separation)"
 
-if [[ ! -f "$CONFIG_FILE" ]]; then
-    echo "ERROR: Config file not found: $CONFIG_FILE"
-    exit 1
+# Check if user has sudo privileges (Fedora uses wheel group for sudo access)
+if groups | grep -qw wheel; then
+    echo -e "${GREEN}✓${NC} User '$CURRENT_USER' is in wheel group (has sudo privileges)"
+else
+    echo -e "${YELLOW}WARNING:${NC} User '$CURRENT_USER' is not in the wheel group."
+    echo "  This user may not have sudo privileges required for this script."
+    echo "  The script will attempt to use sudo, which may prompt for a password."
+    echo ""
+    read -p "  Continue anyway? (y/N) " -n 1 -r
+    echo
+    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+        echo "Aborting."
+        exit 1
+    fi
 fi
 
-echo "→ Updating config: app.base_path → /automat/"
-
-jq '.app.base_path = "/automat/"' "$CONFIG_FILE" > "${CONFIG_FILE}.tmp"
-mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE"
-
-echo "✓ Config updated"
+echo ""
+echo "Note: sudo commands will prompt for your password if required."
 
-# ── 4. Set ownership and permissions for Apache + current user ─────────────
-CURRENT_USER=$(whoami)
+# ── Configuration ─────────────────────────────────────────────────────────
+DOCROOT="/var/www/html"
 APACHE_USER="apache"
 APACHE_GROUP="apache"
+APACHE_CONF="/etc/httpd/conf.d/dev.conf"
+
+# ── 1. Install Apache, PHP, and required modules ──────────────────────────
+echo ""
+echo "→ Installing Apache (httpd), PHP, and required modules..."
+
+sudo dnf install -y httpd php php-json php-mbstring php-xml php-fpm jq acl
 
+echo -e "${GREEN}✓${NC} Packages installed"
+
+# ── 2. Ensure document root exists ────────────────────────────────────────
+echo ""
+echo "→ Ensuring document root exists: $DOCROOT"
+
+sudo mkdir -p "$DOCROOT"
+echo -e "${GREEN}✓${NC} Document root ready"
+
+# ── 3. Set ownership and permissions for Apache + current user ────────────
+echo ""
 echo "→ Setting permissions for user '$CURRENT_USER' and Apache user '$APACHE_USER'..."
 
-# Ensure ACL support is available
+# Ensure ACL support is available (should be installed now)
 if ! command -v setfacl &>/dev/null; then
     echo "  Installing ACL tools..."
-    dnf install -y acl
+    sudo dnf install -y acl
 fi
 
-# Set ACLs so both the current user and Apache can read/write
-setfacl -R -m u:"$CURRENT_USER":rwx,u:"$APACHE_USER":rwx "$PROJECT_ROOT"
-setfacl -R -d -m u:"$CURRENT_USER":rwx,u:"$APACHE_USER":rwx "$PROJECT_ROOT"
+# Set ownership to current user (so user can easily create files)
+sudo chown -R "$CURRENT_USER:$CURRENT_USER" "$DOCROOT"
 
-# Also ensure the data directory itself is writable
-chmod 0775 "$PROJECT_ROOT/data"
-chmod 0664 "$PROJECT_ROOT/data/"*.json 2>/dev/null || true
+# Set base permissions
+sudo chmod 0755 "$DOCROOT"
 
-echo "✓ Permissions configured (ACLs set for $CURRENT_USER and $APACHE_USER)"
+# Set ACLs so both the current user and Apache can read/write
+sudo setfacl -R -m u:"$CURRENT_USER":rwx,u:"$APACHE_USER":rwx "$DOCROOT"
+sudo setfacl -R -d -m u:"$CURRENT_USER":rwx,u:"$APACHE_USER":rwx "$DOCROOT"
 
-# ── 5. Create Apache alias config for /automat ─────────────────────────────
-APACHE_CONF="/etc/httpd/conf.d/automat.conf"
+# Also allow the apache group to access
+sudo setfacl -R -m g:"$APACHE_GROUP":r-x "$DOCROOT"
+sudo setfacl -R -d -m g:"$APACHE_GROUP":r-x "$DOCROOT"
 
-echo "→ Creating Apache configuration: $APACHE_CONF"
+echo -e "${GREEN}✓${NC} Permissions configured (ACLs set for $CURRENT_USER and $APACHE_USER)"
 
-sudo cat > "$APACHE_CONF" << EOF
-Alias /automat "$PROJECT_ROOT"
+# ── 4. Create/update Apache configuration ─────────────────────────────────
+echo ""
+echo "→ Creating Apache configuration: $APACHE_CONF"
 
-<Directory "$PROJECT_ROOT">
+sudo tee "$APACHE_CONF" > /dev/null << EOF
+# Development environment - serve /var/www/html on localhost
+<Directory "$DOCROOT">
     Options Indexes FollowSymLinks
     AllowOverride All
     Require all granted
+    
+    # Enable directory listing if no index file found
+    DirectoryIndex index.php index.html index.htm
 </Directory>
+
+# Ensure PHP files are processed
+<FilesMatch \.php$>
+    SetHandler application/x-httpd-php
+</FilesMatch>
 EOF
 
-echo "✓ Apache configuration written"
+echo -e "${GREEN}✓${NC} Apache configuration written"
+
+# ── 5. Configure PHP for development ──────────────────────────────────────
+echo ""
+echo "→ Configuring PHP for development..."
+
+PHP_INI="/etc/php.ini"
+if [[ -f "$PHP_INI" ]]; then
+    # Backup original
+    sudo cp "$PHP_INI" "$PHP_INI.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true
+    
+    # Enable error display for development
+    sudo sed -i 's/^display_errors = .*/display_errors = On/' "$PHP_INI" 2>/dev/null || true
+    sudo sed -i 's/^display_startup_errors = .*/display_startup_errors = On/' "$PHP_INI" 2>/dev/null || true
+    sudo sed -i 's/^error_reporting = .*/error_reporting = E_ALL/' "$PHP_INI" 2>/dev/null || true
+    
+    echo -e "${GREEN}✓${NC} PHP configured for development (errors displayed)"
+fi
 
 # ── 6. Enable and start httpd ─────────────────────────────────────────────
+echo ""
 echo "→ Enabling and starting httpd..."
 
-systemctl enable httpd
-systemctl restart httpd
+sudo systemctl enable httpd
+sudo systemctl restart httpd
 
-echo "✓ Apache (httpd) is running"
+echo -e "${GREEN}${NC} Apache (httpd) is running"
 
 # ── 7. Configure SELinux permissions ─────────────────────────────────────
+echo ""
 echo "→ Configuring SELinux permissions..."
 
 # Check if SELinux is enabled
@@ -107,30 +164,22 @@ if command -v getenforce &>/dev/null; then
             sudo dnf install -y policycoreutils-python-utils
         fi
         
-        # Set SELinux context for web content (readable by httpd)
-        echo "  → Setting httpd_sys_content_t context for project files..."
-        sudo semanage fcontext -a -t httpd_sys_content_t "$PROJECT_ROOT(/.*)?" 2>/dev/null || \
-        sudo semanage fcontext -m -t httpd_sys_content_t "$PROJECT_ROOT(/.*)?" 2>/dev/null || true
-        
-        # Set SELinux context for data directory (writable by httpd/PHP)
-        echo "  → Setting httpd_sys_rw_content_t context for data directory..."
-        sudo semanage fcontext -a -t httpd_sys_rw_content_t "$PROJECT_ROOT/data(/.*)?" 2>/dev/null || \
-        sudo semanage fcontext -m -t httpd_sys_rw_content_t "$PROJECT_ROOT/data(/.*)?" 2>/dev/null || true
+        # Set SELinux context for web content (readable/writable by httpd)
+        echo "  → Setting httpd_sys_rw_content_t context for $DOCROOT..."
+        sudo semanage fcontext -a -t httpd_sys_rw_content_t "$DOCROOT(/.*)?" 2>/dev/null || \
+        sudo semanage fcontext -m -t httpd_sys_rw_content_t "$DOCROOT(/.*)?" 2>/dev/null || true
         
         # Apply the contexts
-        sudo restorecon -Rv "$PROJECT_ROOT" 2>/dev/null || true
+        sudo restorecon -Rv "$DOCROOT" 2>/dev/null || true
         
-        # Allow httpd to read/write to the data directory via PHP
+        # Allow httpd to read/write to the document root via PHP
         echo "  → Setting SELinux boolean: httpd_can_network_connect..."
         sudo setsebool -P httpd_can_network_connect on 2>/dev/null || true
         
-        # If using PHP-FPM, allow httpd to connect to FPM socket
-        if systemctl is-active php-fpm &>/dev/null; then
-            echo "  → Allowing httpd to connect to PHP-FPM..."
-            sudo setsebool -P httpd_can_network_relay on 2>/dev/null || true
-        fi
+        # Allow httpd to send mail (sometimes needed for PHP mail())
+        sudo setsebool -P httpd_can_sendmail on 2>/dev/null || true
         
-        echo "✓ SELinux contexts configured"
+        echo -e "${GREEN}✓${NC} SELinux contexts configured"
     else
         echo "  SELinux is $SELINUX_STATUS, skipping SELinux configuration"
     fi
@@ -139,28 +188,52 @@ else
 fi
 
 # ── 8. Open firewall for HTTP ─────────────────────────────────────────────
+echo ""
 echo "→ Configuring firewall for HTTP..."
 
 if command -v firewall-cmd &>/dev/null; then
-    firewall-cmd --permanent --add-service=http 2>/dev/null || true
-    firewall-cmd --reload 2>/dev/null || true
-    echo "✓ Firewall updated (HTTP service added)"
+    sudo firewall-cmd --permanent --add-service=http 2>/dev/null || true
+    sudo firewall-cmd --reload 2>/dev/null || true
+    echo -e "${GREEN}${NC} Firewall updated (HTTP service added)"
 else
     echo "  (firewall-cmd not found, skipping firewall configuration)"
 fi
 
+# ── 9. Create a test PHP file ─────────────────────────────────────────────
+echo ""
+echo "→ Creating test PHP file..."
+
+TEST_FILE="$DOCROOT/info.php"
+sudo tee "$TEST_FILE" > /dev/null << 'EOF'
+<?php
+phpinfo();
+?>
+EOF
+
+# Set proper ownership on the test file
+sudo chown "$CURRENT_USER:$CURRENT_USER" "$TEST_FILE"
+sudo setfacl -m u:"$APACHE_USER":r "$TEST_FILE"
+
+echo -e "${GREEN}✓${NC} Test file created: $TEST_FILE"
+
 # ── Done ───────────────────────────────────────────────────────────────────
 echo ""
-echo "========================================"
-echo "  Setup complete!"
-echo "========================================"
+echo -e "${GREEN}========================================${NC}"
+echo -e "${GREEN}  Setup complete!${NC}"
+echo -e "${GREEN}========================================${NC}"
 echo ""
-echo "  Project root : $PROJECT_ROOT"
-echo "  Served at    : http://localhost/automat/"
+echo "  Document root : $DOCROOT"
+echo "  Served at     : http://localhost/"
+echo "  Test page     : http://localhost/info.php"
 echo ""
 echo "  Next steps:"
-echo "  1. Open http://localhost/automat/ in your browser"
-echo "  2. Check httpd status: sudo systemctl status httpd"
-echo "  3. Check PHP errors:   $PROJECT_ROOT/data/php_errors.log"
-echo "  4. Check SELinux denials: sudo ausearch -m AVC -ts recent"
+echo "  1. Open http://localhost/ in your browser"
+echo "  2. Place your PHP files in $DOCROOT"
+echo "  3. Check httpd status:   sudo systemctl status httpd"
+echo "  4. View PHP errors:      sudo tail -f /var/log/httpd/error_log"
+echo "  5. Check SELinux denials: sudo ausearch -m AVC -ts recent"
+echo ""
+echo -e "${YELLOW}Note:${NC} Files created in $DOCROOT will be owned by $CURRENT_USER"
+echo "      and accessible by Apache. Use standard file permissions"
+echo "      or ACLs if you need to adjust access for specific files."
 echo ""

+ 65 - 0
src/MonitorService.php

@@ -85,6 +85,22 @@ final class MonitorService
         $distanceMm = (float) $payload['distance_mm'];
         $measuredAt = $this->normalizeMeasuredAt($payload['measured_at'] ?? null);
         $previous = $this->stateRepository->getSlotState($machineId, $sensorId);
+
+        if ($distanceMm === -1.0) {
+            $slotState = $this->recordInputError($machine, $slot, $machineId, $sensorId, $previous, $measuredAt);
+
+            return [
+                'ok' => true,
+                'machine_id' => $machineId,
+                'sensor_id' => $sensorId,
+                'slot_label' => $slotState['slot_label'] ?? ($slot['label'] ?? $sensorId),
+                'units_estimated' => $slotState['units_estimated'] ?? null,
+                'fill_percent' => $slotState['fill_percent'] ?? null,
+                'state' => $slotState['state'] ?? 'unknown',
+                'input_error' => $slotState['input_error'] ?? null,
+            ];
+        }
+
         $metrics = $this->inventoryService->calculate($slot, $distanceMm);
 
         $slotState = array_merge($metrics, [
@@ -144,6 +160,55 @@ final class MonitorService
         return array_merge($base, $existingState);
     }
 
+    private function recordInputError(
+        array $machine,
+        array $slot,
+        string $machineId,
+        string $sensorId,
+        ?array $previous,
+        string $measuredAt
+    ): array {
+        $lastGood = $previous === null ? null : [
+            'distance_mm' => $previous['distance_mm'] ?? null,
+            'fill_percent' => $previous['fill_percent'] ?? null,
+            'units_estimated' => $previous['units_estimated'] ?? null,
+            'max_units' => $previous['max_units'] ?? null,
+            'state' => $previous['state'] ?? null,
+            'measured_at' => $previous['measured_at'] ?? null,
+        ];
+
+        $lastGood = $lastGood === null || $lastGood['distance_mm'] === null ? null : $lastGood;
+        $error = [
+            'received_at' => gmdate(DATE_ATOM),
+            'measured_at' => $measuredAt,
+            'reported_value' => -1,
+            'message' => 'Client meldete einen Messfehler.',
+            'last_good' => $lastGood,
+        ];
+
+        $slotState = $this->stateRepository->recordSlotInputError($machineId, $sensorId, $error);
+
+        return array_merge([
+            '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,
+            'alert_below_units' => (int) ($slot['alert_below_units'] ?? 0),
+        ], $slotState);
+    }
+
     private function normalizeMeasuredAt(mixed $value): string
     {
         if (!is_string($value) || trim($value) === '') {

+ 14 - 0
src/StateRepository.php

@@ -35,6 +35,20 @@ final class StateRepository
         $this->storage->write($state);
     }
 
+    public function recordSlotInputError(string $machineId, string $sensorId, array $error): array
+    {
+        $state = $this->getState();
+        $key = $this->buildKey($machineId, $sensorId);
+        $slotState = $state['slots'][$key] ?? [];
+        $slotState['input_error'] = $error;
+        $slotState['updated_at'] = gmdate(DATE_ATOM);
+
+        $state['slots'][$key] = $slotState;
+        $this->storage->write($state);
+
+        return $slotState;
+    }
+
     private function buildKey(string $machineId, string $sensorId): string
     {
         return $machineId . '::' . $sensorId;

+ 49 - 0
styles.css

@@ -298,6 +298,55 @@ button:hover {
   color: rgba(29, 30, 26, 0.62);
 }
 
+.slot-card__status {
+  display: inline-flex;
+  align-items: flex-start;
+  gap: 0.45rem;
+}
+
+.input-error-hint {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 1.8rem;
+  height: 1.8rem;
+  border-radius: 999px;
+  background: rgba(224, 163, 59, 0.16);
+  cursor: help;
+  font-size: 0.9rem;
+}
+
+.input-error-hint:focus {
+  outline: 2px solid rgba(184, 105, 21, 0.48);
+  outline-offset: 2px;
+}
+
+.input-error-hint__popup {
+  position: absolute;
+  z-index: 4;
+  top: calc(100% + 0.5rem);
+  right: 0;
+  width: min(280px, 72vw);
+  padding: 0.75rem 0.85rem;
+  border-radius: 14px;
+  background: rgba(29, 30, 26, 0.94);
+  color: #fff8ed;
+  box-shadow: 0 14px 34px rgba(29, 30, 26, 0.22);
+  font-size: 0.82rem;
+  line-height: 1.45;
+  opacity: 0;
+  pointer-events: none;
+  transform: translateY(-4px);
+  transition: opacity 140ms ease, transform 140ms ease;
+}
+
+.input-error-hint:hover .input-error-hint__popup,
+.input-error-hint:focus .input-error-hint__popup {
+  opacity: 1;
+  transform: translateY(0);
+}
+
 .fill-tube {
   position: relative;
   width: 82px;