From f834d70859c66c60d10d367cd2b34ac8560813df Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Wed, 11 Feb 2026 21:04:10 +0100 Subject: [PATCH] open_workshop_mqtt entfernt --- .../FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md | 716 ------------------ open_workshop_mqtt/IMPLEMENTATION_PLAN.md | 381 ---------- open_workshop_mqtt/PHASE2_TESTING.md | 235 ------ open_workshop_mqtt/README.md | 165 ---- open_workshop_mqtt/TODO.md | 336 -------- open_workshop_mqtt/__init__.py | 4 - open_workshop_mqtt/__manifest__.py | 49 -- open_workshop_mqtt/check_routes.py | 34 - open_workshop_mqtt/controllers/__init__.py | 3 - open_workshop_mqtt/controllers/iot_api.py | 312 -------- open_workshop_mqtt/iot_bridge/.dockerignore | 61 -- open_workshop_mqtt/iot_bridge/.gitignore | 48 -- open_workshop_mqtt/iot_bridge/Dockerfile | 57 -- open_workshop_mqtt/iot_bridge/README.md | 402 ---------- open_workshop_mqtt/iot_bridge/config.py | 105 --- open_workshop_mqtt/iot_bridge/config.yaml | 38 - open_workshop_mqtt/iot_bridge/config.yaml.dev | 57 -- .../iot_bridge/config.yaml.example | 35 - open_workshop_mqtt/iot_bridge/event_queue.py | 197 ----- open_workshop_mqtt/iot_bridge/logger_setup.py | 35 - open_workshop_mqtt/iot_bridge/main.py | 210 ----- open_workshop_mqtt/iot_bridge/mqtt_client.py | 144 ---- open_workshop_mqtt/iot_bridge/odoo_client.py | 184 ----- .../iot_bridge/requirements.txt | 36 - .../iot_bridge/session_detector.py | 308 -------- .../iot_bridge/shelly_parser.py | 79 -- .../iot_bridge/tests/__init__.py | 1 - .../iot_bridge/tests/integration/__init__.py | 1 - .../integration/test_bridge_integration.py | 459 ----------- .../tests/integration/test_retry_logic.py | 183 ----- .../iot_bridge/tests/tools/__init__.py | 1 - .../tests/tools/shelly_simulator.py | 190 ----- .../iot_bridge/tests/unit/__init__.py | 1 - .../iot_bridge/tests/unit/test_event_queue.py | 258 ------- .../tests/unit/test_session_detector.py | 233 ------ open_workshop_mqtt/models/__init__.py | 5 - open_workshop_mqtt/models/iot_event.py | 197 ----- open_workshop_mqtt/models/mqtt_device.py | 322 -------- open_workshop_mqtt/models/mqtt_session.py | 326 -------- .../python_prototype/.gitignore | 31 - open_workshop_mqtt/python_prototype/README.md | 133 ---- .../python_prototype/config.yaml.example | 70 -- .../python_prototype/event_normalizer.py | 192 ----- .../python_prototype/event_storage.py | 232 ------ open_workshop_mqtt/python_prototype/main.py | 286 ------- .../python_prototype/mqtt_client.py | 198 ----- .../python_prototype/requirements.txt | 17 - .../python_prototype/session_detector.py | 514 ------------- open_workshop_mqtt/python_prototype/setup.sh | 73 -- .../python_prototype/shelly_parser.py | 201 ----- .../python_prototype/tests/__init__.py | 3 - .../tests/integration/__init__.py | 3 - .../integration/test_mqtt_integration.py | 550 -------------- .../python_prototype/tests/tools/__init__.py | 3 - .../tests/tools/shelly_simulator.py | 375 --------- .../python_prototype/tests/unit/__init__.py | 3 - .../tests/unit/test_session_detector.py | 396 ---------- open_workshop_mqtt/run-tests.sh | 115 --- .../security/ir.model.access.csv | 7 - open_workshop_mqtt/test-direct.sh | 21 - open_workshop_mqtt/test-output.txt | 6 - open_workshop_mqtt/tests/__init__.py | 4 - open_workshop_mqtt/tests/common.py | 22 - .../tests/test_device_status.py | 84 -- .../tests/test_session_detection.py | 115 --- open_workshop_mqtt/tests/test_start_stop.py | 67 -- open_workshop_mqtt/views/iot_event_views.xml | 113 --- .../views/mqtt_device_views.xml | 249 ------ open_workshop_mqtt/views/mqtt_menus.xml | 41 - .../views/mqtt_session_views.xml | 193 ----- 70 files changed, 10725 deletions(-) delete mode 100644 open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md delete mode 100644 open_workshop_mqtt/IMPLEMENTATION_PLAN.md delete mode 100644 open_workshop_mqtt/PHASE2_TESTING.md delete mode 100644 open_workshop_mqtt/README.md delete mode 100644 open_workshop_mqtt/TODO.md delete mode 100644 open_workshop_mqtt/__init__.py delete mode 100644 open_workshop_mqtt/__manifest__.py delete mode 100644 open_workshop_mqtt/check_routes.py delete mode 100644 open_workshop_mqtt/controllers/__init__.py delete mode 100644 open_workshop_mqtt/controllers/iot_api.py delete mode 100644 open_workshop_mqtt/iot_bridge/.dockerignore delete mode 100644 open_workshop_mqtt/iot_bridge/.gitignore delete mode 100644 open_workshop_mqtt/iot_bridge/Dockerfile delete mode 100644 open_workshop_mqtt/iot_bridge/README.md delete mode 100644 open_workshop_mqtt/iot_bridge/config.py delete mode 100644 open_workshop_mqtt/iot_bridge/config.yaml delete mode 100644 open_workshop_mqtt/iot_bridge/config.yaml.dev delete mode 100644 open_workshop_mqtt/iot_bridge/config.yaml.example delete mode 100644 open_workshop_mqtt/iot_bridge/event_queue.py delete mode 100644 open_workshop_mqtt/iot_bridge/logger_setup.py delete mode 100644 open_workshop_mqtt/iot_bridge/main.py delete mode 100644 open_workshop_mqtt/iot_bridge/mqtt_client.py delete mode 100644 open_workshop_mqtt/iot_bridge/odoo_client.py delete mode 100644 open_workshop_mqtt/iot_bridge/requirements.txt delete mode 100644 open_workshop_mqtt/iot_bridge/session_detector.py delete mode 100644 open_workshop_mqtt/iot_bridge/shelly_parser.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/__init__.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/integration/__init__.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/integration/test_bridge_integration.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/integration/test_retry_logic.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/tools/__init__.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/tools/shelly_simulator.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/unit/__init__.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/unit/test_event_queue.py delete mode 100644 open_workshop_mqtt/iot_bridge/tests/unit/test_session_detector.py delete mode 100644 open_workshop_mqtt/models/__init__.py delete mode 100644 open_workshop_mqtt/models/iot_event.py delete mode 100644 open_workshop_mqtt/models/mqtt_device.py delete mode 100644 open_workshop_mqtt/models/mqtt_session.py delete mode 100644 open_workshop_mqtt/python_prototype/.gitignore delete mode 100644 open_workshop_mqtt/python_prototype/README.md delete mode 100644 open_workshop_mqtt/python_prototype/config.yaml.example delete mode 100644 open_workshop_mqtt/python_prototype/event_normalizer.py delete mode 100644 open_workshop_mqtt/python_prototype/event_storage.py delete mode 100644 open_workshop_mqtt/python_prototype/main.py delete mode 100644 open_workshop_mqtt/python_prototype/mqtt_client.py delete mode 100644 open_workshop_mqtt/python_prototype/requirements.txt delete mode 100644 open_workshop_mqtt/python_prototype/session_detector.py delete mode 100755 open_workshop_mqtt/python_prototype/setup.sh delete mode 100644 open_workshop_mqtt/python_prototype/shelly_parser.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/__init__.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/integration/__init__.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/tools/__init__.py delete mode 100755 open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/unit/__init__.py delete mode 100644 open_workshop_mqtt/python_prototype/tests/unit/test_session_detector.py delete mode 100755 open_workshop_mqtt/run-tests.sh delete mode 100644 open_workshop_mqtt/security/ir.model.access.csv delete mode 100644 open_workshop_mqtt/test-direct.sh delete mode 100644 open_workshop_mqtt/test-output.txt delete mode 100644 open_workshop_mqtt/tests/__init__.py delete mode 100644 open_workshop_mqtt/tests/common.py delete mode 100644 open_workshop_mqtt/tests/test_device_status.py delete mode 100644 open_workshop_mqtt/tests/test_session_detection.py delete mode 100644 open_workshop_mqtt/tests/test_start_stop.py delete mode 100644 open_workshop_mqtt/views/iot_event_views.xml delete mode 100644 open_workshop_mqtt/views/mqtt_device_views.xml delete mode 100644 open_workshop_mqtt/views/mqtt_menus.xml delete mode 100644 open_workshop_mqtt/views/mqtt_session_views.xml diff --git a/open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md b/open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md deleted file mode 100644 index cac6416..0000000 --- a/open_workshop_mqtt/FEATURE_REQUEST_OPEN_WORKSHOP_MQTT_IoT.md +++ /dev/null @@ -1,716 +0,0 @@ -# Projektplan: MQTT-basierte IoT-Events in Odoo 18 Community (Hardware-First mit Shelly PM Mini G3) - -Stand: 2026-01-22 (aktualisiert) -Original: 2026-01-10 : https://chatgpt.com/share/696e559d-1640-800f-9d53-f7a9d1e784bd - -Ziel: **Odoo-18-Community (self-hosted)** soll **Geräte-Events** (Timer/Maschinenlaufzeit, Waage, Zustandswechsel) über **MQTT** aufnehmen und in Odoo als **saubere, nachvollziehbare Sessions/Events** verarbeiten. -**Änderung der Vorgehensweise**: Entwicklung startet mit **echtem Shelly PM Mini G3** Hardware-Device, das bereits im MQTT Broker integriert ist. Python-Prototyp wird so entwickelt, dass Code später direkt in Odoo-Bridge übernommen werden kann. - ---- - -## 1. Ziele und Nicht-Ziele - -### 1.1 Ziele (MVP) -- **Hardware-Integration**: Shelly PM Mini G3 als primäres Test-Device -- **Session-Logik** für Maschinenlaufzeit basierend auf Power-Schwellenwerten (Start/Stop mit Hysterese) -- **Python-Prototyp** (Standalone) der später in Odoo-Bridge übernommen wird: - - MQTT Client für Shelly-Integration - - Event-Normalisierung (Shelly-Format → Unified Event Schema) - - Session-Detection Engine - - Konfigurierbare Device-Mappings und Schwellenwerte -- **Odoo-Integration** (Phase 2): - - Einheitliche **Device-Event-Schnittstelle** (REST/Webhook) inkl. Authentifizierung - - **Event-Log** in Odoo (persistente Rohereignisse + Normalisierung) - - Session-Verwaltung mit Maschinenzuordnung -- Reproduzierbare Tests und eindeutige Fehlerdiagnostik (Logging) - -### 1.2 Nicht-Ziele (für Phase 1) -- Keine Enterprise-IoT-Box, keine Enterprise-Module -- Keine Simulatoren in Phase 1 (nur echte Hardware) -- Kein POS-Frontend-Live-Widget (optional erst in späterer Phase) -- Keine Abrechnungslogik/Preisregeln (kann vorbereitet, aber nicht umgesetzt werden) - ---- - -## 2. Zielarchitektur (Docker-First, Sidecar-Pattern) - -### 2.1 Komponenten -1. **MQTT Broker**: Mosquitto in Docker (bereits vorhanden) -2. **IoT Bridge Service**: Separater Docker Container - - Image: `iot_mqtt_bridge_for_odoo` - - Source: `iot_bridge/` Unterverzeichnis - - MQTT Client (subscribed auf Topics) - - Session Detection Engine (State Machine) - - Event-Normalisierung & Parsing - - Kommunikation mit Odoo via REST API -3. **Odoo Container**: Business Logic & Konfiguration - - REST API für Bridge-Konfiguration: `GET /ows/iot/config` - - REST API für Event-Empfang: `POST /ows/iot/event` - - Models: Devices, Events, Sessions - - Admin UI für Device-Management -4. **Docker Compose**: Orchestrierung aller Services - - Shared Network - - Shared Volumes (optional) - - Services starten zusammen: `docker compose up -d` - -### 2.2 Datenfluss -``` -Shelly PM → MQTT Broker ← Bridge Service → REST API → Odoo - (Subscribe) (POST) (Business Logic) -``` - -1. Bridge subscribed auf MQTT Topics (z.B. `shaperorigin/status/pm1:0`) -2. Bridge normalisiert Payload zu Unified Event Schema -3. Bridge erkennt Sessions via State Machine (Dual-Threshold, Debounce) -4. Bridge sendet Events via `POST /ows/iot/event` an Odoo -5. Odoo speichert Events + aktualisiert Session-Status - -### 2.3 Vorteile Sidecar-Pattern -- ✅ **Klare Trennung**: Odoo = Business Logic, Bridge = MQTT/Retry/Queue -- ✅ **Unabhängige Prozesse**: Bridge restart ohne Odoo-Downtime -- ✅ **Docker Best Practice**: Ein Prozess pro Container -- ✅ **Einfaches Setup**: `docker compose up -d` startet alles -- ✅ **Kein Overhead**: Gleiche Network/Volumes, kein Extra-Komplexität - ---- - -## 3. Schnittstellen zwischen Bridge und Odoo - -### 3.1 Bridge → Odoo: Event-Empfang - -**Endpoint:** `POST /ows/iot/event` - -**Authentifizierung (optional):** -- **Empfohlen für**: Produktiv-Umgebung, Multi-Host-Setup, externe Zugriffe -- **Nicht nötig für**: Docker-Compose-Setup (isoliertes Netzwerk) -- Header: `Authorization: Bearer ` (falls aktiviert) -- Token wird Bridge als ENV-Variable übergeben: `ODOO_TOKEN=...` -- Token in Odoo: `ir.config_parameter` → `ows_iot.bridge_token` -- Aktivierung: `ir.config_parameter` → `ows_iot.require_token = true` - -**Request Body:** - -**Variante 1: Session Lifecycle Events** -```json -{ - "schema_version": "v1", - "event_uid": "uuid", - "ts": "2026-01-31T10:30:15Z", - "device_id": "shellypmminig3-48f6eeb73a1c", - "event_type": "session_started", - "payload": { "session_id": "sess-abc123" } -} -``` - -**Variante 2: Session Heartbeat (periodisch, aggregiert)** -```json -{ - "schema_version": "v1", - "event_uid": "uuid", - "ts": "2026-01-31T10:35:00Z", - "device_id": "shellypmminig3-48f6eeb73a1c", - "event_type": "session_heartbeat", - "payload": { - "session_id": "sess-abc123", - "interval_start": "2026-01-31T10:30:00Z", - "interval_end": "2026-01-31T10:35:00Z", - "interval_working_s": 200, - "interval_standby_s": 100, - "current_state": "WORKING", - "avg_power_w": 142.3 - } -} -``` - -**Response:** -- `200 OK`: `{ "status": "ok", "event_id": 123, "session_id": 456 }` -- `400 Bad Request`: Schema-Fehler -- `401 Unauthorized`: Token fehlt/ungültig (nur wenn Auth aktiviert) -- `409 Conflict`: Duplikat (wenn `event_uid` bereits existiert) -- `500 Internal Server Error`: Odoo-Fehler - -**Idempotenz:** -- `event_uid` hat Unique-Constraint in Odoo -- Wiederholte Events → `409 Conflict` (Bridge ignoriert) - ---- - -### 3.2 Bridge ← Odoo: Konfiguration abrufen - -**Endpoint:** `GET /ows/iot/config` - -**Authentifizierung (optional):** -- Header: `Authorization: Bearer ` (falls aktiviert) - -**Response:** -```json -{ - "devices": [ - { - "device_id": "shellypmminig3-48f6eeb73a1c", - "mqtt_topic": "shaperorigin/status/pm1:0", - "parser_type": "shelly_pm_mini_g3", - "machine_name": "Shaper Origin", - "session_config": { - "strategy": "power_threshold", - "standby_threshold_w": 20, - "working_threshold_w": 100, - "start_debounce_s": 3, - "stop_debounce_s": 15, - "message_timeout_s": 20, - "heartbeat_interval_s": 300 - } - } - ] -} -``` - -**Wann aufgerufen:** -- Bridge-Start: Initiale Config laden -- Alle 5 Minuten: Config-Refresh (neue Devices, geänderte Schwellenwerte) -- Bei 401/403 Response von `/ows/iot/event`: Token evtl. ungültig geworden - ---- - -## 4. Ereignis- und Topic-Standard (Versioniert) - -### 4.1 MQTT Topics (v1) -- Maschinenzustand: - `hobbyhimmel/machines//state` -- Maschinenereignisse: - `hobbyhimmel/machines//event` -- Waage: - `hobbyhimmel/scales//event` -- Geräte-Status (optional): - `hobbyhimmel/devices//status` - -### 4.2 Gemeinsames JSON Event Schema (v1) -Pflichtfelder: -- `schema_version`: `"v1"` -- `event_uid`: UUID/string -- `ts`: ISO-8601 UTC (z. B. `"2026-01-10T12:34:56Z"`) -- `source`: `"simulator" | "device" | "gateway"` -- `device_id`: string -- `entity_type`: `"machine" | "scale" | "sensor"` -- `entity_id`: string (z. B. machine_id) -- `event_type`: string (siehe unten) -- `payload`: object -- `confidence`: `"high" | "medium" | "low"` (für Sensorfusion) - -### 4.3 Event-Typen (v1) -**Maschine/Timer** -- `session_started` (Session-Start, Bridge erkannt Aktivität) -- `session_heartbeat` (periodische Aggregation während laufender Session) -- `session_ended` (Session-Ende nach Timeout oder manuell) -- `session_timeout` (Session automatisch beendet wegen Inaktivität) -- `state_changed` (optional, Echtzeit-State-Change: IDLE/STANDBY/WORKING) -- `fault` (Fehler mit Code/Severity) - -**Waage** -- `stable_weight` (stabiler Messwert) -- `weight` (laufend) -- `tare` -- `zero` -- `error` - ---- - -## 5. Odoo Datenmodell (Vorschlag) - -### 5.1 `ows.iot.device` -- `name` -- `device_id` (unique) -- `token_hash` (oder Token in separater Tabelle) -- `device_type` (machine/scale/...) -- `active` -- `last_seen` -- `notes` - -### 5.2 `ows.iot.event` -- `event_uid` (unique) -- `device_id` (m2o -> device) -- `entity_type`, `entity_id` -- `event_type` -- `timestamp` -- `payload_json` (Text/JSON) -- `confidence` -- `processing_state` (new/processed/error) -- `session_id` (m2o optional) - -### 5.3 `ows.machine.session` (Timer-Sessions) -- `machine_id` (Char oder m2o auf bestehendes Maschinenmodell) -- `session_id` (external, von Bridge generiert) -- `start_ts`, `stop_ts` -- `duration_s` (computed: stop_ts - start_ts) -- `total_working_time_s` (Summe aller WORKING-Intervalle) -- `total_standby_time_s` (Summe aller STANDBY-Intervalle) -- `state` (running/stopped/aborted/timeout) -- `origin` (sensor/manual/sim) -- `billing_units` (computed: ceil(total_working_time_s / billing_unit_seconds)) -- `event_ids` (o2m → session_heartbeat Events) - -> Hinweis: Wenn du bereits `ows.machine` aus deinem open_workshop nutzt, referenziert `machine_id` direkt dieses Modell. - ---- - -## 6. Verarbeitungslogik (Bridge & Odoo) - -### 6.1 Bridge: State Tracking & Aggregation -**State Machine (5 Zustände):** -- `IDLE` (0-20W): Maschine aus/Leerlauf -- `STARTING` (Debounce): Power > standby_threshold für < start_debounce_s -- `STANDBY` (20-100W): Maschine an, aber nicht aktiv arbeitend -- `WORKING` (>100W): Maschine arbeitet aktiv -- `STOPPING` (Debounce): Power < standby_threshold für < stop_debounce_s - -**Aggregation Logic:** -- Bridge trackt intern State-Wechsel (1 msg/s von Shelly) -- Alle `heartbeat_interval_s` (z.B. 300s = 5 Min): - - Berechne `interval_working_s` (Summe aller WORKING-Zeiten) - - Berechne `interval_standby_s` (Summe aller STANDBY-Zeiten) - - Sende `session_heartbeat` an Odoo -- Bei Session-Start: `session_started` Event -- Bei Session-Ende: `session_ended` Event mit Totals - -### 6.2 Odoo: Session-Aggregation & Billing -- Empfängt `session_heartbeat` Events -- Summiert `total_working_time_s` und `total_standby_time_s` -- Berechnet `billing_units` nach Maschinen-Konfiguration: - ```python - billing_unit_minutes = machine.billing_unit_minutes # z.B. 5 - billing_units = ceil(total_working_time_s / (billing_unit_minutes * 60)) - ``` -- Timeout-Regel: wenn kein Heartbeat für `2 * heartbeat_interval_s` → Session `timeout` - ---- - -## 7. Simulation (Software statt Hardware) - -### 7.1 Device Simulator: Maschine -- Konfigurierbar: - - Muster: random, fixed schedule, manuell per CLI - - Zustände: idle/running/fault - - Optional: „power_w“ und „vibration“ als Felder im Payload -- Publiziert MQTT in realistischen Intervallen - -### 7.2 Device Simulator: Waage -- Modi: - - stream weight (mehrfach pro Sekunde) - - stable_weight nur auf „stabil“ - - tare/zero Events per CLI - -### 7.3 Bridge Simulator (MQTT → Odoo) -- Abonniert alle relevanten Topics -- Validiert Schema v1 -- POSTet Events an Odoo -- Retry-Queue (lokal) bei Odoo-Ausfall -- Metriken/Logs: - - gesendete Events, Fehlerquoten, Latenz - ---- - -## 8. Milestones & Deliverables (NEU: Hardware-First Approach) - -### Phase 1: Python-Prototyp (Standalone, ohne Odoo) - -#### M0 – Projekt Setup & MQTT Verbindung (0.5 Tag) -**Deliverables** -- Python Projekt Struktur -- Requirements.txt (paho-mqtt, pyyaml, etc.) -- Config-Datei (YAML): MQTT Broker Settings -- MQTT Client Basis-Verbindung testen - -**Test**: Verbindung zum MQTT Broker herstellen, Topics anzeigen - ---- - -#### M1 – Shelly PM Mini G3 Integration (1 Tag) -**Deliverables** -- MQTT Subscriber für Shelly-Topics -- Parser für Shelly JSON-Payloads (beide Formate): - - Status-Updates (full data mit voltage, current, apower) - - NotifyStatus Events (einzelne Werte) -- Datenextraktion: `apower`, `timestamp`, `device_id` -- Logging aller empfangenen Shelly-Messages - -**Test**: Shelly-Daten empfangen und parsen, Console-Output - ---- - -#### M2 – Event-Normalisierung & Unified Schema (1 Tag) -**Deliverables** -- Unified Event Schema v1 Implementation -- Shelly-to-Unified Konverter: - - `apower` Updates → `power_measurement` Events - - Enrichment mit `device_id`, `machine_id`, `timestamp` -- Event-UID Generierung -- JSON-Export der normalisierten Events - -**Test**: Shelly-Daten werden zu Schema-v1-konformen Events konvertiert - ---- - -#### M3 – Session Detection Engine (1-2 Tage) -**Deliverables** -- State Machine für run_start/run_stop Detection -- Konfigurierbare Parameter pro Device: - - `power_threshold` (z.B. 50W) - - `start_debounce_s` (z.B. 3s) - - `stop_debounce_s` (z.B. 15s) -- Session-Objekte: - - `session_id`, `machine_id`, `start_ts`, `stop_ts`, `duration_s` - - `events` (Liste der zugehörigen Events) -- Session-Export (JSON/CSV) - -**Test**: -- Maschine anschalten → `run_start` Event -- Maschine ausschalten → `run_stop` Event -- Sessions werden korrekt erkannt und gespeichert - ---- - -#### M4 – Multi-Device Support & Config (0.5-1 Tag) -**Deliverables** -- Device-Registry in Config: - ```yaml - devices: - - shelly_id: "shellypmminig3-48f6eeb73a1c" - machine_name: "Shaper Origin" - power_threshold: 50 - start_debounce_s: 3 - stop_debounce_s: 15 - ``` -- Dynamisches Laden mehrerer Devices -- Parallele Session-Tracking pro Device - -**Test**: Mehrere Shelly-Devices in Config, jedes wird separat getrackt - ---- - -#### M5 – Monitoring & Robustheit (0.5 Tag) -**Deliverables** -- Reconnect-Logik bei MQTT-Disconnect -- Error-Handling bei ungültigen Payloads -- Statistiken: empfangene Events, aktive Sessions, Fehler -- Strukturiertes Logging (Logger-Konfiguration) - -**Test**: MQTT Broker Neustart → Client reconnected automatisch - ---- - -### Phase 2: Docker-Container-Architektur - -#### M6 – IoT Bridge Docker Container (2-3 Tage) -**Deliverables:** -- Verzeichnis: `iot_bridge/` - - `main.py` - Bridge Hauptprogramm - - `mqtt_client.py` - MQTT Client (von Odoo portiert) - - `session_detector.py` - State Machine (von Odoo portiert) - - `parsers/` - Shelly, Tasmota, Generic - - `odoo_client.py` - REST API Client für Odoo - - `config.py` - Config Management (ENV + Odoo) - - `requirements.txt` - Python Dependencies - - `Dockerfile` - Multi-stage Build -- Docker Image: `iot_mqtt_bridge_for_odoo` -- ENV-Variablen: - - `ODOO_URL` (z.B. `http://odoo:8069`) - **Pflicht** - - `MQTT_URL` (z.B. `mqtt://mosquitto:1883`) - **Pflicht** - - `ODOO_TOKEN` (API Token für Bridge) - **Optional**, nur wenn Auth in Odoo aktiviert - - `LOG_LEVEL`, `CONFIG_REFRESH_INTERVAL` - Optional - -**Test:** -- Bridge startet via `docker run` -- Verbindet zu MQTT Broker -- Holt Config von Odoo via `GET /ows/iot/config` -- Subscribed auf Topics - ---- - -#### M7 – Odoo REST API Endpoints (1 Tag) -**Deliverables:** -- Controller: `controllers/iot_api.py` - - `GET /ows/iot/config` - Bridge-Konfiguration - - `POST /ows/iot/event` - Event-Empfang -- **Optionale** Token-Authentifizierung (Bearer, via `ows_iot.require_token`) -- Event-Validation gegen Schema v1 -- Event → Session Mapping - -**Test:** -- `curl -H "Authorization: Bearer " http://localhost:8069/ows/iot/config` -- `curl -X POST -H "Authorization: Bearer " -d '{...}' http://localhost:8069/ows/iot/event` - ---- - -#### M8 – Docker Compose Integration (0.5 Tag) -**Deliverables:** -- `docker-compose.yaml` Update: - ```yaml - services: - odoo: - # ... existing config ... - - iot_bridge: - image: iot_mqtt_bridge_for_odoo - environment: - ODOO_URL: http://odoo:8069 - MQTT_URL: mqtt://mosquitto:1883 - # ODOO_TOKEN: ${IOT_BRIDGE_TOKEN} # Optional: nur für Produktiv - depends_on: - - odoo - - mosquitto - networks: - - odoo_network - restart: unless-stopped - ``` -- `.env` Template mit `IOT_BRIDGE_TOKEN` (optional, für Produktiv-Setup) -- README Update: Setup-Anleitung - -**Test:** -- `docker compose up -d` startet Odoo + Bridge + Mosquitto -- Bridge subscribed Topics -- Events erscheinen in Odoo UI - ---- - -#### M9 – End-to-End Tests & Dokumentation (1 Tag) -**Deliverables:** -- Integration Tests: Shelly → MQTT → Bridge → Odoo -- Session Tests: run_start/run_stop Detection -- Container Restart Tests: Bridge/Odoo Recovery -- Dokumentation: - - `iot_bridge/README.md` - Bridge Architektur - - `DEPLOYMENT.md` - Docker Compose Setup - - API Docs - REST Endpoints - -**Test:** -- End-to-End: Shelly PM einschalten → Session erscheint in Odoo -- Container Restart → Sessions werden recovered -- Config-Änderung in Odoo → Bridge lädt neu (nach 5 Min) - ---- - -#### M10 – Tests & Dokumentation (1 Tag) -**Deliverables** -- Unit Tests: Event-Parsing, Session-Detection -- Integration Tests: MQTT → Odoo End-to-End -- Dokumentation: Setup-Anleitung, Config-Beispiele, API-Docs - ---- - -### Optional M11 – POS/Anzeige (später) -- Realtime Anzeige im POS oder auf Display -- Live Power-Consumption in Odoo - ---- - -## 9. Testplan (Simulation-first) - -### 9.1 Unit Tests (Odoo) -- Auth: gültiger Token → 200 -- ungültig/fehlend → 401/403 -- Schema-Validation → 400 -- Idempotenz: duplicate `event_uid` → 409 oder duplicate-flag - -### 9.2 Integration Tests -- Sequenz: start → heartbeat → stop → Session duration plausibel -- stop ohne start → kein Crash, Event loggt Fehlerzustand -- Timeout: start → keine heartbeat → Session aborted - -### 9.3 Last-/Stabilitätstest (Simulator) -- 20 Maschinen, je 1 Event/s, 1h Lauf -- Ziel: Odoo bleibt stabil, Event-Insert performant, Queue läuft nicht über - ---- - -## 10. Betriebs- und Sicherheitskonzept - -- Token-Rotation möglich (neues Token, altes deaktivieren) -- Reverse Proxy: - - HTTPS - - Rate limiting auf `/ows/iot/event` - - optional Basic WAF Regeln -- Payload-Größenlimit (z. B. 16–64 KB) -- Bridge persistiert Retry-Queue auf Disk - ---- - -## 11. Offene Entscheidungen (später, nicht blocker für MVP) - -- Event-Consumer in Odoo vs. Bridge-only Webhook (empfohlen: Webhook) -- Genaues Mapping zu bestehenden open_workshop Modellen (`ows.machine`) -- User-Zuordnung zu Sessions (manuell über UI oder zukünftig via RFID/NFC) -- Abrechnung: Preisregeln, Rundungen, POS-Integration -- Realtime: Odoo Bus vs. eigener WebSocket Service - ---- - -## 12. Nächste Schritte (AKTUALISIERT: Hardware-First) - -### Sofort starten (Phase 1 - Python Prototyp): -1. **M0**: Python-Projekt aufsetzen, MQTT-Verbindung testen -2. **M1**: Shelly PM Mini G3 Daten empfangen und parsen -3. **M2**: Event-Normalisierung implementieren (Shelly → Schema v1) -4. **M3**: Session Detection Engine (run_start/run_stop basierend auf Power) -5. **M4**: Multi-Device Config-Support - -### Danach (Phase 2 - Odoo Integration): -6. **M6-M9**: Odoo Module + Bridge + Session-Engine -7. **M10**: Tests & Dokumentation - -### Offene Informationen benötigt: -- [ ] Genaue MQTT Topic-Namen des Shelly PM Mini G3 -- [ ] Typische Power-Werte des Shaper Origin (Idle vs. Running) -- [ ] Gewünschte Schwellenwerte für Start/Stop-Detection -- [ ] MQTT Broker Zugangsdaten (Host, Port, User, Password) - ---- - -## Anhang C: Shelly PM Mini G3 Payload-Beispiele - -### Format 1: Full Status Update -```json -{ - "id": 0, - "voltage": 234.7, - "current": 0.289, - "apower": 59.7, - "freq": 49.9, - "aenergy": { - "total": 256.325, - "by_minute": [415.101, 830.202, 830.202], - "minute_ts": 1769100576 - }, - "ret_aenergy": { - "total": 0.000, - "by_minute": [0.000, 0.000, 0.000], - "minute_ts": 1769100576 - } -} -``` - -### Format 2: NotifyStatus Event -```json -{ - "src": "shellypmminig3-48f6eeb73a1c", - "dst": "shellypmminig3/events", - "method": "NotifyStatus", - "params": { - "ts": 1769100615.87, - "pm1:0": { - "id": 0, - "current": 0.185 - } - } -} -``` - -### Mapping zu Unified Event Schema -```json -{ - "schema_version": "v1", - "event_uid": "generated-uuid", - "ts": "2026-01-22T10:30:15Z", - "source": "shelly_pm_mini_g3", - "device_id": "shellypmminig3-48f6eeb73a1c", - "entity_type": "machine", - "entity_id": "shaper-origin-01", - "event_type": "power_measurement", - "confidence": "high", - "payload": { - "apower": 59.7, - "voltage": 234.7, - "current": 0.289, - "frequency": 49.9, - "total_energy_kwh": 0.256325 - } -} -``` - -### Session Detection Beispiel -```yaml -# Device Config -device_id: shellypmminig3-48f6eeb73a1c -machine_name: Shaper Origin -power_threshold: 50 # Watt -start_debounce_s: 3 # Power > threshold für 3s → run_start -stop_debounce_s: 15 # Power < threshold für 15s → run_stop -``` - -**Session Flow**: -1. `apower: 5W` (idle) → keine Session -2. `apower: 120W` → Power > 50W → Debounce startet -3. Nach 3s immer noch > 50W → `run_start` Event, Session öffnet -4. `apower: 95W, 110W, 88W` → Session läuft weiter -5. `apower: 12W` → Power < 50W → Stop-Debounce startet -6. Nach 15s immer noch < 50W → `run_stop` Event, Session schließt -7. Session: `duration = stop_ts - start_ts` - ---- - -## Anhang A: Beispiel-Event (Session Heartbeat) - -### Szenario: 5 Minuten mit mehreren State-Wechseln - -**Bridge-internes Tracking (nicht an Odoo gesendet):** -``` -10:00:00 120W WORKING (Session startet) -10:01:30 35W STANDBY (State-Wechsel) -10:02:15 155W WORKING (State-Wechsel) -10:03:00 28W STANDBY (State-Wechsel) -10:04:30 142W WORKING (State-Wechsel) -10:05:00 138W WORKING (Heartbeat-Intervall erreicht) -``` - -**Aggregiertes Event an Odoo:** -```json -{ - "schema_version": "v1", - "event_uid": "heartbeat-sess-abc123-10-05-00", - "ts": "2026-01-31T10:05:00Z", - "device_id": "shellypmminig3-48f6eeb73a1c", - "entity_type": "machine", - "entity_id": "shaper-origin-01", - "event_type": "session_heartbeat", - "confidence": "high", - "payload": { - "session_id": "sess-abc123", - "interval_start": "2026-01-31T10:00:00Z", - "interval_end": "2026-01-31T10:05:00Z", - "interval_working_s": 200, - "interval_standby_s": 100, - "current_state": "WORKING", - "avg_power_w": 142.3, - "state_change_count": 4 - } -} -``` - -**Odoo Verarbeitung:** -```python -# Session Update -session.total_working_time_s += 200 # += interval_working_s -session.total_standby_time_s += 100 # += interval_standby_s - -# Billing Berechnung (5-Minuten-Einheiten) -billing_units = ceil(session.total_working_time_s / 300) # 200s / 300s = 0.67 → 1 Einheit -``` - -## Anhang B: Beispiel-Event (Waage stable_weight) -```json -{ - "schema_version": "v1", - "event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11", - "ts": "2026-01-10T12:40:12Z", - "source": "simulator", - "device_id": "scale-sim-01", - "entity_type": "scale", - "entity_id": "waage-01", - "event_type": "stable_weight", - "confidence": "high", - "payload": { - "weight_g": 1532.4, - "unit": "g", - "stable_ms": 1200 - } -} -``` diff --git a/open_workshop_mqtt/IMPLEMENTATION_PLAN.md b/open_workshop_mqtt/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 84c4041..0000000 --- a/open_workshop_mqtt/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,381 +0,0 @@ -# Implementation Plan: IoT Bridge & Odoo Integration - -**Ziel:** Sidecar-Container-Architektur mit periodischer Session-Aggregation -**Stand:** 03.02.2026 -**Strategie:** Bridge zuerst (standalone testbar), dann Odoo API, dann Integration - ---- - -## 📦 Bestandsaufnahme - -### Vorhanden (wiederverwendbar) -- ✅ `python_prototype/` - Standalone MQTT Client & Session Detector (Basis für Bridge) -- ✅ `services/mqtt_client.py` - Odoo-integrierter MQTT Client (Referenz) -- ✅ `services/session_detector.py` - State Machine Logik (portierbar) -- ✅ `services/parsers/shelly_parser.py` - Shelly PM Parser (direkt übernehmen) -- ✅ `models/` - Odoo Models (anzupassen) -- ✅ Mosquitto MQTT Broker (läuft) - -### Neu zu erstellen -- ❌ `iot_bridge/` Container-Source (Skeleton existiert) -- ❌ Odoo REST API Controller -- ❌ Session-Aggregation in Bridge -- ❌ Odoo Session-Model mit Billing-Logik -- ❌ Docker Compose Integration - ---- - -## 🎯 Phase 1: Bridge Container (Standalone) - -**Ziel:** Bridge läuft unabhängig, loggt auf Console, nutzt YAML-Config - -### 1.1 Bridge Grundstruktur ✅ -- [x] `iot_bridge/config.yaml` erstellen (Device-Registry, MQTT Settings) -- [x] `iot_bridge/config.py` - Config Loader (YAML → Dataclass) -- [x] Mock-OdooClient (gibt hardcoded Config zurück, kein HTTP) -- [x] Logger Setup (structlog mit JSON output) - -**Test:** ✅ Bridge startet, lädt Config, loggt Status (JSON), Graceful Shutdown funktioniert - ---- - ---- - -### 1.2 MQTT Client portieren ✅ -- [x] `python_prototype/mqtt_client.py` → `iot_bridge/mqtt_client.py` -- [x] Shelly Parser integrieren (copy from `services/parsers/`) -- [x] Connection Handling (reconnect, error handling) -- [x] Message-Callback registrieren -- [x] TLS/SSL Support (Port 8883) - -**Test:** ✅ Bridge empfängt Shelly-Messages (40-55W), parst apower, loggt auf Console (JSON), TLS funktioniert -**Reconnect Test:** ✅ Broker neugestartet → Bridge disconnected (rc=7) → Auto-Reconnect → Re-Subscribe → Messages wieder empfangen - ---- - -### 1.3 Session Detector mit Aggregation ✅ -- [x] `session_detector.py` portieren (5-State Machine) -- [x] Aggregation-Logik hinzufügen: - - Interner State-Tracker (1s Updates) - - Timer für `heartbeat_interval_s` - - Berechnung: `interval_working_s`, `interval_standby_s` -- [x] Session-IDs generieren (UUID) -- [x] Events sammeln (event_callback) -- [x] Unit Tests (9 Tests, alle PASSED) -- [x] Shelly Simulator für Testing -- [x] Integration Tests (7 Tests, alle PASSED) -- [x] Lokaler Mosquitto Container (docker-compose.dev.yaml) - -**Test:** ✅ Session gestartet (34.5W > 20W threshold), State-Machine funktioniert, Heartbeat-Events nach 10s -**Unit Tests:** ✅ 9/9 passed (test_session_detector.py) -**Integration Tests:** ✅ 7/7 passed (test_bridge_integration.py) - Session Start, Heartbeat, Multi-Device, Timeout - ---- - -### 1.4 Event Queue & Retry Logic ✅ -- [x] `event_queue.py`: Event-Queue mit Retry-Logic - - Exponential Backoff (1s → 2s → 4s → ... → max 60s) - - Max 10 Retries - - Background-Thread für Queue-Processing - - Thread-safe mit `collections.deque` und `threading.Lock` -- [x] `event_uid` zu allen Events hinzufügen (UUID) -- [x] Queue-Integration in `main.py` - - `on_event_generated()` nutzt Queue statt direktem send - - Queue-Start/Stop im Lifecycle -- [x] Mock-Odoo Failure-Simulation - - `mock_failure_rate` in config.yaml (0.0-1.0) - - MockOdooClient wirft Exceptions bei failures -- [x] Unit Tests: `test_event_queue.py` (13 Tests, alle PASSED) - - Queue Operations (enqueue, statistics) - - Exponential Backoff Berechnung - - Retry Logic mit Mock-Callback - - Max Retries exceeded -- [x] Integration Tests: `test_retry_logic.py` (2 Tests, PASSED in 48.29s) - - test_retry_on_odoo_failure: Events werden enqueued - - test_eventual_success_after_retries: 50% failure rate → eventual success - -**Test:** ✅ Mock-Odoo-Client gibt 500 → Events in Queue → Retry mit Backoff → Success -**Unit Tests:** ✅ 13/13 passed -**Integration Tests:** ✅ 2/2 passed in 48.29s - ---- - -### 1.5 Docker Container Build -- [x] Multi-stage `Dockerfile` finalisiert - - Builder Stage: gcc + pip install dependencies - - Runtime Stage: minimal image, non-root user (bridge:1000) - - Python packages korrekt nach /usr/local/lib/python3.11/site-packages kopiert -- [x] `.dockerignore` erstellt (venv/, tests/, __pycache__, logs/, data/) -- [x] `requirements.txt` erweitert mit PyYAML>=6.0 -- [x] `docker build -t iot_mqtt_bridge:latest` - - 3 Build-Iterationen (Package-Path-Fix erforderlich) - - Finale Image: 8b690d20b5f7 -- [x] `docker run` erfolgreich getestet - - Volume mount: config.yaml (read-only) - - Network: host (für MQTT-Zugriff) - - Container verbindet sich mit mqtt.majufilo.eu:8883 - - Sessions werden erkannt und Events verarbeitet -- [x] Development Setup - - `config.yaml.dev` für lokale Tests (Mosquitto + Odoo) - - `docker-compose.dev.yaml` erweitert mit iot-bridge service - -**Test:** ✅ Container läuft produktiv, empfängt MQTT, erkennt Sessions -**Docker:** ✅ Multi-stage build, 71MB final image, non-root user - ---- - -## 🎯 Phase 2: Odoo REST API - -**Ziel:** Odoo REST API für Bridge-Config und Event-Empfang - -### 2.1 Models anpassen -- [x] **ows.iot.event** Model erstellt (`models/iot_event.py`) - - `event_uid` (unique constraint) - - `event_type` (selection: session_started/updated/stopped/timeout/heartbeat/power_change) - - `payload_json` (JSON Field) - - `device_id`, `session_id` (Char fields) - - Auto-Linking zu `mqtt.device` und `mqtt.session` - - Payload-Extraktion: `power_w`, `state` - - `processed` flag, `processing_error` für Error-Tracking -- [x] **mqtt.device** erweitert: - - `device_id` (External ID für Bridge API) - - Bestehende Felder: `strategy_config` (JSON), session_strategy, parser_type, topic_pattern -- [x] **mqtt.session** - bereits vorhanden: - - `session_id` (external UUID) - - Duration fields: `total_duration_s`, `standby_duration_s`, `working_duration_s` - - State tracking: `status` (running/completed), `current_state` - - Power tracking: `start_power_w`, `end_power_w`, `current_power_w` - -**Test:** ✅ Models erstellt, Security Rules aktualisiert - ---- - -### 2.2 REST API Controller -- [x] `controllers/iot_api.py` erstellt -- [x] **GET /ows/iot/config**: - - Returns: Alle aktiven Devices mit `session_config` als JSON - - Auth: public (später API-Key möglich) - - Response Format: - ```json - { - "status": "success", - "devices": [{ - "device_id": "...", - "mqtt_topic": "...", - "parser_type": "...", - "machine_name": "...", - "session_config": { strategy, thresholds, ... } - }], - "timestamp": "ISO8601" - } - ``` -- [x] **POST /ows/iot/event**: - - Schema-Validation (event_type, device_id, event_uid, timestamp required) - - Event-UID Duplikat-Check → 409 Conflict (idempotent) - - Event speichern in `ows.iot.event` - - Auto-Processing: Session erstellen/updaten/beenden - - Response Codes: 201 Created, 409 Duplicate, 400 Bad Request, 500 Error - - JSON-RPC Format (Odoo type='json') - -**Test:** ✅ Controller erstellt, bereit für manuellen Test -```bash -curl http://localhost:8069/ows/iot/config -curl -X POST http://localhost:8069/ows/iot/event -d '{...}' -``` - ---- - -### 2.3 Bridge Client - OdooClient -- [x] **OdooClient** implementiert (`iot_bridge/odoo_client.py`) - - `get_config()`: GET /ows/iot/config (HTTP) - - `send_event()`: POST /ows/iot/event (JSON-RPC) - - Retry-Logic über EventQueue (bereits in Phase 1.4) - - Duplicate Handling: 409 wird als Success behandelt - - Error Handling: Exceptions für 4xx/5xx - - HTTP Session mit requests library -- [x] **config.py** erweitert: - - `OdooConfig`: `base_url`, `database`, `username`, `api_key` - - Entfernt: alte `url`, `token` Felder -- [x] **main.py** angepasst: - - OdooClient Initialisierung mit neuen Parametern - - Conditional: MockOdooClient (use_mock=true) oder OdooClient (use_mock=false) - -**Test:** ✅ Code fertig, bereit für Integration Test - ---- - -### 2.4 Integration Testing -- [x] Odoo Modul upgraden (Models + Controller laden) - - ✅ Module installed in OWS_MQTT database - - ✅ Deprecation warnings (non-blocking, documented) -- [x] mqtt.device angelegt: "Shaper Origin" (device_id: shellypmminig3-48f6eeb73a1c) - - ✅ Widget fix: CodeEditor 'ace' → 'text' (Odoo 18 compatibility) - - ✅ connection_id made optional (deprecated legacy field) -- [x] API manuell getestet - - ✅ GET /ows/iot/config → HTTP 200, returns device list - - ✅ POST /ows/iot/event → HTTP 200, creates event records -- [x] `config.yaml.dev`: `use_mock=false` gesetzt -- [x] Docker Compose gestartet: Odoo + Mosquitto + Bridge -- [x] End-to-End Tests: - - ✅ MQTT → Bridge → Odoo event flow working - - ✅ Bridge fetches config from Odoo (1 device) - - ✅ Events stored in ows_iot_event table - - ✅ session_started creates mqtt.session automatically - - ✅ session_heartbeat updates session (controller fix applied) -- [x] Odoo 18 Compatibility Fixes: - - ✅ type='json' routes: Use function parameters instead of request.jsonrequest - - ✅ event_type Selection: Added 'session_heartbeat' - - ✅ Timestamp parsing: ISO format → Odoo datetime format - - ✅ Multiple databases issue: Deleted extra DBs (hh18, odoo18) for db_monodb() - - ✅ auth='none' working with single database - -**Outstanding Issues:** -- [x] UI Cleanup - MQTT Connection (deprecated) view removal - - ✅ Menu items hidden (commented out in mqtt_menus.xml) - - ✅ mqtt.message menu hidden (replaced by ows.iot.event) - - ✅ New "IoT Events" menu added -- [x] UI Cleanup - mqtt.device form: - - ✅ connection_id field hidden (invisible="1") - - ✅ device_id field prominent ("External Device ID") - - ✅ Parser Type + Session Strategy fields clarified - - ✅ Help text: "Configuration is sent to IoT Bridge via REST API" - - ✅ Strategy config placeholder updated with all required fields -- [x] ows.iot.event Views created: - - ✅ List view with filters (event type, processed status) - - ✅ Form view with payload display - - ✅ Search/filters: event type, device, session, date grouping - - ✅ Security rules configured -- [x] Create Test Device in Odoo: - - ✅ "Test Device - Manual Simulation" (device_id: test-device-manual) - - ✅ Low thresholds for easy testing (10W standby, 50W working) - - ✅ Test script created: tests/send_test_event.sh -- [x] Verify session auto-create/update workflow: - - ✅ session_started creates mqtt.session - - ✅ session_heartbeat updates durations + power - - ✅ total_duration_s = total_working_s + total_standby_s (calculated in controller) - -**Next Steps:** -- [ ] UI Testing: Login to Odoo and verify: - - [ ] MQTT -> Devices: See "Shaper Origin" + "Test Device" - - [ ] MQTT -> Sessions: Check duration calculations (Total = Working + Standby) - - [ ] MQTT -> IoT Events: Verify event list, filters work - - [ ] No "Connections" or "Messages" menus visible -- [ ] Duration Field Validation: - - [ ] Check if Total Hours displays correctly (computed from _s fields) - - [ ] Verify Standby Hours + Working Hours sum equals Total Hours - -**Test:** ⏳ Core functionality working, UI cleanup needed - ---- - -## 🎯 Phase 3: Integration & End-to-End - -**Ziel:** Bridge ↔ Odoo kommunizieren, Docker Compose Setup - -### 3.1 Bridge: Odoo Client implementieren -- [ ] `iot_bridge/odoo_client.py`: - - `get_config()` → HTTP GET zu Odoo - - `send_event()` → HTTP POST zu Odoo - - Error Handling (401, 409, 500) -- [ ] Config-Refresh alle 5 Min (von Odoo laden) -- [ ] ENV-Variablen: `ODOO_URL`, `MQTT_URL` - -**Test:** Bridge holt Config von Odoo, sendet Events → Odoo empfängt - ---- - -### 3.2 Docker Compose Setup -- [ ] `docker-compose.yaml` updaten: - - Service: `iot_bridge` - - Depends on: `odoo`, `mosquitto` - - ENV: `ODOO_URL=http://odoo:8069` -- [ ] `.env.example` mit Variablen -- [ ] README Update: Setup-Anleitung - -**Test:** `docker compose up -d` → Bridge startet → Config von Odoo → Events in DB - ---- - -### 3.3 End-to-End Tests -- [ ] Shelly PM einschalten → Session erscheint in Odoo -- [ ] Mehrere State-Wechsel → Heartbeat aggregiert korrekt -- [ ] Bridge Restart → Sessions werden recovered -- [ ] Odoo Config ändern → Bridge lädt neu - -**Test:** Real-World-Szenario mit echter Hardware durchspielen - ---- - -## 🎯 Phase 4: Polishing & Dokumentation - -### 4.1 Error Handling & Monitoring -- [ ] Bridge: Structured Logging (JSON) -- [ ] Odoo: Event-Processing Errors loggen -- [ ] Metriken: Events sent/failed, Session count -- [ ] Health-Checks für beide Services - ---- - -### 4.2 Dokumentation -- [ ] `iot_bridge/README.md` aktualisieren (ENV vars, Config) -- [ ] `DEPLOYMENT.md` - Produktiv-Setup Guide -- [ ] API Docs - REST Endpoints dokumentieren -- [ ] Troubleshooting Guide - ---- - -## 📊 Dependency Graph - -``` -Phase 1.1-1.4 (Bridge Core) - ↓ -Phase 1.5 (Docker) - ↓ -Phase 2 (Odoo API) ← kann parallel zu 1.1-1.4 - ↓ -Phase 3.1 (Integration) - ↓ -Phase 3.2-3.3 (E2E) - ↓ -Phase 4 (Polish) -``` - ---- - -## 🚀 Quick Start (nächster Schritt) - -**Jetzt starten mit:** -1. [ ] Phase 1.1: `iot_bridge/config.yaml` erstellen -2. [ ] Phase 1.2: MQTT Client portieren -3. [ ] Test: Bridge empfängt Shelly-Messages - -**Empfohlene Reihenfolge:** -- Phase 1 komplett durchziehen (Bridge standalone funktionsfähig) -- Phase 2 parallel starten (Odoo API) -- Phase 3 Integration (wenn beides fertig) - ---- - -## ✅ Definition of Done - -**Phase 1 Done:** -- [ ] Bridge läuft als Docker Container -- [ ] Empfängt Shelly-Messages -- [ ] State-Detection + Aggregation funktioniert -- [ ] Loggt aggregierte Events auf Console - -**Phase 2 Done:** -- [ ] Odoo REST API antwortet -- [ ] Events werden in DB gespeichert -- [ ] Sessions werden erstellt/aktualisiert -- [ ] Billing Units werden berechnet - -**Phase 3 Done:** -- [ ] Bridge sendet Events an Odoo -- [ ] Docker Compose startet alles zusammen -- [ ] End-to-End Test erfolgreich - -**Phase 4 Done:** -- [ ] Dokumentation vollständig -- [ ] Error Handling robust -- [ ] Produktiv-Ready diff --git a/open_workshop_mqtt/PHASE2_TESTING.md b/open_workshop_mqtt/PHASE2_TESTING.md deleted file mode 100644 index fcb7bf0..0000000 --- a/open_workshop_mqtt/PHASE2_TESTING.md +++ /dev/null @@ -1,235 +0,0 @@ -# Phase 2 - Odoo REST API Testing Guide - -## ✅ Was wurde implementiert - -### Models (Phase 2.1) -- **ows.iot.event**: Event Log mit unique `event_uid` - - Alle Events von der Bridge werden geloggt - - Auto-Linking zu mqtt.device und mqtt.session - - Payload-Extraktion (power_w, state) - -- **mqtt.device**: Erweitert mit `device_id` (External ID) - - Für API-Zugriff durch Bridge - -### REST API Controller (Phase 2.2) -- **GET /ows/iot/config** - - Returns: Liste aller aktiven Devices mit session_config - - Auth: public (später API-Key) - -- **POST /ows/iot/event** - - Empfängt Events von Bridge - - Duplikat-Check via event_uid (409 Conflict) - - Auto-Processing: Session erstellen/updaten - - Returns: 201 Created, 409 Duplicate, 400 Bad Request, 500 Error - -### Bridge Client (Phase 2.3) -- **OdooClient**: Echte REST API Calls - - `get_config()` → GET /ows/iot/config - - `send_event()` → POST /ows/iot/event (JSON-RPC) - - Retry-Logic über EventQueue - -## 🧪 Testing-Schritte - -### 1. Odoo Modul upgraden - -```bash -cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo -docker compose -f docker-compose.dev.yaml exec odoo-dev odoo --upgrade open_workshop_mqtt -``` - -Oder über UI: Apps → open_workshop_mqtt → Upgrade - -### 2. MQTT Device in Odoo anlegen - -UI: MQTT → Devices → Create - -**Beispiel-Konfiguration:** -- **Name**: Shaper Origin -- **Device ID**: `shellypmminig3-48f6eeb73a1c` -- **MQTT Connection**: (wähle bestehende oder erstelle neue für local Mosquitto) -- **Topic Pattern**: `shaperorigin/#` -- **Parser Type**: Shelly PM Mini G3 -- **Session Strategy**: Power Threshold (Dual) -- **Strategy Configuration**: - ```json - { - "standby_threshold_w": 20, - "working_threshold_w": 100, - "start_debounce_s": 3, - "stop_debounce_s": 15, - "message_timeout_s": 20, - "heartbeat_interval_s": 300 - } - ``` - -### 3. API manuell testen - -**Test 1: GET /ows/iot/config** -```bash -curl http://localhost:8069/ows/iot/config -``` - -Expected Response: -```json -{ - "status": "success", - "devices": [ - { - "device_id": "shellypmminig3-48f6eeb73a1c", - "mqtt_topic": "shaperorigin/status/pm1:0", - "parser_type": "shelly_pm", - "machine_name": "Shaper Origin", - "session_config": { - "strategy": "power_threshold", - "standby_threshold_w": 20, - ... - } - } - ], - "timestamp": "2026-02-05T15:30:00.000000Z" -} -``` - -**Test 2: POST /ows/iot/event** -```bash -curl -X POST http://localhost:8069/ows/iot/event \ - -H "Content-Type: application/json" \ - -d '{ - "jsonrpc": "2.0", - "method": "call", - "params": { - "event_uid": "test-' $(uuidgen) '", - "event_type": "session_started", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "test-session-123", - "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)'", - "payload": { - "power_w": 47.9, - "state": "starting" - } - }, - "id": null - }' -``` - -Expected Response: -```json -{ - "jsonrpc": "2.0", - "id": null, - "result": { - "status": "success", - "message": "Event received and processed", - "event_uid": "test-...", - "event_id": 1, - "code": 201 - } -} -``` - -**Test 3: Duplicate Event (sollte 409 zurückgeben)** -```bash -# Gleichen curl nochmal ausführen → sollte code: 409 zurückgeben -``` - -### 4. config.yaml.dev für echtes Odoo anpassen - -```yaml -odoo: - base_url: "http://odoo-dev:8069" - database: "your-db-name" # ← WICHTIG: Deinen DB-Namen eintragen - username: "admin" - api_key: "" # Aktuell nicht benötigt (auth='public') - use_mock: false # ← VON true AUF false ÄNDERN -``` - -### 5. Bridge im Docker Compose starten - -```bash -cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo -docker compose -f docker-compose.dev.yaml up -d iot-bridge -docker compose -f docker-compose.dev.yaml logs -f iot-bridge -``` - -**Erwartete Logs:** -``` -Loading configuration from /app/config.yaml -INFO: bridge_started config_file=/app/config.yaml devices=1 -INFO: using_real_odoo_client base_url=http://odoo-dev:8069 database=... -INFO: config_loaded device_count=1 -INFO: event_queue_started -INFO: session_detector_initialized device=shellypmminig3-48f6eeb73a1c -INFO: connected_to_mqtt broker=mosquitto port=1883 -INFO: subscribed_to_topic topic=shaperorigin/status/pm1:0 -INFO: bridge_ready status=running -``` - -### 6. End-to-End Test - -**Option A: Mit echtem Shelly Device** -- Sende MQTT-Nachrichten an lokalen Mosquitto -- Bridge empfängt → Session Detection → Events an Odoo - -**Option B: Mit MQTT Simulator** -```bash -mosquitto_pub -h localhost -t "shaperorigin/status/pm1:0" \ - -m '{"apower": 45.5, "timestamp": 1234567890}' -``` - -**Prüfen in Odoo:** -1. IoT Events: `ows.iot.event` Records -2. MQTT Sessions: `mqtt.session` Records -3. Logs: Bridge Logs zeigen erfolgreiche API Calls - -### 7. Troubleshooting - -**Problem: "ModuleNotFoundError: No module named 'requests'"** -→ Docker Image neu bauen (ist schon in requirements.txt) - -**Problem: "Connection refused to odoo-dev"** -→ Prüfe dass Odoo Container läuft: `docker compose ps` - -**Problem: "Database not found"** -→ Korrekten DB-Namen in config.yaml.dev eintragen - -**Problem: "Event UID constraint violation"** -→ Normal bei Restarts - Bridge sendet Events nochmal -→ API returned 409 (duplicate) → wird als Success behandelt - -## 📊 Monitoring - -### Odoo UI -- **IoT Events**: Settings → Technical → IoT Events -- **MQTT Sessions**: MQTT → Sessions -- **MQTT Devices**: MQTT → Devices - -### Bridge Logs -```bash -docker compose -f docker-compose.dev.yaml logs -f iot-bridge -``` - -### MQTT Messages (Debug) -```bash -mosquitto_sub -h localhost -t "shaperorigin/#" -v -``` - -## ✅ Success Criteria - -- [ ] GET /ows/iot/config returns device list -- [ ] POST /ows/iot/event creates ows.iot.event record -- [ ] Duplicate event_uid returns 409 -- [ ] Bridge starts with use_mock=false -- [ ] Bridge fetches config from Odoo -- [ ] Bridge sends events to Odoo -- [ ] mqtt.session is created for session_started event -- [ ] mqtt.session is updated for session_updated event -- [ ] mqtt.session is completed for session_stopped event -- [ ] No errors in Bridge or Odoo logs - -## 🎯 Nächste Schritte - -Nach erfolgreichem Test: -- Phase 2 Commit -- Optional: API-Key Authentication hinzufügen -- Optional: Health-Check Endpoint für Bridge -- Phase 3: Production Deployment diff --git a/open_workshop_mqtt/README.md b/open_workshop_mqtt/README.md deleted file mode 100644 index 8f67f86..0000000 --- a/open_workshop_mqtt/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# Open Workshop MQTT - -**MQTT IoT Bridge for Odoo 18** - Sidecar-Container-Architektur - -## Architektur-Übersicht - -``` -┌─────────────┐ MQTT ┌──────────────┐ REST API ┌────────────┐ -│ Shelly PM │ ────────────────► │ IoT Bridge │ ──────────────► │ Odoo 18 │ -│ (Hardware) │ │ (Container) │ │ (Business) │ -└─────────────┘ └──────────────┘ └────────────┘ - │ │ - ▼ │ - ┌──────────────┐ │ - │ Mosquitto │ ◄──────────────────────┘ - │ MQTT Broker │ (Config via API) - └──────────────┘ -``` - -### Komponenten - -1. **IoT Bridge** (Separater Docker Container) - - MQTT Client (subscribed auf Device-Topics) - - Session Detection Engine (State Machine) - - Event-Parsing & Normalisierung - - REST Client für Odoo-Kommunikation - - Image: `iot_mqtt_bridge_for_odoo` - - Source: `iot_bridge/` Verzeichnis - -2. **Odoo Module** (Business Logic) - - Device-Management UI - - REST API für Bridge (`/ows/iot/config`, `/ows/iot/event`) - - Event-Speicherung & Session-Verwaltung - - Analytics & Reporting - -3. **MQTT Broker** (Mosquitto) - - Message-Transport zwischen Hardware und Bridge - -## Features - -- ✅ **Sidecar-Pattern** - Bridge als separater Container (Docker Best Practice) -- ✅ **Klare Trennung** - Odoo = Business, Bridge = MQTT/Retry/Queue -- ✅ **Auto-Config** - Bridge holt Device-Config von Odoo via REST API -- ✅ **Session Detection** - Dual-Threshold, Debounce, Timeout (in Bridge) -- ✅ **Flexible Parsers** - Shelly PM Mini G3, Tasmota, Generic JSON -- ✅ **Analytics** - Pivot tables and graphs for runtime analysis -- ✅ **Auto-Reconnect** - Exponential backoff on MQTT connection loss -- ✅ **Idempotenz** - Event-UID verhindert Duplikate - -## Installation - -### 1. Docker Compose Setup - -```yaml -services: - odoo: - # ... existing config ... - - iot_bridge: - image: iot_mqtt_bridge_for_odoo - build: - context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge - environment: - ODOO_URL: http://odoo:8069 - ODOO_TOKEN: ${IOT_BRIDGE_TOKEN} - MQTT_URL: mqtt://mosquitto:1883 - depends_on: - - odoo - - mosquitto - restart: unless-stopped -``` - -### 2. Odoo Module Installation - -```bash -docker compose exec odoo odoo -u open_workshop_mqtt -``` - -### 3. Bridge Token generieren - -Odoo UI: -- Settings → Technical → System Parameters -- Create: `ows_iot.bridge_token` = `` - -Add to `.env`: -``` -IOT_BRIDGE_TOKEN= -``` - -### 4. Start Services - -```bash -docker compose up -d -``` - -## Quick Start - -1. **Add Device in Odoo** - - MQTT → Devices → Create - - Device ID: `shellypmminig3-48f6eeb73a1c` - - MQTT Topic: `shaperorigin/status/pm1:0` - - Parser: Shelly PM Mini G3 - - Session Strategy: Power Threshold - - Standby: 20W - - Working: 100W - - Debounce: 3s / 15s - -2. **Bridge Auto-Config** - - Bridge holt Device-Config via `GET /ows/iot/config` - - Subscribed auf Topic automatisch - - Startet Session Detection - -3. **Monitor Sessions** - - MQTT → Sessions - - View runtime analytics in Pivot/Graph views - -## Session Detection Strategies - -### Power Threshold (Recommended) -- Dual threshold detection (standby/working) -- Configurable debounce timers -- Timeout detection -- Reference: `python_prototype/session_detector.py` - -### Last Will Testament -- Uses MQTT LWT for offline detection -- Immediate session end on device disconnect - -### Manual -- Start/stop sessions via buttons or MQTT commands - -## Configuration Example - -**Shelly PM Mini G3:** -```json -{ - "standby_threshold_w": 20, - "working_threshold_w": 100, - "start_debounce_s": 3, - "stop_debounce_s": 15, - "message_timeout_s": 20 -} -``` - -## Optional: Maintenance Integration - -Install `open_workshop_mqtt_maintenance` (separate module) to link MQTT devices to `maintenance.equipment`. - -## Technical Reference - -See `python_prototype/` directory for: -- Implementation reference -- Test suite (pytest) -- Session detection logic -- MQTT client implementation - -## Support - -- Documentation: See `python_prototype/README.md` -- Issues: GitHub Issues -- Tests: `pytest python_prototype/tests/ -v` - -## License - -LGPL-3 diff --git a/open_workshop_mqtt/TODO.md b/open_workshop_mqtt/TODO.md deleted file mode 100644 index 40c6ac5..0000000 --- a/open_workshop_mqtt/TODO.md +++ /dev/null @@ -1,336 +0,0 @@ -# TODO - Open Workshop MQTT (Stand: 30. Januar 2026) - -## Status-Übersicht - -### ✅ Phase 1: Python Prototyp (ABGESCHLOSSEN) -- [x] M0: Projekt Setup & MQTT Verbindung -- [x] M1: Shelly PM Mini G3 Integration -- [x] M2: Event-Normalisierung & Unified Schema -- [x] M3: Session Detection Engine -- [x] M4: Multi-Device Support & Config -- [x] M5: Monitoring & Robustheit -- [x] Unit Tests (pytest) -- [x] Integration Tests (mit echtem MQTT Broker) - -**Ergebnis:** `python_prototype/` ist vollständig implementiert und getestet - ---- - -### 🔄 Phase 2: ARCHITEKTUR-REDESIGN (IN ARBEIT) - -**ALTE Architektur (VERALTET):** -``` -Shelly PM → MQTT Broker → Odoo (MQTT Client) → Session Detection → Sessions -``` -❌ **Problem:** MQTT Client in Odoo = zu eng gekoppelt, Container-Restart-Issues - -**NEUE Architektur (Sidecar-Pattern):** -``` -Shelly PM → MQTT Broker ← Bridge Container → REST API → Odoo (Business Logic) -``` -✅ **Vorteile:** -- Klare Prozess-Trennung (Odoo = HTTP+Business, Bridge = MQTT+Retry) -- Bridge restart unabhängig von Odoo -- Docker Best Practice (ein Prozess pro Container) -- Einfache Orchestrierung via `docker compose up -d` - -#### ✅ M6: Odoo Modul Grundgerüst (FERTIG - 100%) -- [x] Modul `open_workshop_mqtt` erstellt -- [x] Models implementiert: - - [x] `mqtt.connection` - MQTT Broker Verbindungen - - [x] `mqtt.device` - IoT Device Management - - [x] `mqtt.session` - Runtime Sessions - - [x] `mqtt.message` - Message Log (Debug) -- [x] Views erstellt (List, Form, Kanban, Pivot, Graph) -- [x] Security Rules (`ir.model.access.csv`) -- [x] Services: - - [x] `mqtt_client.py` - MQTT Client (mit TLS, Auto-Reconnect) - - [x] `iot_bridge_service.py` - Singleton Service für Connection Management - - [x] **MQTT Client in Odoo** - Subscribed DIREKT auf MQTT Topics ✅ -- [x] Parsers: - - [x] `shelly_parser.py` - Shelly PM Mini G3 - - [x] Generic Parser Support - -**Code-Location:** `/models/`, `/services/`, `/views/` - -**Flow:** Shelly → MQTT → Odoo (kein REST API nötig!) - ---- - -#### ~~❌ M7: REST Endpoint & Authentication~~ (GESTRICHEN!) - -**Begründung:** Odoo hat bereits MQTT Client → REST API überflüssig! - -**NICHT NÖTIG** weil: -- Odoo subscribed DIREKT auf MQTT Topics (via `mqtt_client.py`) -- Keine externe Bridge erforderlich -- `python_prototype/` bleibt als Standalone-Tool für Tests/Entwicklung - ---- - -#### ~~❌ M8: Python-Bridge Integration~~ (GESTRICHEN!) - -**Begründung:** Python Prototype bleibt Standalone für Tests. Odoo macht Production! - ---- - -#### ~~❌ M7: Session-Engine in Odoo~~ (GESTRICHEN - wird zu Bridge!) - -**Alte Architektur (in Odoo implementiert):** -- ✅ SessionDetector State Machine in `services/session_detector.py` -- ✅ 5-State Machine: IDLE → STARTING → STANDBY/WORKING → STOPPING → IDLE -- ✅ Dual-Threshold Detection + Debounce -- ✅ Alle 7 Unit Tests grün - -**NEUE Architektur:** -- 🔄 SessionDetector wird in Bridge Container portiert -- 🔄 Odoo bekommt nur noch fertige Events (run_start/run_stop) -- 🔄 Sessions werden in Odoo via REST API erstellt - -**Code wird wiederverwendet:** -- `services/session_detector.py` → `iot_bridge/session_detector.py` (leicht angepasst) -- State Machine Logic bleibt identisch -- Nur Odoo-Abhängigkeiten (env, cursor) werden entfernt - ---- - -#### 🔧 M7: IoT Bridge Docker Container (IN ARBEIT - 0%) - -**Ziel:** Separater Container für MQTT Bridge - -**Location:** `iot_bridge/` Unterverzeichnis - -**Tasks:** -- [ ] Projekt-Struktur erstellen: - ``` - iot_bridge/ - main.py # Bridge Hauptprogramm - mqtt_client.py # MQTT Client (aus services/ portiert) - session_detector.py # State Machine (aus services/ portiert) - odoo_client.py # REST API Client für Odoo - config.py # Config Management - parsers/ - shelly_parser.py - tasmota_parser.py - requirements.txt - Dockerfile - README.md - ``` -- [ ] Docker Image: `iot_mqtt_bridge_for_odoo` -- [ ] ENV-Variablen: - - `ODOO_URL` (z.B. `http://odoo:8069`) - - `ODOO_TOKEN` (API Token) - - `MQTT_URL` (z.B. `mqtt://mosquitto:1883`) -- [ ] Code-Portierung aus Odoo: - - SessionDetector State Machine - - MQTT Client (ohne Odoo-Abhängigkeiten) - - Shelly Parser -- [ ] Retry-Queue bei Odoo-Ausfall (lokal, in-memory oder SQLite) -- [ ] Config-Refresh alle 5 Min via `GET /ows/iot/config` - -**Test:** -- `docker build -t iot_mqtt_bridge_for_odoo iot_bridge/` -- `docker run -e ODOO_URL=... -e ODOO_TOKEN=... iot_mqtt_bridge_for_odoo` - ---- - -#### 🔧 M8: Odoo REST API Endpoints (IN ARBEIT - 0%) - -**Ziel:** Odoo bietet REST API für Bridge-Kommunikation - -**Tasks:** -- [ ] Controller: `controllers/iot_api.py` - - [ ] `GET /ows/iot/config` - Device-Config für Bridge - - [ ] `POST /ows/iot/event` - Event-Empfang von Bridge -- [ ] Authentifizierung: - - [ ] Token in `ir.config_parameter`: `ows_iot.bridge_token` - - [ ] Bearer Token Validation in Controller -- [ ] Event-Validation: - - [ ] Schema v1 prüfen - - [ ] Unique-Constraint auf `event_uid` - - [ ] 409 Conflict bei Duplikaten -- [ ] Session-Mapping: - - [ ] Events → Session-Updates - - [ ] run_start/run_stop Events - -**Test:** -```bash -# Config abrufen -curl -H "Authorization: Bearer " http://localhost:8069/ows/iot/config - -# Event senden -curl -X POST -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"schema_version":"v1","event_uid":"...","device_id":"..."}' \ - http://localhost:8069/ows/iot/event -``` - ---- - -#### 🔧 M9: Docker Compose Integration (TODO - 0%) - -**Tasks:** -- [ ] `docker-compose.yaml` Update: - ```yaml - services: - iot_bridge: - image: iot_mqtt_bridge_for_odoo - build: - context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge - environment: - ODOO_URL: http://odoo:8069 - ODOO_TOKEN: ${IOT_BRIDGE_TOKEN} - MQTT_URL: mqtt://mosquitto:1883 - depends_on: - - odoo - - mosquitto - restart: unless-stopped - ``` -- [ ] `.env` Template mit `IOT_BRIDGE_TOKEN` -- [ ] `DEPLOYMENT.md` - Setup-Anleitung - -**Test:** -- `docker compose up -d` startet Odoo + Bridge -- Bridge logs zeigen MQTT connection -- Events erscheinen in Odoo UI - - Standby/Working Thresholds ✅ - - Debounce (Start: 3s, Stop: 15s) ✅ - - 5-State Machine (IDLE → STARTING → STANDBY/WORKING → STOPPING → IDLE) ✅ - - Timeout Detection (20s) ✅ - - Duration Tracking (standby_duration_s, working_duration_s) ✅ - - State Recovery nach Restart ✅ -5. **Views & UI** - Forms, Lists, Pivot Tables, Graphs ✅ -6. **ENV-Passing Architecture** - Keine Cursor-Fehler mehr! ✅ - -### ❌ TESTS BROKEN (Code IST NICHT FERTIG!) -1. **8 Unit Tests schlagen fehl** - Tests verwenden alte Signaturen - - Änderung nötig: `SessionDetector(device)` → `SessionDetector(device.id, device.name)` - - Änderung nötig: `detector.process_power_event(power, ts)` → `detector.process_power_event(env, power, ts)` - - Alle Assertions müssen mit `env` arbeiten - - Aufwand: **~1-2 Stunden** (nicht 30 Min - Tests müssen auch laufen!) - -2. **Keine grünen Tests = NICHT FERTIG!ctor(device)` → `SessionDetector(device.id, device.name)` - - Änderung: `detector.process_power_event(power, ts)` → `detector.process_power_event(env, power, ts)` - - Aufwand: **~30 Minuten** - -### ~~NICHT MEHR NÖTIG~~ -- ~~REST API Endpoint~~ - Odoo macht MQTT direkt ✅ -- ~~Event Storage Model~~ - `mqtt.message` reicht für Debug ✅ -- ~~Python Bridge~~ - Odoo ist die Bridge ✅ - ---- - -## Phase 3: Optional Features (Noch nicht gestartet) - -### 🟡 MEDIUM PRIORITY (Optional M9) - -5. **POS Integration** (später) - - Realtime Display - - WebSocket Feed - - Live Power Consumption - -6. **Maintenance Integration** - - Link zu `maintenance.equipment` - - Usage Tracking - - Separate Module `open_workshop_mqtt_maintenance` - -### 🟢 LOW PRIORITY (Nice-to-have) - -7. **Performance & Scale** - - Last-Tests (20 Devices, 1h) - - Queue Optimization - - Database Indexes - -8. **Security Hardening** - - IP Allowlist - - Rate Limiting (Reverse Proxy) - - Token Rotation - ---- - -## Bekannte Probleme - -### 🐛 BUGS - -1. **Tests hängen mit echtem MQTT Broker** - - Location: `tests/test_mqtt_connection.py`, etc. - - Reason: TransactionCase + Background Threads inkompatibel - - Solution: Mock-basierte Tests (erstellt, aber nicht aktiv) - - Status: WORKAROUND erstellt, muss aktiviert werden - -2. **Manual Session Start/Stop TODO** - - Location: `models/mqtt_device.py:327, 349` - - Methods: `action_start_session()`, `action_stop_session()` - - Status: Placeholder, nicht implementiert - -3. **Topic Matching ohne Wildcards** - - Location: `services/iot_bridge_service.py:498` - - TODO Comment: Implement proper topic matching (+, #) - - Status: Funktioniert nur mit exakten Topics - -### ⚠️ TECHNICAL DEBT - -1. **Session Detection zu simpel** - - Problem: `power > 0 = start` ist zu vereinfacht - - Impact: Flackernde Sessions bei Power-Spikes - - Solution: Session-Engine aus Python portieren (M7) ← **DAS IST ALLES** -- [ ] **M7**: REST Endpoint `/ows/iot/event` funktioniert - - [ ] Auth Tests: ✅ Alle Tests grün - - [ ] Manual Test: `curl -H "Authorization: Bearer xxx" -X POST ...` → 200 OK -- [ ] **M7**: Events werden in `mqtt.event` persistiert - - [ ] Test: Event in DB sichtbar nach POST -- [ ] **M8**: Python Bridge sendet Events an Odoo - - [ ] Integration Test: ✅ Event kommt in Odoo an - - [ ] Manual Test: Bridge läuft, Events in Odoo UI sichtbar -- [ ] **M9**: Session-Detection läuft in Odoo (Dual-Threshold!) - - [ ] Unit Tests: ✅ State Machine korrekt - - [ ] Integration Test: ✅ Shelly → Session mit Debounce - - [ ] Manual Test: Maschine an/aus → Session startet/stoppt korrekt -- [ ] **End-to-End**: Shelly → MQTT → Bridge → Odoo → Session sichtbar - - [ ] Test: Kompletter Flow funktioniert -- [ ] **Dokumentation**: API, Setup, Troubleshooting komplett - -**Test-Driven Development:** -- Jeder Milestone hat Tests BEVOR Code geschrieben wird -- Keine manuelle UI-Klickerei zum Testen -- `run-tests.sh` läuft jederzeit durch - -**Geschätzter Aufwand:** 4-6 Tage (bei fokussierter TDD-rt -- [ ] Python Bridge sendet Events an Odoo (statt nur File) -- [ ] Events werden in `mqtt.event` persistiert -- [ ] Session-Detection läuft in Odoo (nicht nur Python) -- [ ] Sessions werden automatisch bei Events aktualisiert -- [ ] Tests laufen durch (Mock-basiert) -- [ ] End-to-End Test: Shelly → MQTT → Bridge → Odoo → Session sichtbar -- [ ] Dokumentation komplett (API, Setup, Troubleshooting) - -**Geschätzter Aufwand:** 4-6 Tage (bei fokussierter Arbeit) - ---- - -## Changelog - -**2026-01-30 16:40 Uhr (Phase 2 FERTIG!):** -- Phase 1: ✅ Komplett (Python Prototype) -- Phase 2: ✅ **100% FERTIG!** - Code + Tests komplett! -- SessionDetector vollständig portiert + env-passing refactoring -- Live-Tests erfolgreich: State Machine funktioniert mit echten MQTT Messages! -- **Alle 26 Unit Tests grün!** (0 failed, 0 errors) -- Test-Fixes: - - test_session_detector.py: Alle 7 Tests an env-passing angepasst - - test_mqtt_mocked.py: Alle 4 Service-Tests repariert - - run-tests.sh: Verbesserte Ausgabe mit klarer Zusammenfassung -- **SessionDetector ist produktionsreif!** - -**2026-01-29 (Env-Passing Refactoring):** Odoo Cursor Management gelöst -- Komplettes Refactoring: SessionDetector speichert nur IDs -- 20+ Method Signatures geändert (env-passing) -- Keine "Cursor already closed" Fehler mehr -- Live getestet mit echten MQTT Messages - -**2026-01-28 (Update 2):** Architektur vereinfacht - Odoo macht ALLES direkt -- Phase 1: ✅ Komplett (Python Prototype) -- Phase 2: 🚧 70% (Odoo Integration) -- M7+M8 (REST/Bridge) GESTRICHEN → 2 Tage gespart! -- Nur noch: M7 (Session-Engine portieren) + M8 (Tests reparieren) -- Geschätzt: **2-3 Tage** statt 4-6! diff --git a/open_workshop_mqtt/__init__.py b/open_workshop_mqtt/__init__.py deleted file mode 100644 index c3d410e..0000000 --- a/open_workshop_mqtt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import models -from . import controllers diff --git a/open_workshop_mqtt/__manifest__.py b/open_workshop_mqtt/__manifest__.py deleted file mode 100644 index 65864a1..0000000 --- a/open_workshop_mqtt/__manifest__.py +++ /dev/null @@ -1,49 +0,0 @@ -# -*- coding: utf-8 -*- -{ - 'name': 'Open Workshop MQTT', - 'version': '18.0.1.0.0', - 'category': 'IoT', - 'summary': 'MQTT IoT Device Integration for Workshop Equipment Tracking', - 'description': """ - Open Workshop MQTT Module - ========================= - - Connect MQTT IoT devices (Shelly, Tasmota, etc.) to Odoo and track: - - Device runtime sessions - - Power consumption - - Machine usage analytics - - Features: - - Flexible device parsers (Shelly PM Mini G3, Tasmota, Generic) - - Multiple session detection strategies (Power threshold, Last Will, Manual) - - Auto-reconnect with exponential backoff - - State recovery after restart - - Real-time device monitoring - - Optional Integration: - - Connect devices to maintenance.equipment (via open_workshop_mqtt_maintenance) - """, - 'author': 'Open Workshop', - 'website': 'https://github.com/your-repo/open_workshop', - 'license': 'LGPL-3', - 'depends': [ - 'base', - 'web', # Needed for HTTP controllers - ], - 'external_dependencies': { - 'python': [ - 'paho-mqtt', # pip install paho-mqtt - ], - }, - 'data': [ - 'security/ir.model.access.csv', - 'views/mqtt_device_views.xml', - 'views/mqtt_session_views.xml', - 'views/iot_event_views.xml', - 'views/mqtt_menus.xml', - ], - 'demo': [], - 'installable': True, - 'application': True, - 'auto_install': False, -} diff --git a/open_workshop_mqtt/check_routes.py b/open_workshop_mqtt/check_routes.py deleted file mode 100644 index 815abc9..0000000 --- a/open_workshop_mqtt/check_routes.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 -import sys -import odoo -from odoo import api, SUPERUSER_ID - -odoo.tools.config.parse_config(['--database=OWS_MQTT']) -with api.Environment.manage(): - with odoo.registry('OWS_MQTT').cursor() as cr: - env = api.Environment(cr, SUPERUSER_ID, {}) - - # Get all routes - from odoo.http import root - - print("All registered routes containing 'iot' or 'ows':") - count = 0 - for rule in root.get_db_router('OWS_MQTT').url_map.iter_rules(): - path = str(rule.rule) - if 'iot' in path.lower() or 'ows' in path.lower(): - print(f" {rule.methods} {path}") - count += 1 - - print(f"\nTotal: {count} routes found") - - if count == 0: - print("\n❌ No IoT routes found - controller not loaded!") - print("\nChecking if controllers module can be imported:") - try: - from odoo.addons.open_workshop_mqtt import controllers - print("✅ controllers module imported successfully") - from odoo.addons.open_workshop_mqtt.controllers import iot_api - print("✅ iot_api module imported successfully") - print(f"✅ Controller class: {iot_api.IotApiController}") - except Exception as e: - print(f"❌ Import failed: {e}") diff --git a/open_workshop_mqtt/controllers/__init__.py b/open_workshop_mqtt/controllers/__init__.py deleted file mode 100644 index 62448fe..0000000 --- a/open_workshop_mqtt/controllers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import iot_api diff --git a/open_workshop_mqtt/controllers/iot_api.py b/open_workshop_mqtt/controllers/iot_api.py deleted file mode 100644 index 485fc0c..0000000 --- a/open_workshop_mqtt/controllers/iot_api.py +++ /dev/null @@ -1,312 +0,0 @@ -# -*- coding: utf-8 -*- -""" -IoT Bridge REST API Controller -Provides endpoints for IoT Bridge to fetch config and send events -""" - -from odoo import http, _ -from odoo.http import request, Response, route -import json -import logging -from datetime import datetime - -_logger = logging.getLogger(__name__) - - -class IotApiController(http.Controller): - """REST API for IoT Bridge integration""" - - # ========== GET /ows/iot/config ========== - @route('/ows/iot/config', type='http', auth='none', methods=['GET'], csrf=False, save_session=False) - def get_iot_config(self, **kw): - """ - Get IoT device configuration for Bridge - - Returns JSON with all active devices and their session configs - - Example Response: - { - "devices": [ - { - "device_id": "shellypmminig3-48f6eeb73a1c", - "mqtt_topic": "shaperorigin/status/pm1:0", - "parser_type": "shelly_pm_mini_g3", - "machine_name": "Shaper Origin", - "session_config": { - "strategy": "power_threshold", - "standby_threshold_w": 20, - "working_threshold_w": 100, - "start_debounce_s": 3, - "stop_debounce_s": 15, - "message_timeout_s": 20, - "heartbeat_interval_s": 300 - } - } - ] - } - """ - try: - # Get database name from query parameter or use monodb - db_name = kw.get('db') or request.db or http.db_monodb() - if not db_name: - return Response( - json.dumps({'status': 'error', 'error': 'No database specified. Use ?db=DATABASE_NAME'}), - content_type='application/json', status=400 - ) - - # Ensure we have environment with correct database - http.db_monodb = lambda force=False: db_name # Temporary override for this request - - # Get all active MQTT devices - devices = request.env['mqtt.device'].sudo().search([ - ('active', '=', True) - ]) - - config_devices = [] - for device in devices: - # Parse strategy_config JSON - session_config = {} - if device.strategy_config: - try: - session_config = json.loads(device.strategy_config) - except json.JSONDecodeError: - _logger.warning(f"Invalid JSON in device {device.id} strategy_config") - - # Add strategy type - session_config['strategy'] = device.session_strategy or 'power_threshold' - - # Build device config - device_config = { - 'device_id': device.device_id, - 'mqtt_topic': device.topic_pattern.replace('/#', '/status/pm1:0'), # Adjust topic - 'parser_type': device.parser_type.replace('_', '_'), # e.g., shelly_pm → shelly_pm_mini_g3 - 'machine_name': device.name, - 'session_config': session_config - } - config_devices.append(device_config) - - response_data = { - 'status': 'success', - 'devices': config_devices, - 'timestamp': datetime.utcnow().isoformat() + 'Z' - } - - return Response( - json.dumps(response_data, indent=2), - content_type='application/json', - status=200 - ) - - except Exception as e: - _logger.exception("Error in get_iot_config") - error_response = { - 'status': 'error', - 'error': str(e), - 'timestamp': datetime.utcnow().isoformat() + 'Z' - } - return Response( - json.dumps(error_response, indent=2), - content_type='application/json', - status=500 - ) - - # ========== POST /ows/iot/event ========== - @route('/ows/iot/event', type='json', auth='none', methods=['POST'], csrf=False, save_session=False) - def receive_iot_event(self, event_uid=None, event_type=None, device_id=None, - session_id=None, timestamp=None, payload=None, **kw): - """ - Receive IoT event from Bridge - - Expected JSON-RPC params: - { - "event_uid": "550e8400-e29b-41d4-a716-446655440000", - "event_type": "session_started", - "device_id": "shellypmminig3-48f6eeb73a1c", - "session_id": "a1b2c3d4", - "timestamp": "2026-02-05T15:09:48.544202Z", - "payload": { - "power_w": 47.9, - "state": "starting", - ... - } - } - - Returns: - - 201 Created: Event successfully created - - 409 Conflict: Event UID already exists (duplicate) - - 400 Bad Request: Invalid payload - - 500 Internal Server Error: Processing failed - """ - try: - # Validate required fields - if not all([event_uid, event_type, device_id, timestamp]): - missing = [f for f, v in [('event_uid', event_uid), ('event_type', event_type), - ('device_id', device_id), ('timestamp', timestamp)] if not v] - return { - 'status': 'error', - 'error': f"Missing required fields: {', '.join(missing)}", - 'code': 400 - } - - # Check for duplicate event_uid - existing_event = request.env['ows.iot.event'].sudo().search([ - ('event_uid', '=', event_uid) - ], limit=1) - - if existing_event: - _logger.info(f"Duplicate event UID: {event_uid} - returning 409") - return { - 'status': 'duplicate', - 'message': 'Event UID already exists', - 'event_uid': event_uid, - 'code': 409 - } - - # Create event record - # Parse ISO timestamp to Odoo datetime format - from dateutil import parser as date_parser - try: - dt = date_parser.isoparse(timestamp) - odoo_timestamp = dt.strftime('%Y-%m-%d %H:%M:%S') - except Exception as e: - return { - 'status': 'error', - 'error': f"Invalid timestamp format: {str(e)}", - 'code': 400 - } - - event_vals = { - 'event_uid': event_uid, - 'event_type': event_type, - 'device_id': device_id, - 'session_id': session_id, - 'timestamp': odoo_timestamp, - 'payload_json': json.dumps(payload or {}, indent=2), - } - - event = request.env['ows.iot.event'].sudo().create(event_vals) - - _logger.info(f"Created IoT event: {event.event_uid} type={event.event_type} device={event.device_id}") - - # Process event (update/create session) - self._process_event(event) - - return { - 'status': 'success', - 'message': 'Event received and processed', - 'event_uid': event.event_uid, - 'event_id': event.id, - 'code': 201 - } - - except Exception as e: - _logger.exception("Error in receive_iot_event") - return { - 'status': 'error', - 'error': str(e), - 'code': 500 - } - - def _process_event(self, event): - """ - Process IoT event: create or update mqtt.session - - Args: - event: ows.iot.event record - """ - try: - payload = event.get_payload_dict() - - # Find or create mqtt.session - session = None - if event.session_id: - session = request.env['mqtt.session'].sudo().search([ - ('session_id', '=', event.session_id) - ], limit=1) - - # Handle different event types - if event.event_type == 'session_started': - if not session: - # Create new session - device = request.env['mqtt.device'].sudo().search([ - ('device_id', '=', event.device_id) - ], limit=1) - - if device: - session = request.env['mqtt.session'].sudo().create({ - 'session_id': event.session_id, - 'device_id': device.id, - 'start_time': event.timestamp, - 'start_power_w': payload.get('power_w', 0.0), - 'status': 'running', - 'current_state': payload.get('state', 'idle'), - 'last_message_time': event.timestamp, - }) - _logger.info(f"Created session: {session.session_id} for device {device.name}") - else: - _logger.warning(f"Device not found for event: {event.device_id}") - - elif event.event_type in ['session_updated', 'session_heartbeat'] and session: - # Update existing session - update_vals = { - 'last_message_time': event.timestamp, - } - - # Update power (different field names for different event types) - if 'avg_power_w' in payload: - update_vals['current_power_w'] = payload['avg_power_w'] - elif 'power_w' in payload: - update_vals['current_power_w'] = payload['power_w'] - - # Update durations (heartbeat uses total_working_s/total_standby_s) - if 'total_working_s' in payload: - update_vals['working_duration_s'] = int(payload['total_working_s']) - if 'total_standby_s' in payload: - update_vals['standby_duration_s'] = int(payload['total_standby_s']) - - # Calculate total duration from working + standby - if 'total_working_s' in payload and 'total_standby_s' in payload: - total = int(payload['total_working_s']) + int(payload['total_standby_s']) - update_vals['total_duration_s'] = total - - # Fallback to explicit duration fields - if 'total_duration_s' in payload: - update_vals['total_duration_s'] = int(payload['total_duration_s']) - if 'standby_duration_s' in payload: - update_vals['standby_duration_s'] = int(payload['standby_duration_s']) - if 'working_duration_s' in payload: - update_vals['working_duration_s'] = int(payload['working_duration_s']) - - # Update state - if 'current_state' in payload: - update_vals['current_state'] = payload['current_state'] - - session.write(update_vals) - _logger.debug(f"Updated session: {session.session_id}") - - elif event.event_type in ['session_stopped', 'session_timeout'] and session: - # End session - end_vals = { - 'end_time': event.timestamp, - 'end_power_w': payload.get('power_w', 0.0), - 'status': 'completed', - 'end_reason': 'timeout' if event.event_type == 'session_timeout' else 'power_drop', - } - - # Final duration updates - if 'total_duration_s' in payload: - end_vals['total_duration_s'] = int(payload['total_duration_s']) - if 'standby_duration_s' in payload: - end_vals['standby_duration_s'] = int(payload['standby_duration_s']) - if 'working_duration_s' in payload: - end_vals['working_duration_s'] = int(payload['working_duration_s']) - - session.write(end_vals) - _logger.info(f"Ended session: {session.session_id} reason={end_vals['end_reason']}") - - # Mark event as processed - event.mark_processed() - - except Exception as e: - _logger.exception(f"Error processing event {event.event_uid}") - event.mark_processed(error=str(e)) diff --git a/open_workshop_mqtt/iot_bridge/.dockerignore b/open_workshop_mqtt/iot_bridge/.dockerignore deleted file mode 100644 index b7cfe60..0000000 --- a/open_workshop_mqtt/iot_bridge/.dockerignore +++ /dev/null @@ -1,61 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -venv/ -env/ -ENV/ - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -tests/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Logs -logs/ -*.log - -# Data -data/ - -# Config (will be mounted as volume) -config.yaml - -# Git -.git/ -.gitignore - -# Documentation -README.md -*.md -docs/ - -# Development files -docker-compose.dev.yaml diff --git a/open_workshop_mqtt/iot_bridge/.gitignore b/open_workshop_mqtt/iot_bridge/.gitignore deleted file mode 100644 index 0cdf65b..0000000 --- a/open_workshop_mqtt/iot_bridge/.gitignore +++ /dev/null @@ -1,48 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual Environment -venv/ -ENV/ -env/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Testing -.pytest_cache/ -.coverage -htmlcov/ -*.log - -# Local config -.env -.env.local -config.local.yml - -# SQLite (if using for retry queue) -*.db -*.sqlite3 diff --git a/open_workshop_mqtt/iot_bridge/Dockerfile b/open_workshop_mqtt/iot_bridge/Dockerfile deleted file mode 100644 index 3f1ccec..0000000 --- a/open_workshop_mqtt/iot_bridge/Dockerfile +++ /dev/null @@ -1,57 +0,0 @@ -# Multi-stage build for IoT MQTT Bridge -# Stage 1: Builder - Install dependencies -FROM python:3.11-slim AS builder - -WORKDIR /app - -# Install build dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends gcc && \ - rm -rf /var/lib/apt/lists/* - -# Copy requirements and install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir --user -r requirements.txt - -# Stage 2: Runtime - Minimal image -FROM python:3.11-slim - -LABEL maintainer="Open Workshop MQTT IoT Bridge" -LABEL description="MQTT Bridge for Odoo IoT Device Integration with Session Detection" -LABEL version="1.0.0" - -WORKDIR /app - -# Copy installed packages from builder to site-packages (not user directory) -COPY --from=builder /root/.local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages - -# Copy application code -COPY *.py ./ -COPY config.yaml.example ./ - -# Create non-root user for security -RUN useradd -m -u 1000 bridge && \ - chown -R bridge:bridge /app && \ - mkdir -p /app/logs /app/data && \ - chown -R bridge:bridge /app/logs /app/data - -# Switch to non-root user -USER bridge - -# Environment variables (can be overridden at runtime) -ENV BRIDGE_CONFIG=/app/config.yaml \ - PYTHONUNBUFFERED=1 \ - LOG_LEVEL=INFO - -# Volume for config and logs -VOLUME ["/app/config.yaml", "/app/logs", "/app/data"] - -# Expose optional health check port (if implemented later) -# EXPOSE 8080 - -# Health check (optional - requires health endpoint implementation) -# HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ -# CMD python -c "import socket; s=socket.socket(); s.connect(('localhost',8080)); s.close()" || exit 1 - -# Run bridge with unbuffered output -CMD ["python", "-u", "main.py"] diff --git a/open_workshop_mqtt/iot_bridge/README.md b/open_workshop_mqtt/iot_bridge/README.md deleted file mode 100644 index 87cf9d5..0000000 --- a/open_workshop_mqtt/iot_bridge/README.md +++ /dev/null @@ -1,402 +0,0 @@ -# IoT MQTT Bridge for Odoo - -**Separater Docker Container für MQTT-IoT-Device-Integration** - -## Architektur-Übersicht - -``` -┌─────────────┐ MQTT ┌──────────────┐ REST API ┌────────────┐ -│ Shelly PM │ ────────────────► │ IoT Bridge │ ──────────────► │ Odoo 18 │ -│ (Hardware) │ │ (THIS!) │ │ (Business) │ -└─────────────┘ └──────────────┘ └────────────┘ - │ │ - ▼ │ - ┌──────────────┐ │ - │ Mosquitto │ ◄──────────────────────┘ - │ MQTT Broker │ (Config via API) - └──────────────┘ -``` - -## Zweck - -Die IoT Bridge ist ein **eigenständiger Python-Service** der: - -1. **MQTT-Verbindung verwaltet** - - Subscribed auf Device-Topics (z.B. Shelly PM Mini G3) - - Auto-Reconnect bei Verbindungsabbruch - - Exponential Backoff - -2. **Event-Normalisierung durchführt** - - Parser für verschiedene Device-Typen (Shelly, Tasmota, Generic) - - Konvertiert zu Unified Event Schema v1 - - Generiert eindeutige Event-UIDs - -3. **Session Detection** (State Machine) - - Dual-Threshold Detection (Standby/Working) - - Debounce Timer (Start/Stop) - - Timeout Detection - - 5-State Machine: IDLE → STARTING → STANDBY/WORKING → STOPPING - -4. **Odoo-Kommunikation** (REST API) - - Holt Device-Config: `GET /ows/iot/config` - - Sendet Events: `POST /ows/iot/event` - - Bearer Token Authentication - - Retry-Queue bei Odoo-Ausfall - -## Projekt-Struktur - -``` -iot_bridge/ -├── main.py # Haupt-Entry-Point -├── mqtt_client.py # MQTT Client (paho-mqtt) -├── session_detector.py # State Machine für Session Detection -├── odoo_client.py # REST API Client für Odoo -├── config.py # Config Management (ENV + Odoo) -├── parsers/ -│ ├── __init__.py -│ ├── base_parser.py # Abstract Parser Interface -│ ├── shelly_parser.py # Shelly PM Mini G3 -│ ├── tasmota_parser.py # Tasmota (optional) -│ └── generic_parser.py # Generic JSON -├── requirements.txt # Python Dependencies -├── Dockerfile # Multi-stage Build -└── README.md # Dieses Dokument -``` - -## Konfiguration - -### ENV-Variablen - -Die Bridge wird ausschließlich über Umgebungsvariablen konfiguriert: - -| Variable | Pflicht | Default | Beschreibung | -|----------|---------|---------|--------------| -| `ODOO_URL` | ✅ | - | Odoo Base-URL (z.B. `http://odoo:8069`) | -| `ODOO_TOKEN` | ✅ | - | API Token für Authentifizierung | -| `MQTT_URL` | ✅ | - | MQTT Broker URL (z.B. `mqtt://mosquitto:1883`) | -| `MQTT_USERNAME` | ❌ | `None` | MQTT Username (optional) | -| `MQTT_PASSWORD` | ❌ | `None` | MQTT Password (optional) | -| `LOG_LEVEL` | ❌ | `INFO` | Logging Level (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | -| `CONFIG_REFRESH_INTERVAL` | ❌ | `300` | Config-Refresh in Sekunden (5 Min) | - -### Odoo-Konfiguration - -Die Bridge holt Device-spezifische Konfiguration von Odoo via: - -**Request:** `GET /ows/iot/config` - -**Response:** -```json -{ - "devices": [ - { - "device_id": "shellypmminig3-48f6eeb73a1c", - "mqtt_topic": "shaperorigin/status/pm1:0", - "parser_type": "shelly_pm_mini_g3", - "machine_name": "Shaper Origin", - "session_config": { - "strategy": "power_threshold", - "standby_threshold_w": 20, - "working_threshold_w": 100, - "start_debounce_s": 3, - "stop_debounce_s": 15, - "message_timeout_s": 20 - } - } - ] -} -``` - -## Event-Flow - -### 1. MQTT Message empfangen - -```python -# Topic: shaperorigin/status/pm1:0 -# Payload (Shelly Format): -{ - "id": 0, - "voltage": 234.7, - "current": 0.289, - "apower": 120.5, # ← Power-Wert! - "aenergy": { "total": 256.325 } -} -``` - -### 2. Parser normalisiert zu Event Schema v1 - -```python -{ - "schema_version": "v1", - "event_uid": "b6d0a2c5-9b1f-4a0b-8b19-7f2e1b8f3d11", - "ts": "2026-01-31T10:30:15.123Z", - "device_id": "shellypmminig3-48f6eeb73a1c", - "event_type": "power_measurement", - "payload": { - "apower": 120.5, - "voltage": 234.7, - "current": 0.289, - "total_energy_kwh": 0.256325 - } -} -``` - -### 3. Session Detector analysiert - -``` -State Machine: -IDLE (Power < 20W) - ↓ Power > 100W -STARTING (Debounce 3s) - ↓ 3s vergangen, Power > 100W -WORKING (Session läuft) - ↓ Power < 100W -STOPPING (Debounce 15s) - ↓ 15s vergangen, Power < 20W -IDLE (Session beendet) -``` - -### 4. Events an Odoo senden - -**run_start Event:** -```python -POST /ows/iot/event -Authorization: Bearer - -{ - "schema_version": "v1", - "event_uid": "...", - "event_type": "run_start", - "device_id": "shellypmminig3-48f6eeb73a1c", - "ts": "2026-01-31T10:30:18.500Z", - "payload": { - "power_w": 120.5, - "reason": "power_threshold" - } -} -``` - -**run_stop Event:** -```python -POST /ows/iot/event -{ - "event_type": "run_stop", - "payload": { - "power_w": 8.2, - "reason": "normal", - "duration_s": 187.3 - } -} -``` - -## Docker Integration - -### Dockerfile - -```dockerfile -FROM python:3.11-slim - -WORKDIR /app - -# Install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Copy application -COPY . . - -# Run as non-root -RUN useradd -m -u 1000 bridge && chown -R bridge:bridge /app -USER bridge - -CMD ["python", "-u", "main.py"] -``` - -### docker-compose.yaml - -```yaml -services: - iot_bridge: - image: iot_mqtt_bridge_for_odoo - build: - context: ./extra-addons/open_workshop/open_workshop_mqtt/iot_bridge - environment: - ODOO_URL: http://odoo:8069 - ODOO_TOKEN: ${IOT_BRIDGE_TOKEN} - MQTT_URL: mqtt://mosquitto:1883 - LOG_LEVEL: INFO - depends_on: - - odoo - - mosquitto - networks: - - odoo_network - restart: unless-stopped -``` - -## Development - -### Lokales Testen - -```bash -# 1. Python Dependencies installieren -cd iot_bridge/ -python -m venv venv -source venv/bin/activate # Linux/Mac -pip install -r requirements.txt - -# 2. ENV-Variablen setzen -export ODOO_URL=http://localhost:8069 -export ODOO_TOKEN=your-token-here -export MQTT_URL=mqtt://localhost:1883 -export LOG_LEVEL=DEBUG - -# 3. Bridge starten -python main.py -``` - -### Docker Build & Run - -```bash -# Build -docker build -t iot_mqtt_bridge_for_odoo . - -# Run -docker run --rm \ - -e ODOO_URL=http://odoo:8069 \ - -e ODOO_TOKEN=your-token \ - -e MQTT_URL=mqtt://mosquitto:1883 \ - iot_mqtt_bridge_for_odoo -``` - -## Monitoring - -### Logs - -```bash -# Docker Container Logs -docker compose logs -f iot_bridge - -# Wichtige Log-Events: -# - [INFO] Bridge started, connecting to MQTT... -# - [INFO] MQTT connected, subscribing to topics... -# - [INFO] Config loaded: 3 devices -# - [DEBUG] Message received: topic=..., power=120.5W -# - [INFO] Session started: device=..., session_id=... -# - [WARNING] Odoo API error, retrying in 5s... -``` - -### Health Check - -Die Bridge sollte einen Health-Check-Endpoint anbieten: - -```python -# GET http://bridge:8080/health -{ - "status": "ok", - "mqtt_connected": true, - "odoo_reachable": true, - "devices_configured": 3, - "active_sessions": 1, - "uptime_seconds": 3600 -} -``` - -## Fehlerbehandlung - -### MQTT Disconnect - -- Auto-Reconnect mit Exponential Backoff -- Topics werden nach Reconnect neu subscribed -- Laufende Sessions werden **nicht** beendet - -### Odoo Unreachable - -- Events werden in lokaler Queue gespeichert (in-memory oder SQLite) -- Retry alle 5 Sekunden -- Max. 1000 Events in Queue (älteste werden verworfen) - -### Config-Reload - -- Alle 5 Minuten: `GET /ows/iot/config` -- Neue Devices → subscribe Topics -- Gelöschte Devices → unsubscribe Topics -- Geänderte Schwellenwerte → SessionDetector aktualisieren - -## Testing - -### Manuelle Tests - -```bash -# Shelly Simulator für Tests -python tests/tools/shelly_simulator.py --scenario session_end -python tests/tools/shelly_simulator.py --scenario full_session -python tests/tools/shelly_simulator.py --scenario timeout - -python3 tests/tools/shelly_simulator.py --broker localhost --port 1883 --no-tls --username "" --password "" --scenario full_session - -### Unit Tests - -```bash -pytest tests/test_session_detector.py -v -pytest tests/test_parsers.py -v -pytest tests/test_odoo_client.py -v -``` - -### Integration Tests - -```bash -# Requires: Running MQTT Broker + Odoo instance -pytest tests/integration/ -v -``` - -## Production Deployment - -### Best Practices - -1. **Token Security** - - Token in `.env` (nicht in Git) - - Regelmäßige Token-Rotation - - Separate Tokens pro Umgebung (dev/staging/prod) - -2. **Logging** - - `LOG_LEVEL=INFO` in Production - - `LOG_LEVEL=DEBUG` nur für Troubleshooting - - Log-Aggregation (z.B. via Docker Logging Driver) - -3. **Monitoring** - - Health-Check in Docker Compose - - Alerts bei Container-Restart - - Metrics: Events/s, Queue-Größe, Odoo-Latenz - -4. **Scaling** - - Eine Bridge-Instanz pro MQTT-Broker - - Mehrere Broker → mehrere Bridge-Container - - Shared Subscriptions (MQTT 5.0) für Load-Balancing - -## Roadmap - -### Phase 1 (MVP) -- [x] Architektur-Design -- [ ] MQTT Client Implementation -- [ ] Shelly Parser -- [ ] Session Detector (aus Odoo portiert) -- [ ] Odoo REST Client -- [ ] Dockerfile - -### Phase 2 (Features) -- [ ] Retry-Queue (SQLite) -- [ ] Health-Check-Endpoint -- [ ] Tasmota Parser -- [ ] Generic JSON Parser -- [ ] Config-Hot-Reload - -### Phase 3 (Production) -- [ ] Integration Tests -- [ ] Docker Compose Example -- [ ] Deployment Guide -- [ ] Monitoring Dashboard -- [ ] Performance Tuning - -## License - -LGPL-3 (same as Odoo) diff --git a/open_workshop_mqtt/iot_bridge/config.py b/open_workshop_mqtt/iot_bridge/config.py deleted file mode 100644 index a11e9a7..0000000 --- a/open_workshop_mqtt/iot_bridge/config.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Configuration loader for IoT Bridge.""" -import yaml -from dataclasses import dataclass -from pathlib import Path -from typing import List, Optional - - -@dataclass -class MQTTConfig: - broker: str - port: int - username: Optional[str] = None - password: Optional[str] = None - client_id: str = "iot_bridge" - keepalive: int = 60 - use_tls: bool = False - - -@dataclass -class OdooConfig: - base_url: Optional[str] = None - database: Optional[str] = None - username: Optional[str] = None - api_key: Optional[str] = None - use_mock: bool = True - mock_failure_rate: float = 0.0 # For testing retry logic (0.0-1.0) - - -@dataclass -class LoggingConfig: - level: str = "INFO" - format: str = "json" - log_file: Optional[str] = None - - -@dataclass -class EventQueueConfig: - max_retries: int = 3 - initial_retry_delay_s: float = 2.0 - max_retry_delay_s: float = 60.0 - retry_backoff_factor: float = 2.0 - - -@dataclass -class SessionConfig: - strategy: str - standby_threshold_w: float - working_threshold_w: float - start_debounce_s: float - stop_debounce_s: float - message_timeout_s: float - heartbeat_interval_s: float - - -@dataclass -class DeviceConfig: - device_id: str - mqtt_topic: str - parser_type: str - machine_name: str - session_config: SessionConfig - - -@dataclass -class BridgeConfig: - mqtt: MQTTConfig - odoo: OdooConfig - logging: LoggingConfig - event_queue: EventQueueConfig - devices: List[DeviceConfig] - - -def load_config(config_path: str = "config.yaml") -> BridgeConfig: - """Load configuration from YAML file.""" - path = Path(config_path) - if not path.exists(): - raise FileNotFoundError(f"Config file not found: {config_path}") - - with open(path, 'r') as f: - data = yaml.safe_load(f) - - mqtt_config = MQTTConfig(**data['mqtt']) - odoo_config = OdooConfig(**data.get('odoo', {})) - logging_config = LoggingConfig(**data.get('logging', {})) - event_queue_config = EventQueueConfig(**data.get('event_queue', {})) - - devices = [] - for dev_data in data.get('devices', []): - session_cfg = SessionConfig(**dev_data['session_config']) - device = DeviceConfig( - device_id=dev_data['device_id'], - mqtt_topic=dev_data['mqtt_topic'], - parser_type=dev_data['parser_type'], - machine_name=dev_data['machine_name'], - session_config=session_cfg - ) - devices.append(device) - - return BridgeConfig( - mqtt=mqtt_config, - odoo=odoo_config, - logging=logging_config, - event_queue=event_queue_config, - devices=devices - ) diff --git a/open_workshop_mqtt/iot_bridge/config.yaml b/open_workshop_mqtt/iot_bridge/config.yaml deleted file mode 100644 index 342537f..0000000 --- a/open_workshop_mqtt/iot_bridge/config.yaml +++ /dev/null @@ -1,38 +0,0 @@ -# IoT Bridge Configuration -# This file is used when running the bridge standalone (without Odoo API) -# WARNING: Contains credentials - DO NOT commit to git! - -mqtt: - broker: "mqtt.majufilo.eu" - port: 8883 - username: "mosquitto" - password: "jer7Pehr" - client_id: "iot_bridge_standalone" - keepalive: 60 - use_tls: true - -odoo: - # Odoo REST API configuration for development - base_url: "http://odoo-dev:8069" - database: "OWS_MQTT" - username: "admin" - api_key: "" # Optional: Add API key for authentication - use_mock: false - -logging: - level: "INFO" # DEBUG, INFO, WARNING, ERROR - format: "json" # json or text - -devices: - - device_id: "shellypmminig3-48f6eeb73a1c" - mqtt_topic: "shaperorigin/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - machine_name: "Shaper Origin" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - heartbeat_interval_s: 30 # 30 seconds for testing (300 = 5 minutes in production) diff --git a/open_workshop_mqtt/iot_bridge/config.yaml.dev b/open_workshop_mqtt/iot_bridge/config.yaml.dev deleted file mode 100644 index 57060c5..0000000 --- a/open_workshop_mqtt/iot_bridge/config.yaml.dev +++ /dev/null @@ -1,57 +0,0 @@ -# IoT Bridge Development Configuration -# For use with docker-compose.dev.yaml and local Mosquitto - -mqtt: - broker: "mosquitto" # Docker service name from docker-compose - port: 1883 # Unencrypted for local development - username: "" # Leave empty if Mosquitto allows anonymous - password: "" - client_id: "iot_bridge_dev" - keepalive: 60 - use_tls: false # No TLS for local testing - -odoo: - # Local Odoo instance from docker-compose - base_url: "http://odoo-dev:8069" - database: "OWS_MQTT" - username: "admin" - api_key: "" # Add your API key here when ready for Phase 2 - use_mock: false # Using real Odoo REST API - -logging: - level: "DEBUG" # More verbose for development - format: "json" - -# Event Queue Configuration -event_queue: - max_retries: 3 - initial_retry_delay_s: 2 - max_retry_delay_s: 60 - retry_backoff_factor: 2.0 - -devices: - - device_id: "shellypmminig3-48f6eeb73a1c" - mqtt_topic: "shaperorigin/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - machine_name: "Shaper Origin" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - heartbeat_interval_s: 300 # 5 minutes - - - device_id: "testshelly-simulator" - mqtt_topic: "testshelly/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - machine_name: "Test Shelly Simulator" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - heartbeat_interval_s: 30 # 30 seconds for faster testing diff --git a/open_workshop_mqtt/iot_bridge/config.yaml.example b/open_workshop_mqtt/iot_bridge/config.yaml.example deleted file mode 100644 index 59111a3..0000000 --- a/open_workshop_mqtt/iot_bridge/config.yaml.example +++ /dev/null @@ -1,35 +0,0 @@ -# IoT Bridge Configuration Example -# Copy this file to config.yaml and fill in your values - -mqtt: - broker: "your-mqtt-broker.com" - port: 8883 # 1883 for unencrypted, 8883 for TLS - username: "your_username" - password: "your_password" - client_id: "iot_bridge_standalone" - keepalive: 60 - use_tls: true # Set to true for port 8883 - -odoo: - # URL for Odoo API (when not using mock) - # url: "http://localhost:8069" - # token: "" - use_mock: true - -logging: - level: "INFO" # DEBUG, INFO, WARNING, ERROR - format: "json" # json or text - -devices: - - device_id: "shellypmminig3-48f6eeb73a1c" - mqtt_topic: "shaperorigin/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - machine_name: "Shaper Origin" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - heartbeat_interval_s: 300 # 5 minutes diff --git a/open_workshop_mqtt/iot_bridge/event_queue.py b/open_workshop_mqtt/iot_bridge/event_queue.py deleted file mode 100644 index 56da791..0000000 --- a/open_workshop_mqtt/iot_bridge/event_queue.py +++ /dev/null @@ -1,197 +0,0 @@ -""" -Event Queue with Retry Logic for IoT Bridge - -Handles queuing and retry of events sent to Odoo with exponential backoff. -""" -import uuid -import time -import threading -from collections import deque -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Dict, Any, Optional, Callable -import logging - - -@dataclass -class QueuedEvent: - """Represents an event in the queue with retry metadata.""" - - event_uid: str - event_type: str - device_id: str - payload: Dict[str, Any] - created_at: datetime - retry_count: int = 0 - next_retry_time: Optional[datetime] = None - max_retries: int = 10 - - def should_retry(self, current_time: datetime) -> bool: - """Check if event should be retried now.""" - if self.retry_count >= self.max_retries: - return False - - if self.next_retry_time is None: - return True - - return current_time >= self.next_retry_time - - def calculate_next_retry(self) -> datetime: - """Calculate next retry time with exponential backoff.""" - # Exponential backoff: 1s, 2s, 4s, 8s, 16s, ..., max 60s - delay_seconds = min(2 ** self.retry_count, 60) - return datetime.utcnow() + timedelta(seconds=delay_seconds) - - def increment_retry(self): - """Increment retry counter and set next retry time.""" - self.retry_count += 1 - self.next_retry_time = self.calculate_next_retry() - - -class EventQueue: - """Thread-safe event queue with retry logic.""" - - def __init__(self, send_callback: Callable[[Dict[str, Any]], bool], logger: Optional[logging.Logger] = None): - """ - Initialize event queue. - - Args: - send_callback: Function to send event to Odoo. Returns True on success, False on failure. - logger: Logger instance for queue operations. - """ - self.queue = deque() - self.lock = threading.Lock() - self.send_callback = send_callback - self.logger = logger or logging.getLogger(__name__) - - # Statistics - self.stats = { - 'pending_count': 0, - 'sent_count': 0, - 'failed_count': 0, - 'retry_count': 0 - } - - # Background processing - self.running = False - self.process_thread = None - - def enqueue(self, event: Dict[str, Any]) -> str: - """ - Add event to queue. - - Args: - event: Event dictionary with event_type, device_id, payload - - Returns: - event_uid: Unique identifier for the event - """ - # Generate UID if not present - event_uid = event.get('event_uid', str(uuid.uuid4())) - - queued_event = QueuedEvent( - event_uid=event_uid, - event_type=event['event_type'], - device_id=event['device_id'], - payload=event, - created_at=datetime.utcnow() - ) - - with self.lock: - self.queue.append(queued_event) - self.stats['pending_count'] = len(self.queue) - - self.logger.debug(f"event_enqueued uid={event_uid[:8]} type={event['event_type']} queue_size={self.stats['pending_count']}") - - return event_uid - - def process_queue(self): - """Process events in queue (runs in background thread).""" - while self.running: - try: - self._process_next_event() - time.sleep(0.1) # Small delay between processing attempts - except Exception as e: - self.logger.error(f"queue_processing_error error={str(e)}") - - def _process_next_event(self): - """Process next event in queue that's ready for (re)try.""" - with self.lock: - if not self.queue: - return - - # Find first event ready for retry - current_time = datetime.utcnow() - event_to_process = None - - for i, event in enumerate(self.queue): - if event.should_retry(current_time): - event_to_process = event - # Remove from queue for processing - del self.queue[i] - self.stats['pending_count'] = len(self.queue) - break - - if not event_to_process: - return - - # Send event (outside lock to avoid blocking queue) - success = False - try: - success = self.send_callback(event_to_process.payload) - except Exception as e: - self.logger.error(f"event_send_exception uid={event_to_process.event_uid[:8]} error={str(e)}") - success = False - - with self.lock: - if success: - # Event sent successfully - self.stats['sent_count'] += 1 - self.logger.info(f"event_sent_success uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} attempts={event_to_process.retry_count + 1}") - else: - # Send failed - if event_to_process.retry_count >= event_to_process.max_retries: - # Max retries exceeded - self.stats['failed_count'] += 1 - self.logger.error(f"event_send_failed_permanently uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} max_retries={event_to_process.max_retries}") - else: - # Re-queue with backoff - event_to_process.increment_retry() - self.queue.append(event_to_process) - self.stats['pending_count'] = len(self.queue) - self.stats['retry_count'] += 1 - - next_retry_delay = (event_to_process.next_retry_time - datetime.utcnow()).total_seconds() - self.logger.warning(f"event_send_failed_retry uid={event_to_process.event_uid[:8]} type={event_to_process.event_type} retry_count={event_to_process.retry_count} next_retry_in={next_retry_delay:.1f}s") - - def start(self): - """Start background queue processing.""" - if self.running: - self.logger.warning("queue_already_running") - return - - self.running = True - self.process_thread = threading.Thread(target=self.process_queue, daemon=True, name="EventQueueProcessor") - self.process_thread.start() - self.logger.info("queue_processor_started") - - def stop(self): - """Stop background queue processing.""" - if not self.running: - return - - self.running = False - if self.process_thread: - self.process_thread.join(timeout=5) - - self.logger.info(f"queue_processor_stopped pending={self.stats['pending_count']} sent={self.stats['sent_count']} failed={self.stats['failed_count']}") - - def get_stats(self) -> Dict[str, int]: - """Get queue statistics.""" - with self.lock: - return self.stats.copy() - - def get_queue_size(self) -> int: - """Get current queue size.""" - with self.lock: - return len(self.queue) diff --git a/open_workshop_mqtt/iot_bridge/logger_setup.py b/open_workshop_mqtt/iot_bridge/logger_setup.py deleted file mode 100644 index 7d0d7ec..0000000 --- a/open_workshop_mqtt/iot_bridge/logger_setup.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Logging setup for IoT Bridge.""" -import logging -import structlog -from config import LoggingConfig - - -def setup_logging(config: LoggingConfig): - """Configure structured logging with structlog.""" - - # Set log level - log_level = getattr(logging, config.level.upper(), logging.INFO) - logging.basicConfig(level=log_level) - - # Configure structlog - if config.format == "json": - renderer = structlog.processors.JSONRenderer() - else: - renderer = structlog.dev.ConsoleRenderer() - - structlog.configure( - processors=[ - structlog.stdlib.filter_by_level, - structlog.stdlib.add_logger_name, - structlog.stdlib.add_log_level, - structlog.processors.TimeStamper(fmt="iso"), - structlog.processors.StackInfoRenderer(), - structlog.processors.format_exc_info, - renderer, - ], - context_class=dict, - logger_factory=structlog.stdlib.LoggerFactory(), - cache_logger_on_first_use=True, - ) - - return structlog.get_logger() diff --git a/open_workshop_mqtt/iot_bridge/main.py b/open_workshop_mqtt/iot_bridge/main.py deleted file mode 100644 index 85db93f..0000000 --- a/open_workshop_mqtt/iot_bridge/main.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -""" -IoT Bridge Main Entry Point -""" -import sys -import signal -import os -import argparse -from pathlib import Path - -from config import load_config -from logger_setup import setup_logging -from odoo_client import MockOdooClient, OdooClient -from mqtt_client import MQTTClient -from shelly_parser import ShellyParser -from session_detector import SessionDetector -from event_queue import EventQueue - - -# Global flag for graceful shutdown -shutdown_flag = False -mqtt_client = None -session_detectors = {} -event_queue = None - - -def signal_handler(signum, frame): - """Handle shutdown signals.""" - global shutdown_flag - print(f"\nReceived signal {signum}, shutting down gracefully...") - shutdown_flag = True - - -def on_event_generated(event, logger, event_queue): - """Handle events from session detector - enqueue for retry logic.""" - logger.info(f"event_generated type={event['event_type']} device={event['device_id']} session_id={event.get('session_id', 'N/A')[:8]}") - - # Enqueue event (will be sent with retry logic) - event_queue.enqueue(event) - - -def on_mqtt_message(topic: str, payload: dict, logger, parser, device_id, detector): - """Handle incoming MQTT messages.""" - try: - # Parse message - parsed = parser.parse_message(topic, payload) - - if parsed and parsed.get('apower') is not None: - power_w = parsed['apower'] - - # Process in session detector - from datetime import datetime - detector.process_power_measurement(power_w, datetime.utcnow()) - - # logger.debug(f"power_measurement device={device_id} power={power_w}W state={detector.state}") - except Exception as e: - logger.error(f"on_mqtt_message_error error={str(e)}") - - -def main(): - """Main entry point for IoT Bridge.""" - global shutdown_flag, mqtt_client, session_detectors, event_queue - - # Parse command line arguments - parser = argparse.ArgumentParser(description='IoT MQTT Bridge for Odoo') - parser.add_argument('--config', type=str, default='config.yaml', - help='Path to configuration file (default: config.yaml)') - args = parser.parse_args() - - # Register signal handlers - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - - # Load configuration (--config argument overrides BRIDGE_CONFIG env var) - config_path = args.config if args.config != 'config.yaml' else os.getenv("BRIDGE_CONFIG", "config.yaml") - print(f"Loading configuration from {config_path}") - - try: - config = load_config(config_path) - except FileNotFoundError as e: - print(f"ERROR: {e}") - sys.exit(1) - except Exception as e: - print(f"ERROR: Failed to load config: {e}") - sys.exit(1) - - # Setup logging - logger = setup_logging(config.logging) - logger.info("bridge_started", config_file=config_path, devices=len(config.devices)) - - # Initialize Odoo client - if config.odoo.use_mock: - failure_rate = getattr(config.odoo, 'mock_failure_rate', 0.0) - logger.info("using_mock_odoo_client", failure_rate=failure_rate) - odoo_client = MockOdooClient(config.devices, failure_rate=failure_rate) - else: - logger.info("using_real_odoo_client", - base_url=config.odoo.base_url, - database=config.odoo.database) - odoo_client = OdooClient( - base_url=config.odoo.base_url, - database=config.odoo.database, - username=config.odoo.username, - api_key=config.odoo.api_key - ) - - # Test config loading - try: - device_config = odoo_client.get_config() - logger.info("config_loaded", device_count=len(device_config.get("devices", []))) - except Exception as e: - logger.error("config_load_failed", error=str(e)) - sys.exit(1) - - # Initialize Event Queue with retry logic - def send_to_odoo(event): - """Send event to Odoo, returns True on success.""" - try: - response = odoo_client.send_event(event) - return True - except Exception as e: - logger.error(f"odoo_send_failed error={str(e)}") - return False - - event_queue = EventQueue(send_callback=send_to_odoo, logger=logger) - event_queue.start() - logger.info("event_queue_started") - - # Initialize Session Detectors (one per device) - parser = ShellyParser() - - for device in config.devices: - session_cfg = device.session_config - - detector = SessionDetector( - device_id=device.device_id, - machine_name=device.machine_name, - standby_threshold_w=session_cfg.standby_threshold_w, - working_threshold_w=session_cfg.working_threshold_w, - start_debounce_s=session_cfg.start_debounce_s, - stop_debounce_s=session_cfg.stop_debounce_s, - message_timeout_s=session_cfg.message_timeout_s, - heartbeat_interval_s=session_cfg.heartbeat_interval_s, - event_callback=lambda evt: on_event_generated(evt, logger, event_queue) - ) - - session_detectors[device.device_id] = detector - logger.info(f"session_detector_initialized device={device.device_id} machine={device.machine_name}") - - # Initialize MQTT client - topics = [device.mqtt_topic for device in config.devices] - - # Create device mapping for message routing - device_map = {device.mqtt_topic: device.device_id for device in config.devices} - - def mqtt_callback(topic, payload): - device_id = device_map.get(topic) - if device_id and device_id in session_detectors: - on_mqtt_message(topic, payload, logger, parser, device_id, session_detectors[device_id]) - - mqtt_client = MQTTClient( - broker=config.mqtt.broker, - port=config.mqtt.port, - topics=topics, - message_callback=mqtt_callback, - username=config.mqtt.username, - password=config.mqtt.password, - client_id=config.mqtt.client_id, - use_tls=getattr(config.mqtt, 'use_tls', False) - ) - - # Connect to MQTT - if not mqtt_client.connect(): - logger.error("mqtt_connection_failed") - sys.exit(1) - - mqtt_client.start() - - if not mqtt_client.wait_for_connection(timeout=10): - logger.error("mqtt_connection_timeout") - sys.exit(1) - - logger.info("bridge_ready", status="running") - print("Bridge is running. Press Ctrl+C to stop.") - - # Main loop - while not shutdown_flag: - import time - time.sleep(1) - - # Check for timeouts - from datetime import datetime - current_time = datetime.utcnow() - for detector in session_detectors.values(): - detector.check_timeout(current_time) - - # Shutdown - logger.info("bridge_shutdown", status="stopping") - - # Stop event queue first (process remaining events) - if event_queue: - event_queue.stop() - - mqtt_client.stop() - logger.info("bridge_shutdown", status="stopped") - print("Bridge stopped.") - - -if __name__ == "__main__": - main() diff --git a/open_workshop_mqtt/iot_bridge/mqtt_client.py b/open_workshop_mqtt/iot_bridge/mqtt_client.py deleted file mode 100644 index 90e4fae..0000000 --- a/open_workshop_mqtt/iot_bridge/mqtt_client.py +++ /dev/null @@ -1,144 +0,0 @@ -"""MQTT Client for IoT Bridge - connects to broker and receives device events.""" -import json -import logging -import time -from typing import Callable, Optional, List -import paho.mqtt.client as mqtt - -logger = logging.getLogger(__name__) - - -class MQTTClient: - """MQTT Client wrapper for device event reception.""" - - def __init__(self, broker: str, port: int, topics: List[str], - message_callback: Optional[Callable] = None, - username: Optional[str] = None, - password: Optional[str] = None, - client_id: str = "iot_bridge", - use_tls: bool = False): - """ - Initialize MQTT Client. - - Args: - broker: MQTT broker hostname - port: MQTT broker port - topics: List of topics to subscribe to - message_callback: Callback(topic, payload_dict) for incoming messages - username: Optional MQTT username - password: Optional MQTT password - client_id: MQTT client ID - """ - self.broker = broker - self.port = port - self.topics = topics - self.message_callback = message_callback - - # Create MQTT Client (v5) - self.client = mqtt.Client( - client_id=client_id, - protocol=mqtt.MQTTv5 - ) - - # Set callbacks - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect - self.client.on_message = self._on_message - - # Auth - if username and password: - self.client.username_pw_set(username, password) - - # TLS/SSL for port 8883 - if use_tls or port == 8883: - import ssl - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - logger.info(f"tls_enabled port={port}") - - self.connected = False - self.reconnect_delay = 1 - self.max_reconnect_delay = 60 - - logger.info(f"MQTT Client initialized for {broker}:{port}") - - def _on_connect(self, client, userdata, flags, rc, properties=None): - """Callback when connected to MQTT broker.""" - if rc == 0: - self.connected = True - self.reconnect_delay = 1 - logger.info(f"connected_to_mqtt broker={self.broker} port={self.port}") - - # Subscribe to all topics - for topic in self.topics: - self.client.subscribe(topic) - logger.info(f"subscribed_to_topic topic={topic}") - else: - logger.error(f"mqtt_connection_failed rc={rc}") - self.connected = False - - def _on_disconnect(self, client, userdata, rc, properties=None): - """Callback when disconnected from MQTT broker.""" - self.connected = False - if rc != 0: - logger.warning(f"mqtt_unexpected_disconnect rc={rc}") - # Auto-reconnect handled by paho - else: - logger.info("mqtt_disconnected") - - def _on_message(self, client, userdata, msg): - """Callback when message received.""" - try: - topic = msg.topic - payload = msg.payload.decode('utf-8') - - # Try JSON parse - try: - payload_json = json.loads(payload) - except json.JSONDecodeError: - logger.debug(f"non_json_message topic={topic}") - payload_json = None - - # Call user callback - if self.message_callback and payload_json: - self.message_callback(topic, payload_json) - - except Exception as e: - logger.error(f"message_processing_error error={str(e)} topic={topic}") - - def connect(self) -> bool: - """Connect to MQTT broker.""" - try: - logger.info(f"connecting_to_mqtt broker={self.broker} port={self.port}") - self.client.connect(self.broker, self.port, keepalive=60) - return True - except Exception as e: - logger.error(f"mqtt_connect_error error={str(e)}") - return False - - def start(self): - """Start MQTT client loop (non-blocking).""" - self.client.loop_start() - logger.info("mqtt_loop_started") - - def stop(self): - """Stop MQTT client loop.""" - self.client.loop_stop() - self.client.disconnect() - logger.info("mqtt_client_stopped") - - def wait_for_connection(self, timeout: int = 10) -> bool: - """ - Wait for connection to be established. - - Args: - timeout: Maximum seconds to wait - - Returns: - True if connected, False if timeout - """ - start_time = time.time() - while not self.connected and (time.time() - start_time) < timeout: - time.sleep(0.1) - - return self.connected diff --git a/open_workshop_mqtt/iot_bridge/odoo_client.py b/open_workshop_mqtt/iot_bridge/odoo_client.py deleted file mode 100644 index 7ac082c..0000000 --- a/open_workshop_mqtt/iot_bridge/odoo_client.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Odoo API Client - handles communication with Odoo REST API.""" -import logging -from typing import List, Dict, Any -from dataclasses import asdict - -from config import DeviceConfig - - -logger = logging.getLogger(__name__) - - -class MockOdooClient: - """Mock Odoo client for standalone testing.""" - - def __init__(self, devices: List[DeviceConfig], failure_rate: float = 0.0): - """ - Initialize with hardcoded device config. - - Args: - devices: List of device configurations - failure_rate: Probability of send_event failing (0.0-1.0) for testing retry logic - """ - self.devices = devices - self.failure_rate = failure_rate - self.call_count = 0 - logger.info("MockOdooClient initialized with %d devices (failure_rate=%.1f%%)", len(devices), failure_rate * 100) - - def get_config(self) -> Dict[str, Any]: - """Return hardcoded device configuration.""" - devices_data = [] - for device in self.devices: - devices_data.append({ - "device_id": device.device_id, - "mqtt_topic": device.mqtt_topic, - "parser_type": device.parser_type, - "machine_name": device.machine_name, - "session_config": asdict(device.session_config) - }) - - logger.debug("Returning config for %d devices", len(devices_data)) - return {"devices": devices_data} - - def send_event(self, event: Dict[str, Any]) -> Dict[str, Any]: - """ - Mock event sending - simulates failures based on failure_rate. - - Raises: - Exception: If failure is simulated - """ - import random - - self.call_count += 1 - - # Simulate failure - if self.failure_rate > 0 and random.random() < self.failure_rate: - logger.warning( - "MOCK: Simulated failure for event type=%s device=%s (call #%d)", - event.get("event_type"), - event.get("device_id"), - self.call_count - ) - raise Exception("Simulated Odoo API failure (500 Internal Server Error)") - - # Success - logger.info( - "MOCK: Successfully sent event type=%s device=%s (call #%d)", - event.get("event_type"), - event.get("device_id"), - self.call_count - ) - return { - "status": "ok", - "event_id": 999, - "session_id": 123 - } - - -class OdooClient: - """Real Odoo API client using REST API.""" - - def __init__(self, base_url: str, database: str, username: str, api_key: str): - """ - Initialize Odoo REST API client. - - Args: - base_url: Odoo base URL (e.g., http://odoo-dev:8069) - database: Odoo database name - username: Odoo username (usually 'admin') - api_key: API key for authentication - """ - self.base_url = base_url.rstrip('/') - self.database = database - self.username = username - self.api_key = api_key - self.session = None - logger.info("OdooClient initialized for %s (database: %s)", base_url, database) - - # Initialize HTTP session - import requests - self.session = requests.Session() - self.session.headers.update({ - 'Content-Type': 'application/json', - }) - - def get_config(self) -> Dict[str, Any]: - """ - Fetch device configuration from Odoo. - - Returns: - Dict with 'devices' key containing list of device configs - - Raises: - Exception: If API call fails - """ - url = f"{self.base_url}/ows/iot/config" - - try: - logger.debug("GET %s", url) - response = self.session.get(url, timeout=10) - response.raise_for_status() - - data = response.json() - logger.info("Fetched config for %d devices from Odoo", len(data.get('devices', []))) - return data - - except Exception as e: - logger.error("Failed to fetch config from Odoo: %s", e) - raise - - def send_event(self, event: Dict[str, Any]) -> Dict[str, Any]: - """ - Send event to Odoo via POST /ows/iot/event. - - Args: - event: Event dict with event_uid, event_type, device_id, etc. - - Returns: - Dict with response from Odoo (status, event_id, etc.) - - Raises: - Exception: If API call fails (4xx, 5xx status) - """ - url = f"{self.base_url}/ows/iot/event" - - try: - logger.debug("POST %s - event_uid=%s type=%s", url, event.get('event_uid'), event.get('event_type')) - - # POST as JSON-RPC format (Odoo's type='json' expects this) - response = self.session.post( - url, - json={ - "jsonrpc": "2.0", - "method": "call", - "params": event, - "id": None - }, - timeout=10 - ) - - response.raise_for_status() - data = response.json() - - # Check JSON-RPC result - if 'error' in data: - raise Exception(f"Odoo JSON-RPC error: {data['error']}") - - result = data.get('result', {}) - code = result.get('code', 200) - - # Handle duplicate (409) as success - if code == 409: - logger.debug("Event %s already exists in Odoo (duplicate)", event.get('event_uid')) - return result - - # Handle other errors - if code >= 400: - raise Exception(f"Odoo returned error {code}: {result.get('error', 'Unknown error')}") - - logger.info("Event sent successfully: uid=%s device=%s", event.get('event_uid'), event.get('device_id')) - return result - - except Exception as e: - logger.error("Failed to send event to Odoo: %s", e) - raise diff --git a/open_workshop_mqtt/iot_bridge/requirements.txt b/open_workshop_mqtt/iot_bridge/requirements.txt deleted file mode 100644 index 93aec54..0000000 --- a/open_workshop_mqtt/iot_bridge/requirements.txt +++ /dev/null @@ -1,36 +0,0 @@ -# IoT Bridge - Python Dependencies - -# Configuration -pyyaml>=6.0 - -# MQTT Client -paho-mqtt>=2.0.0 - -# HTTP Client -requests>=2.31.0 - -# Structured Logging -structlog>=23.1.0 - -# Configuration -pyyaml>=6.0 - -# HTTP Client for Odoo API -requests>=2.31.0 - -# Configuration & ENV -python-dotenv>=1.0.0 - -# Logging -structlog>=24.1.0 - -# Retry-Queue (optional, wenn SQLite verwendet wird) -# sqlalchemy>=2.0.0 - -# Testing -pytest>=8.0.0 -pytest-cov>=4.1.0 -pytest-asyncio>=0.23.0 - -# Type Hints -typing-extensions>=4.9.0 diff --git a/open_workshop_mqtt/iot_bridge/session_detector.py b/open_workshop_mqtt/iot_bridge/session_detector.py deleted file mode 100644 index 0eea9b9..0000000 --- a/open_workshop_mqtt/iot_bridge/session_detector.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Session Detector with Aggregation - State Machine for IoT Bridge - -State Machine: -IDLE → STARTING → STANDBY → WORKING → STOPPING → IDLE - ↓ ↓ ↓ - IDLE (STANDBY ↔ WORKING) - -Aggregation: -- Tracks state internally every message (1 msg/s) -- Accumulates interval_working_s, interval_standby_s -- Emits aggregated heartbeat events every heartbeat_interval_s (e.g., 300s) -""" - -import logging -import uuid -from datetime import datetime, timedelta -from typing import Optional, Dict, Callable - -logger = logging.getLogger(__name__) - - -class SessionDetector: - """ - State Machine for Power-Based Session Detection with Aggregation. - - Emits events: - - session_started: When session begins - - session_heartbeat: Periodic aggregated updates - - session_ended: When session ends normally - - session_timeout: When session times out - """ - - def __init__(self, device_id: str, machine_name: str, - standby_threshold_w: float, - working_threshold_w: float, - start_debounce_s: float, - stop_debounce_s: float, - message_timeout_s: float, - heartbeat_interval_s: float, - event_callback: Optional[Callable] = None): - """ - Initialize Session Detector. - - Args: - device_id: Device identifier - machine_name: Human-readable machine name - standby_threshold_w: Power threshold for session start (e.g., 20W) - working_threshold_w: Power threshold for working state (e.g., 100W) - start_debounce_s: Debounce time for session start (e.g., 3s) - stop_debounce_s: Debounce time for session stop (e.g., 15s) - message_timeout_s: Max time without messages before timeout (e.g., 20s) - heartbeat_interval_s: Interval for aggregated heartbeat events (e.g., 300s) - event_callback: Callback function(event_dict) for emitting events - """ - self.device_id = device_id - self.machine_name = machine_name - self.standby_threshold_w = standby_threshold_w - self.working_threshold_w = working_threshold_w - self.start_debounce_s = start_debounce_s - self.stop_debounce_s = stop_debounce_s - self.message_timeout_s = message_timeout_s - self.heartbeat_interval_s = heartbeat_interval_s - self.event_callback = event_callback - - # State - self.state = 'idle' - self.current_session_id: Optional[str] = None - - # Timing - self.state_entered_at: Optional[datetime] = None - self.last_message_time: Optional[datetime] = None - self.session_start_time: Optional[datetime] = None - self.last_heartbeat_time: Optional[datetime] = None - - # Aggregation counters (since last heartbeat) - self.interval_working_s = 0.0 - self.interval_standby_s = 0.0 - self.interval_power_samples = [] - self.interval_state_changes = 0 - - # Total counters (entire session) - self.total_working_s = 0.0 - self.total_standby_s = 0.0 - - logger.info(f"SessionDetector initialized device={device_id} machine={machine_name} " - f"standby={standby_threshold_w}W working={working_threshold_w}W " - f"heartbeat={heartbeat_interval_s}s") - - def process_power_measurement(self, power_w: float, timestamp: datetime): - """ - Process a power measurement. - - Args: - power_w: Power in watts - timestamp: Measurement timestamp - """ - self.last_message_time = timestamp - - # State machine - if self.state == 'idle': - self._handle_idle(power_w, timestamp) - elif self.state == 'starting': - self._handle_starting(power_w, timestamp) - elif self.state == 'standby': - self._handle_standby(power_w, timestamp) - elif self.state == 'working': - self._handle_working(power_w, timestamp) - elif self.state == 'stopping': - self._handle_stopping(power_w, timestamp) - - # Collect power sample for aggregation - if self.current_session_id: - self.interval_power_samples.append(power_w) - - # Check for heartbeat emission - if self.current_session_id and self.last_heartbeat_time: - time_since_heartbeat = (timestamp - self.last_heartbeat_time).total_seconds() - if time_since_heartbeat >= self.heartbeat_interval_s: - self._emit_heartbeat(timestamp) - - def _handle_idle(self, power_w: float, timestamp: datetime): - """IDLE State: Wait for power above standby threshold.""" - if power_w > self.standby_threshold_w: - self._transition_to('starting', timestamp) - - def _handle_starting(self, power_w: float, timestamp: datetime): - """STARTING State: Debounce period before confirming session start.""" - if power_w < self.standby_threshold_w: - logger.info(f"device={self.device_id} power_dropped_during_starting back_to=idle") - self._transition_to('idle', timestamp) - return - - time_in_state = (timestamp - self.state_entered_at).total_seconds() - if time_in_state >= self.start_debounce_s: - self._start_session(power_w, timestamp) - self._transition_to('standby', timestamp) - - def _handle_standby(self, power_w: float, timestamp: datetime): - """STANDBY State: Session running at low power.""" - # Accumulate standby time - if self.state_entered_at: - time_in_state = (timestamp - self.state_entered_at).total_seconds() - self.interval_standby_s += time_in_state - self.total_standby_s += time_in_state - - if power_w < self.standby_threshold_w: - self._transition_to('stopping', timestamp) - elif power_w > self.working_threshold_w: - self._transition_to('working', timestamp) - else: - # Stay in standby, update state entry time for next increment - self.state_entered_at = timestamp - - def _handle_working(self, power_w: float, timestamp: datetime): - """WORKING State: Session running at high power.""" - # Accumulate working time - if self.state_entered_at: - time_in_state = (timestamp - self.state_entered_at).total_seconds() - self.interval_working_s += time_in_state - self.total_working_s += time_in_state - - if power_w < self.standby_threshold_w: - self._transition_to('stopping', timestamp) - elif power_w < self.working_threshold_w: - self._transition_to('standby', timestamp) - else: - # Stay in working, update state entry time for next increment - self.state_entered_at = timestamp - - def _handle_stopping(self, power_w: float, timestamp: datetime): - """STOPPING State: Debounce period before ending session.""" - if power_w > self.standby_threshold_w: - if power_w > self.working_threshold_w: - logger.info(f"device={self.device_id} power_resumed_during_stopping back_to=working") - self._transition_to('working', timestamp) - else: - logger.info(f"device={self.device_id} power_resumed_during_stopping back_to=standby") - self._transition_to('standby', timestamp) - return - - time_in_state = (timestamp - self.state_entered_at).total_seconds() - if time_in_state >= self.stop_debounce_s: - self._end_session('normal', timestamp) - self._transition_to('idle', timestamp) - - def _transition_to(self, new_state: str, timestamp: datetime): - """Transition to a new state.""" - old_state = self.state - self.state = new_state - self.state_entered_at = timestamp - - if self.current_session_id: - self.interval_state_changes += 1 - - logger.info(f"device={self.device_id} state_transition from={old_state} to={new_state}") - - def _start_session(self, power_w: float, timestamp: datetime): - """Start a new session.""" - self.current_session_id = str(uuid.uuid4()) - self.session_start_time = timestamp - self.last_heartbeat_time = timestamp - - # Reset counters - self.interval_working_s = 0.0 - self.interval_standby_s = 0.0 - self.interval_power_samples = [] - self.interval_state_changes = 0 - self.total_working_s = 0.0 - self.total_standby_s = 0.0 - - logger.info(f"device={self.device_id} session_started session_id={self.current_session_id[:8]} power={power_w}W") - - if self.event_callback: - self.event_callback({ - 'event_uid': str(uuid.uuid4()), - 'event_type': 'session_started', - 'device_id': self.device_id, - 'session_id': self.current_session_id, - 'machine_name': self.machine_name, - 'timestamp': timestamp.isoformat(), - 'payload': { - 'start_power_w': power_w, - 'start_state': 'standby' - } - }) - - def _emit_heartbeat(self, timestamp: datetime): - """Emit aggregated heartbeat event.""" - if not self.current_session_id: - return - - # Calculate average power - avg_power = sum(self.interval_power_samples) / len(self.interval_power_samples) if self.interval_power_samples else 0 - - logger.info(f"device={self.device_id} session_heartbeat session_id={self.current_session_id[:8]} " - f"interval_working={self.interval_working_s:.0f}s interval_standby={self.interval_standby_s:.0f}s " - f"avg_power={avg_power:.1f}W state_changes={self.interval_state_changes}") - - if self.event_callback: - self.event_callback({ - 'event_uid': str(uuid.uuid4()), - 'event_type': 'session_heartbeat', - 'device_id': self.device_id, - 'session_id': self.current_session_id, - 'machine_name': self.machine_name, - 'timestamp': timestamp.isoformat(), - 'payload': { - 'interval_working_s': round(self.interval_working_s, 1), - 'interval_standby_s': round(self.interval_standby_s, 1), - 'current_state': self.state, - 'avg_power_w': round(avg_power, 1), - 'state_change_count': self.interval_state_changes, - 'total_working_s': round(self.total_working_s, 1), - 'total_standby_s': round(self.total_standby_s, 1) - } - }) - - # Reset interval counters - self.interval_working_s = 0.0 - self.interval_standby_s = 0.0 - self.interval_power_samples = [] - self.interval_state_changes = 0 - self.last_heartbeat_time = timestamp - - def _end_session(self, reason: str, timestamp: datetime): - """End the current session.""" - if not self.current_session_id: - return - - # Emit final heartbeat if there's accumulated data - if self.interval_working_s > 0 or self.interval_standby_s > 0: - self._emit_heartbeat(timestamp) - - total_duration = (timestamp - self.session_start_time).total_seconds() if self.session_start_time else 0 - - logger.info(f"device={self.device_id} session_ended session_id={self.current_session_id[:8]} " - f"reason={reason} duration={total_duration:.0f}s " - f"total_working={self.total_working_s:.0f}s total_standby={self.total_standby_s:.0f}s") - - if self.event_callback: - self.event_callback({ - 'event_uid': str(uuid.uuid4()), - 'event_type': 'session_ended' if reason == 'normal' else 'session_timeout', - 'device_id': self.device_id, - 'session_id': self.current_session_id, - 'machine_name': self.machine_name, - 'timestamp': timestamp.isoformat(), - 'payload': { - 'end_reason': reason, - 'total_duration_s': round(total_duration, 1), - 'total_working_s': round(self.total_working_s, 1), - 'total_standby_s': round(self.total_standby_s, 1) - } - }) - - self.current_session_id = None - - def check_timeout(self, current_time: datetime): - """Check if session timed out (no messages for too long).""" - if not self.current_session_id or not self.last_message_time: - return - - time_since_last = (current_time - self.last_message_time).total_seconds() - if time_since_last > self.message_timeout_s: - logger.warning(f"device={self.device_id} session_timeout no_messages_for={time_since_last:.0f}s") - self._end_session('timeout', current_time) - self._transition_to('idle', current_time) diff --git a/open_workshop_mqtt/iot_bridge/shelly_parser.py b/open_workshop_mqtt/iot_bridge/shelly_parser.py deleted file mode 100644 index c84db93..0000000 --- a/open_workshop_mqtt/iot_bridge/shelly_parser.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Shelly PM Mini G3 Parser - extracts power data from MQTT messages.""" -import json -import logging -from typing import Dict, Optional -from datetime import datetime - -logger = logging.getLogger(__name__) - - -class ShellyParser: - """Parser for Shelly PM Mini G3 MQTT Messages.""" - - def parse_message(self, topic: str, payload: dict) -> Optional[Dict]: - """ - Parse Shelly MQTT message. - - Args: - topic: MQTT topic - payload: Message payload (already JSON parsed) - - Returns: - Dict with parsed data or None - """ - try: - # Only parse status messages - if '/status/pm1:0' in topic: - return self._parse_status_message(topic, payload) - - return None - - except Exception as e: - logger.debug(f"parse_error topic={topic} error={str(e)}") - return None - - def _parse_status_message(self, topic: str, data: dict) -> Optional[Dict]: - """ - Parse Shelly PM status message. - Topic: shaperorigin/status/pm1:0 - - Payload format: - { - "id": 0, - "voltage": 230.0, - "current": 0.217, - "apower": 50.0, - "freq": 50.0, - "aenergy": {"total": 12345.6}, - "temperature": {"tC": 35.2} - } - """ - try: - device_id = self._extract_device_id(topic) - - result = { - 'message_type': 'status', - 'device_id': device_id, - 'timestamp': datetime.utcnow().isoformat() + 'Z', - 'voltage': data.get('voltage'), - 'current': data.get('current'), - 'apower': data.get('apower', 0), # Active Power in Watts - 'frequency': data.get('freq'), - 'total_energy': data.get('aenergy', {}).get('total'), - 'temperature': data.get('temperature', {}).get('tC'), - } - - logger.debug(f"parsed_status device={device_id} apower={result['apower']}") - return result - - except Exception as e: - logger.error(f"status_parse_error error={str(e)}") - return None - - def _extract_device_id(self, topic: str) -> str: - """Extract device ID from topic path.""" - # Example: shaperorigin/status/pm1:0 -> shaperorigin - parts = topic.split('/') - if len(parts) > 0: - return parts[0] - return "unknown" diff --git a/open_workshop_mqtt/iot_bridge/tests/__init__.py b/open_workshop_mqtt/iot_bridge/tests/__init__.py deleted file mode 100644 index fa16943..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""IoT Bridge Tests""" diff --git a/open_workshop_mqtt/iot_bridge/tests/integration/__init__.py b/open_workshop_mqtt/iot_bridge/tests/integration/__init__.py deleted file mode 100644 index 211d7ae..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/integration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Integration tests for iot_bridge diff --git a/open_workshop_mqtt/iot_bridge/tests/integration/test_bridge_integration.py b/open_workshop_mqtt/iot_bridge/tests/integration/test_bridge_integration.py deleted file mode 100644 index 6c8acc2..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/integration/test_bridge_integration.py +++ /dev/null @@ -1,459 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration Tests für IoT Bridge - -Testet komplette Pipeline: -MQTT Simulator → Bridge → SessionDetector → Event Callback - -Usage: - cd iot_bridge - source venv/bin/activate - pytest tests/integration/test_bridge_integration.py -v -s -""" - -import pytest -import subprocess -import time -import json -import signal -import os -from pathlib import Path -from datetime import datetime -import sys - - -@pytest.fixture(scope="session") -def workspace_dir(): - """iot_bridge root directory""" - return Path(__file__).parent.parent.parent - - -@pytest.fixture(scope="session") -def test_config_file(workspace_dir): - """Erstellt temporäre test_config.yaml für Integration Tests""" - - # Use local Mosquitto container (no auth needed) - # If running outside docker-compose, fallback to cloud broker - import socket - - # Try localhost first (docker-compose.dev.yaml mosquitto) - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - result = sock.connect_ex(('localhost', 1883)) - sock.close() - - if result == 0: - # Local Mosquitto available - mqtt_broker = "localhost" - mqtt_port = 1883 - mqtt_username = None - mqtt_password = None - use_tls = False - print("\n✅ Using local Mosquitto container (localhost:1883)") - else: - raise ConnectionError("Local broker not available") - except: - # Fallback to cloud broker with credentials from config.yaml - import yaml - config_path = workspace_dir / "config.yaml" - - if not config_path.exists(): - pytest.skip("config.yaml nicht gefunden und kein lokaler Mosquitto Container.") - - with open(config_path) as f: - real_config = yaml.safe_load(f) - - mqtt_config = real_config.get('mqtt', {}) - mqtt_broker = mqtt_config.get('broker', 'mqtt.majufilo.eu') - mqtt_port = mqtt_config.get('port', 8883) - mqtt_username = mqtt_config.get('username') - mqtt_password = mqtt_config.get('password') - use_tls = mqtt_config.get('use_tls', True) - print(f"\n⚠️ Using cloud broker {mqtt_broker}:{mqtt_port}") - - # Config mit MQTT Broker (lokal oder cloud) - username_line = f' username: "{mqtt_username}"' if mqtt_username else ' # username: ""' - password_line = f' password: "{mqtt_password}"' if mqtt_password else ' # password: ""' - - config_content = f""" -mqtt: - broker: "{mqtt_broker}" - port: {mqtt_port} -{username_line} -{password_line} - use_tls: {str(use_tls).lower()} - client_id: "iot_bridge_pytest" - keepalive: 60 - -odoo: - url: "http://localhost:8069" - token: "dummy-token-for-tests" - use_mock: true - -logging: - level: "INFO" - format: "json" - -devices: - - device_id: "pytest-device-01" - machine_name: "PyTest Machine 1" - mqtt_topic: "testshelly-m1/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20.0 - working_threshold_w: 100.0 - start_debounce_s: 3.0 - stop_debounce_s: 15.0 - message_timeout_s: 20.0 - heartbeat_interval_s: 10.0 - - device_id: "pytest-device-02" - machine_name: "PyTest Machine 2" - mqtt_topic: "testshelly-m2/status/pm1:0" - parser_type: "shelly_pm_mini_g3" - session_config: - strategy: "power_threshold" - standby_threshold_w: 20.0 - working_threshold_w: 100.0 - start_debounce_s: 3.0 - stop_debounce_s: 15.0 - message_timeout_s: 20.0 - heartbeat_interval_s: 10.0 -""" - - config_path = workspace_dir / "test_config.yaml" - config_path.write_text(config_content) - - yield config_path - - # Cleanup - if config_path.exists(): - config_path.unlink() - - -@pytest.fixture(scope="session") -def test_events_log(workspace_dir): - """Log-Datei für emittierte Events""" - log_file = workspace_dir / "test_events.log" - - # Löschen wenn vorhanden - if log_file.exists(): - log_file.unlink() - - yield log_file - - # Cleanup optional (zum Debugging behalten) - # if log_file.exists(): - # log_file.unlink() - - -@pytest.fixture(scope="module") -def bridge_process(workspace_dir, test_config_file): - """Startet die IoT Bridge als Subprocess""" - - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' - - # Python aus venv - python_exe = workspace_dir / 'venv' / 'bin' / 'python' - if not python_exe.exists(): - pytest.skip("Kein venv gefunden. Bitte 'python -m venv venv' ausführen.") - - bridge_log = workspace_dir / 'bridge_integration_test.log' - - with open(bridge_log, 'w') as log_file: - process = subprocess.Popen( - [str(python_exe), 'main.py', '--config', str(test_config_file)], - cwd=workspace_dir, - env=env, - stdout=log_file, - stderr=subprocess.STDOUT, - text=True - ) - - # Warten bis Bridge connected ist - print("\n⏳ Warte auf Bridge Start...") - time.sleep(5) - - # Prüfen ob Prozess läuft - if process.poll() is not None: - with open(bridge_log, 'r') as f: - output = f.read() - pytest.fail(f"Bridge konnte nicht gestartet werden:\n{output}") - - print("✅ Bridge gestartet") - - yield process - - # Bridge sauber beenden - print("\n🛑 Beende Bridge...") - process.send_signal(signal.SIGTERM) - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - - -@pytest.fixture -def simulator_process(workspace_dir, test_config_file): - """Startet Shelly Simulator als Subprocess""" - - python_exe = workspace_dir / 'venv' / 'bin' / 'python' - simulator_path = workspace_dir / 'tests' / 'tools' / 'shelly_simulator.py' - - if not simulator_path.exists(): - pytest.skip("shelly_simulator.py nicht gefunden") - - # Load MQTT config from test_config (same as bridge uses) - import yaml - with open(test_config_file) as f: - config = yaml.safe_load(f) - - mqtt_config = config.get('mqtt', {}) - broker = mqtt_config.get('broker', 'localhost') - port = mqtt_config.get('port', 1883) - username = mqtt_config.get('username') - password = mqtt_config.get('password') - use_tls = mqtt_config.get('use_tls', False) - - def run_scenario(scenario: str, topic_prefix: str, duration: int = 30, power: float = None): - """Startet Simulator mit Szenario""" - - cmd = [ - str(python_exe), - str(simulator_path), - '--broker', broker, - '--port', str(port), - '--topic-prefix', topic_prefix, - '--scenario', scenario, - '--duration', str(duration) - ] - - # Add credentials only if present - if username: - cmd.extend(['--username', username]) - if password: - cmd.extend(['--password', password]) - - # Add --no-tls if TLS is disabled - if not use_tls: - cmd.append('--no-tls') - - if power is not None: - cmd.extend(['--power', str(power)]) - - print(f"\n🔧 Starte Simulator: {scenario} (topic={topic_prefix})") - print(f" Command: {' '.join(cmd)}") - - # Simulator im Hintergrund starten - STDOUT auf console für Debugging - process = subprocess.Popen( - cmd, - cwd=workspace_dir, - stdout=None, # Inherit stdout (zeigt auf console) - stderr=subprocess.STDOUT, - text=True - ) - - # Kurz warten damit Simulator sich connecten kann - time.sleep(2) - - return process - - yield run_scenario - - -def wait_for_log_line(log_file: Path, search_string: str, timeout: int = 15): - """Wartet bis search_string im Log auftaucht""" - start_time = time.time() - - while time.time() - start_time < timeout: - if log_file.exists(): - with open(log_file, 'r') as f: - content = f.read() - if search_string in content: - return True - time.sleep(0.5) - - return False - - -def count_event_type_in_log(log_file: Path, event_type: str): - """Zählt wie oft event_type im Bridge-Log vorkommt""" - if not log_file.exists(): - return 0 - - count = 0 - with open(log_file, 'r') as f: - for line in f: - if event_type in line and '"event_type"' in line: - count += 1 - - return count - - -class TestBridgeIntegration: - """Integration Tests mit Simulator""" - - def test_bridge_is_running(self, bridge_process): - """Bridge läuft erfolgreich""" - assert bridge_process.poll() is None, "Bridge Prozess ist abgestürzt" - - def test_session_start_standby(self, bridge_process, simulator_process, workspace_dir): - """Session Start → STANDBY via Simulator""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Simulator: standby Szenario (40-60W) - sim = simulator_process('standby', 'testshelly-m1', duration=15) - - # Warten auf session_started Event - print("⏳ Warte auf session_started Event...") - found = wait_for_log_line(log_file, 'session_started', timeout=20) - - # Simulator beenden - sim.terminate() - sim.wait() - - assert found, "session_started Event wurde nicht geloggt" - - # Log ausgeben - with open(log_file, 'r') as f: - lines = f.readlines() - for line in lines[-20:]: # Letzte 20 Zeilen - if 'session_started' in line: - print(f"\n✅ Event gefunden: {line.strip()}") - - def test_session_heartbeat(self, bridge_process, simulator_process, workspace_dir): - """Session Heartbeat nach 10s""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Simulator: standby für 20s (heartbeat_interval_s = 10s) - sim = simulator_process('standby', 'testshelly-m1', duration=20) - - # Warten auf session_heartbeat Event - print("⏳ Warte auf session_heartbeat Event (10s)...") - found = wait_for_log_line(log_file, 'session_heartbeat', timeout=25) - - sim.terminate() - sim.wait() - - assert found, "session_heartbeat Event wurde nicht geloggt" - - # Log ausgeben - with open(log_file, 'r') as f: - lines = f.readlines() - for line in lines[-20:]: - if 'session_heartbeat' in line: - print(f"\n✅ Heartbeat gefunden: {line.strip()}") - - def test_full_session_cycle(self, bridge_process, simulator_process, workspace_dir): - """Kompletter Session-Zyklus: Start → Working → Heartbeat → End""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Simulator: full_session Szenario (dauert ~80s) - print("\n🔄 Starte Full Session Szenario...") - sim = simulator_process('full_session', 'testshelly-m1', duration=90) - - # Warten auf Events - time.sleep(5) - assert wait_for_log_line(log_file, 'session_started', timeout=10), "session_started fehlt" - - time.sleep(12) # Warten auf Heartbeat (10s interval + buffer) - assert wait_for_log_line(log_file, 'session_heartbeat', timeout=5), "session_heartbeat fehlt" - - # Warten bis Simulator fertig ist (80s Szenario) - sim.wait(timeout=90) - - time.sleep(3) # Buffer für session_ended - assert wait_for_log_line(log_file, 'session_ended', timeout=5), "session_ended fehlt" - - print("✅ Full Session Cycle erfolgreich") - - def test_standby_to_working_transition(self, bridge_process, simulator_process, workspace_dir): - """STANDBY → WORKING Transition""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Simulator: working Szenario (>100W) - sim = simulator_process('working', 'testshelly-m1', duration=15) - - # Warten auf session_started - time.sleep(5) - found_start = wait_for_log_line(log_file, 'session_started', timeout=10) - - sim.terminate() - sim.wait() - - assert found_start, "session_started Event fehlt" - - # Prüfen ob WORKING State erreicht wurde - with open(log_file, 'r') as f: - content = f.read() - # Suche nach State-Wechsel Logs - assert 'working' in content.lower() or 'standby' in content.lower(), "Keine State-Logs gefunden" - - print("✅ State Transition Test erfolgreich") - - def test_multi_device_parallel_sessions(self, bridge_process, simulator_process, workspace_dir): - """Zwei Devices parallel""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Log-Marker setzen vor Test - start_marker = f"=== TEST START {time.time()} ===" - - # Zwei Simulatoren parallel starten - print(f"\n🔄 Starte 2 Devices parallel... {start_marker}") - sim1 = simulator_process('standby', 'testshelly-m1', duration=25) - time.sleep(2) # Mehr Zeit zwischen Starts - sim2 = simulator_process('standby', 'testshelly-m2', duration=25) - - # Warten auf beide session_started Events - time.sleep(10) # Mehr Zeit für beide Sessions - - # Prüfe ob beide Devices session_started haben - found_device1 = wait_for_log_line(log_file, 'session_started device=pytest-device-01', timeout=5) - found_device2 = wait_for_log_line(log_file, 'session_started device=pytest-device-02', timeout=5) - - sim1.terminate() - sim2.terminate() - sim1.wait() - sim2.wait() - - # Beide Sessions sollten gestartet sein - assert found_device1, "Device 1 Session nicht gestartet" - assert found_device2, "Device 2 Session nicht gestartet" - - print("✅ 2 parallele Sessions erfolgreich") - - def test_session_timeout(self, bridge_process, simulator_process, workspace_dir): - """Session Timeout nach 20s ohne Messages""" - - log_file = workspace_dir / 'bridge_integration_test.log' - - # Simulator für 10s, dann stoppen - sim = simulator_process('standby', 'testshelly-m1', duration=10) - - # Session starten - time.sleep(8) - assert wait_for_log_line(log_file, 'session_started', timeout=10) - - sim.wait() - - # Jetzt 25s warten (> 20s timeout) - print("\n⏱️ Warte 25s für Timeout Detection...") - time.sleep(25) - - # Prüfe auf session_timeout Event - found_timeout = wait_for_log_line(log_file, 'session_timeout', timeout=5) - - assert found_timeout, "session_timeout Event wurde nicht geloggt" - - print("✅ Timeout Detection funktioniert") - - -if __name__ == '__main__': - pytest.main([__file__, '-v', '-s']) diff --git a/open_workshop_mqtt/iot_bridge/tests/integration/test_retry_logic.py b/open_workshop_mqtt/iot_bridge/tests/integration/test_retry_logic.py deleted file mode 100644 index d488947..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/integration/test_retry_logic.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Integration test for retry logic with simulated Odoo failures.""" -import pytest -import subprocess -import time -import os -import tempfile -from pathlib import Path - - -@pytest.fixture -def test_config_with_failures(): - """Create test config with MockOdoo that simulates failures.""" - config_content = """ -mqtt: - broker: localhost - port: 1883 - username: null - password: null - client_id: iot-bridge-test-retry - use_tls: false - -odoo: - url: http://localhost:8069 - token: test_token_123 - use_mock: true - mock_failure_rate: 0.5 # 50% failure rate for first attempts - -logging: - level: INFO - format: json - log_file: null - -devices: - - device_id: retry-test-device-01 - mqtt_topic: shellypmmini-test-retry/status/pm1:0 - parser_type: shelly_pm_mini_g3 - machine_name: Test Machine Retry - session_config: - strategy: power_based - standby_threshold_w: 20.0 - working_threshold_w: 100.0 - start_debounce_s: 3.0 - stop_debounce_s: 15.0 - message_timeout_s: 20.0 - heartbeat_interval_s: 10.0 -""" - - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: - f.write(config_content) - config_path = f.name - - yield config_path - - # Cleanup - os.unlink(config_path) - - -def test_retry_on_odoo_failure(test_config_with_failures): - """ - Test that events are retried when Odoo send fails. - - Scenario: - 1. Start bridge with MockOdoo that fails 50% of the time - 2. Send power message to trigger session_started event - 3. Verify event is eventually sent after retries - """ - # Start bridge - bridge_process = subprocess.Popen( - ['python3', 'main.py', '--config', test_config_with_failures], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - cwd='/home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/iot_bridge' - ) - - # Wait for bridge to start - time.sleep(2) - - try: - # Start simulator to trigger session - simulator_process = subprocess.Popen( - [ - 'python3', 'tests/tools/shelly_simulator.py', - '--broker', 'localhost', - '--port', '1883', - '--topic-prefix', 'shellypmmini-test-retry', - '--scenario', 'standby', - '--duration', '10', - '--no-tls' - ], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - cwd='/home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/iot_bridge' - ) - - # Wait for simulator to finish sending messages - simulator_output, _ = simulator_process.communicate(timeout=15) - print("\n=== Simulator Output ===") - print(simulator_output) - - # Wait a bit more for bridge to process - time.sleep(5) - - # Read bridge logs - bridge_process.terminate() - bridge_output, _ = bridge_process.communicate(timeout=5) - - # Verify retry behavior in logs - print("\n=== Bridge Output ===") - print(bridge_output) - - assert 'event_enqueued' in bridge_output or 'session_started' in bridge_output, \ - "Event should be enqueued or session started" - - finally: - if bridge_process.poll() is None: - bridge_process.terminate() - bridge_process.wait(timeout=5) - - -def test_eventual_success_after_retries(test_config_with_failures): - """ - Test that events eventually succeed after multiple retries. - - With 50% failure rate, events should eventually succeed. - """ - # This test runs longer to allow multiple retry attempts - bridge_process = subprocess.Popen( - ['python3', 'main.py', '--config', test_config_with_failures], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - cwd='/home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/iot_bridge' - ) - - time.sleep(2) - - try: - # Send single message to trigger session_started - simulator_process = subprocess.Popen( - [ - 'python3', 'tests/tools/shelly_simulator.py', - '--broker', 'localhost', - '--port', '1883', - '--topic-prefix', 'shellypmmini-test-retry', - '--scenario', 'standby', - '--duration', '5', - '--no-tls' - ], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - cwd='/home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/iot_bridge' - ) - - # Wait for simulator to finish - simulator_process.communicate(timeout=10) - - # Wait long enough for retries: initial + 2s + 4s + 8s = 15s - time.sleep(20) - - bridge_process.terminate() - bridge_output, _ = bridge_process.communicate(timeout=5) - - # With 50% failure rate and enough retries, should eventually succeed - # Check queue statistics in output - print("\n=== Bridge Output (Eventual Success) ===") - print(bridge_output[-1500:]) - - assert 'event_sent_success' in bridge_output or 'session_started' in bridge_output, \ - "Event should eventually be sent successfully or session started" - - finally: - if bridge_process.poll() is None: - bridge_process.terminate() - bridge_process.wait(timeout=5) - - -if __name__ == '__main__': - pytest.main([__file__, '-v', '--tb=short']) diff --git a/open_workshop_mqtt/iot_bridge/tests/tools/__init__.py b/open_workshop_mqtt/iot_bridge/tests/tools/__init__.py deleted file mode 100644 index 6b342cf..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/tools/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Test Tools""" diff --git a/open_workshop_mqtt/iot_bridge/tests/tools/shelly_simulator.py b/open_workshop_mqtt/iot_bridge/tests/tools/shelly_simulator.py deleted file mode 100644 index 9f4fe78..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/tools/shelly_simulator.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -""" -Shelly PM Mini G3 Simulator for Testing - -Sends MQTT messages to test topics with various power scenarios. - -Usage: - python shelly_simulator.py --scenario standby - python shelly_simulator.py --scenario working - python shelly_simulator.py --scenario full_session -""" - -import argparse -import json -import ssl -import time -from datetime import datetime -from paho.mqtt import client as mqtt_client - - -class ShellySimulator: - """Simulates Shelly PM Mini G3 MQTT Messages.""" - - def __init__(self, broker_host: str, broker_port: int, - username: str, password: str, - topic_prefix: str = "testshelly", - use_tls: bool = True): - self.broker_host = broker_host - self.broker_port = broker_port - self.username = username - self.password = password - self.topic_prefix = topic_prefix - self.topic = f"{topic_prefix}/status/pm1:0" - self.use_tls = use_tls - self.client = None - - def connect(self): - """Connect to MQTT Broker.""" - client_id = f"shelly-simulator-{int(time.time())}" - self.client = mqtt_client.Client(client_id=client_id, protocol=mqtt_client.MQTTv5) - self.client.username_pw_set(self.username, self.password) - - if self.use_tls: - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - - print(f"Connecting to MQTT Broker {self.broker_host}:{self.broker_port}...") - self.client.connect(self.broker_host, self.broker_port, keepalive=60) - self.client.loop_start() - time.sleep(1) - print("✓ Connected") - - def disconnect(self): - """Disconnect from broker.""" - if self.client: - self.client.loop_stop() - self.client.disconnect() - print("✓ Disconnected") - - def send_power_message(self, power_w: float, interval_s: int = 1): - """ - Send Shelly PM Mini G3 status message. - - Args: - power_w: Power in watts - interval_s: Sleep interval after sending (seconds) - """ - message = { - "id": 0, - "voltage": 230.0, - "current": round(power_w / 230.0, 3), - "apower": power_w, - "freq": 50.0, - "aenergy": { - "total": round(12345.6 + (power_w * interval_s / 3600), 1), - "by_minute": [0.0, 0.0, 0.0], - "minute_ts": int(time.time()) - }, - "temperature": { - "tC": 35.2, - "tF": 95.4 - } - } - - payload = json.dumps(message) - self.client.publish(self.topic, payload, qos=1) - - timestamp = datetime.now().strftime("%H:%M:%S") - print(f"[{timestamp}] 📤 Sent: {power_w:.1f}W") - - if interval_s > 0: - time.sleep(interval_s) - - def scenario_standby(self, duration_s: int = 60): - """Scenario: Machine in STANDBY (20-100W).""" - print("\n=== SCENARIO: STANDBY (20-100W) ===") - print(f"Duration: {duration_s}s\n") - - for i in range(duration_s): - power = 40 + (i % 20) # 40-60W - self.send_power_message(power) - - def scenario_working(self, duration_s: int = 60): - """Scenario: Machine WORKING (>100W).""" - print("\n=== SCENARIO: WORKING (>100W) ===") - print(f"Duration: {duration_s}s\n") - - for i in range(duration_s): - power = 150 + (i % 50) # 150-200W - self.send_power_message(power) - - def scenario_full_session(self): - """Scenario: Complete session with transitions.""" - print("\n=== SCENARIO: FULL SESSION ===") - print("IDLE → STARTING → STANDBY → WORKING → STANDBY → STOPPING → IDLE\n") - - # 1. IDLE (10s) - print("1. IDLE (10W, 10s)") - for _ in range(10): - self.send_power_message(10) - - # 2. STARTING → STANDBY (30W, 5s) - print("2. STARTING → STANDBY (30W, 5s)") - for _ in range(5): - self.send_power_message(30) - - # 3. STANDBY (50W, 15s) - print("3. STANDBY (50W, 15s)") - for _ in range(15): - self.send_power_message(50) - - # 4. WORKING (150W, 20s) - print("4. WORKING (150W, 20s)") - for _ in range(20): - self.send_power_message(150) - - # 5. Back to STANDBY (40W, 10s) - print("5. Back to STANDBY (40W, 10s)") - for _ in range(10): - self.send_power_message(40) - - # 6. STOPPING → IDLE (5W, 20s) - print("6. STOPPING → IDLE (5W, 20s)") - for _ in range(20): - self.send_power_message(5) - - print("\n✓ Session completed") - - -def main(): - parser = argparse.ArgumentParser(description="Shelly PM Mini G3 Simulator") - parser.add_argument("--broker", default="mqtt.majufilo.eu", help="MQTT Broker host") - parser.add_argument("--port", type=int, default=8883, help="MQTT Broker port") - parser.add_argument("--username", default="mosquitto", help="MQTT username") - parser.add_argument("--password", default="jer7Pehr", help="MQTT password") - parser.add_argument("--topic-prefix", default="testshelly", help="MQTT topic prefix") - parser.add_argument("--scenario", choices=["standby", "working", "full_session"], - default="full_session", help="Test scenario") - parser.add_argument("--duration", type=int, default=60, help="Duration for standby/working scenarios") - parser.add_argument("--no-tls", action="store_true", help="Disable TLS") - - args = parser.parse_args() - - simulator = ShellySimulator( - broker_host=args.broker, - broker_port=args.port, - username=args.username, - password=args.password, - topic_prefix=args.topic_prefix, - use_tls=not args.no_tls - ) - - try: - simulator.connect() - - if args.scenario == "standby": - simulator.scenario_standby(args.duration) - elif args.scenario == "working": - simulator.scenario_working(args.duration) - elif args.scenario == "full_session": - simulator.scenario_full_session() - - except KeyboardInterrupt: - print("\n\nInterrupted by user") - finally: - simulator.disconnect() - - -if __name__ == "__main__": - main() diff --git a/open_workshop_mqtt/iot_bridge/tests/unit/__init__.py b/open_workshop_mqtt/iot_bridge/tests/unit/__init__.py deleted file mode 100644 index a44def1..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/unit/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit Tests""" diff --git a/open_workshop_mqtt/iot_bridge/tests/unit/test_event_queue.py b/open_workshop_mqtt/iot_bridge/tests/unit/test_event_queue.py deleted file mode 100644 index 557c122..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/unit/test_event_queue.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Unit tests for Event Queue with Retry Logic.""" -import pytest -import time -from datetime import datetime, timedelta -from unittest.mock import Mock - -from event_queue import EventQueue, QueuedEvent - - -class TestQueuedEvent: - """Tests for QueuedEvent class.""" - - def test_should_retry_initial(self): - """Test that new event should retry immediately.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow() - ) - - assert event.should_retry(datetime.utcnow()) is True - assert event.retry_count == 0 - - def test_should_retry_after_max(self): - """Test that event won't retry after max retries.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow(), - retry_count=10, - max_retries=10 - ) - - assert event.should_retry(datetime.utcnow()) is False - - def test_should_retry_before_next_time(self): - """Test that event won't retry before next_retry_time.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow(), - retry_count=1, - next_retry_time=datetime.utcnow() + timedelta(seconds=10) - ) - - assert event.should_retry(datetime.utcnow()) is False - - def test_should_retry_after_next_time(self): - """Test that event will retry after next_retry_time.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow(), - retry_count=1, - next_retry_time=datetime.utcnow() - timedelta(seconds=1) - ) - - assert event.should_retry(datetime.utcnow()) is True - - def test_exponential_backoff(self): - """Test exponential backoff calculation.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow() - ) - - # First retry: 2^0 = 1s - event.increment_retry() - assert event.retry_count == 1 - delay1 = (event.next_retry_time - datetime.utcnow()).total_seconds() - assert 1.9 <= delay1 <= 2.1 # 2^1 = 2s - - # Second retry: 2^2 = 4s - event.increment_retry() - assert event.retry_count == 2 - delay2 = (event.next_retry_time - datetime.utcnow()).total_seconds() - assert 3.9 <= delay2 <= 4.1 # 2^2 = 4s - - # Third retry: 2^3 = 8s - event.increment_retry() - assert event.retry_count == 3 - delay3 = (event.next_retry_time - datetime.utcnow()).total_seconds() - assert 7.9 <= delay3 <= 8.1 # 2^3 = 8s - - def test_max_backoff_cap(self): - """Test that backoff is capped at 60s.""" - event = QueuedEvent( - event_uid="test-uid", - event_type="session_started", - device_id="device-01", - payload={}, - created_at=datetime.utcnow(), - retry_count=10 # 2^10 = 1024s, but should be capped - ) - - event.increment_retry() - delay = (event.next_retry_time - datetime.utcnow()).total_seconds() - assert 59.9 <= delay <= 60.1 - - -class TestEventQueue: - """Tests for EventQueue class.""" - - def test_enqueue(self): - """Test enqueuing events.""" - send_mock = Mock(return_value=True) - queue = EventQueue(send_callback=send_mock) - - event = { - 'event_type': 'session_started', - 'device_id': 'device-01', - 'session_id': 'session-123' - } - - uid = queue.enqueue(event) - - assert uid is not None - assert queue.get_queue_size() == 1 - assert queue.stats['pending_count'] == 1 - - def test_enqueue_preserves_uid(self): - """Test that existing event_uid is preserved.""" - send_mock = Mock(return_value=True) - queue = EventQueue(send_callback=send_mock) - - event = { - 'event_uid': 'my-custom-uid', - 'event_type': 'session_started', - 'device_id': 'device-01' - } - - uid = queue.enqueue(event) - assert uid == 'my-custom-uid' - - def test_successful_send(self): - """Test successful event sending.""" - send_mock = Mock(return_value=True) - queue = EventQueue(send_callback=send_mock) - queue.start() - - event = { - 'event_type': 'session_started', - 'device_id': 'device-01' - } - - queue.enqueue(event) - - # Wait for processing - time.sleep(0.3) - - queue.stop() - - # Verify send was called - assert send_mock.call_count == 1 - assert queue.stats['sent_count'] == 1 - assert queue.stats['pending_count'] == 0 - - def test_failed_send_with_retry(self): - """Test that failed sends are retried.""" - # Fail first 2 times, succeed on 3rd - send_mock = Mock(side_effect=[False, False, True]) - queue = EventQueue(send_callback=send_mock) - queue.start() - - event = { - 'event_type': 'session_started', - 'device_id': 'device-01' - } - - queue.enqueue(event) - - # Wait for initial send + 2 retries (initial + 2s + 4s = 7s) - time.sleep(8) - - queue.stop() - - # Verify retries happened - assert send_mock.call_count == 3 - assert queue.stats['sent_count'] == 1 - assert queue.stats['retry_count'] == 2 - assert queue.stats['pending_count'] == 0 - - def test_max_retries_exceeded(self): - """Test that events are marked failed after max retries.""" - send_mock = Mock(return_value=False) # Always fail - queue = EventQueue(send_callback=send_mock) - queue.start() - - event = { - 'event_type': 'session_started', - 'device_id': 'device-01' - } - - queue.enqueue(event) - - # Wait for first 5 retries (2s + 4s + 8s + 16s + 32s = 62s) - # For faster tests, just verify retry behavior with 4 attempts - time.sleep(32) # Enough for 4-5 retry attempts - - queue.stop() - - # Should have attempted at least 5 times - assert send_mock.call_count >= 5 - assert queue.stats['retry_count'] >= 4 - - def test_queue_statistics(self): - """Test queue statistics tracking.""" - send_mock = Mock(side_effect=[True, False, True]) - queue = EventQueue(send_callback=send_mock) - queue.start() - - # Enqueue 3 events - for i in range(3): - queue.enqueue({ - 'event_type': 'session_heartbeat', - 'device_id': f'device-{i:02d}' - }) - - time.sleep(3) - queue.stop() - - stats = queue.get_stats() - assert stats['sent_count'] >= 2 # At least 2 succeeded - assert stats['retry_count'] >= 1 # At least 1 retry - - def test_queue_stop_waits_for_completion(self): - """Test that stop() waits for queue processing to finish.""" - send_mock = Mock(return_value=True) - queue = EventQueue(send_callback=send_mock) - queue.start() - - # Enqueue event - queue.enqueue({ - 'event_type': 'session_started', - 'device_id': 'device-01' - }) - - # Stop immediately - queue.stop() - - # Verify event was processed before stop completed - assert send_mock.call_count >= 0 # May or may not have processed - assert not queue.running - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/open_workshop_mqtt/iot_bridge/tests/unit/test_session_detector.py b/open_workshop_mqtt/iot_bridge/tests/unit/test_session_detector.py deleted file mode 100644 index 2658aa8..0000000 --- a/open_workshop_mqtt/iot_bridge/tests/unit/test_session_detector.py +++ /dev/null @@ -1,233 +0,0 @@ -#!/usr/bin/env python3 -""" -Unit Tests for SessionDetector - -Tests the session detection logic with aggregation. - -Usage: - pytest test_session_detector.py -v - pytest test_session_detector.py::TestSessionStart -v -""" - -import pytest -from datetime import datetime, timedelta -import sys -from pathlib import Path - -# Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent.parent)) - -from session_detector import SessionDetector - - -@pytest.fixture -def events_received(): - """Track events emitted by detector.""" - events = [] - return events - - -@pytest.fixture -def detector(events_received): - """SessionDetector with test configuration.""" - def event_callback(event): - events_received.append(event) - - return SessionDetector( - device_id="test-device-01", - machine_name="Test Machine", - standby_threshold_w=20.0, - working_threshold_w=100.0, - start_debounce_s=3.0, - stop_debounce_s=15.0, - message_timeout_s=20.0, - heartbeat_interval_s=30.0, - event_callback=event_callback - ) - - -class TestSessionStart: - """Tests for session start with debounce.""" - - def test_idle_to_starting(self, detector, events_received): - """Power >= 20W → STARTING state.""" - timestamp = datetime.utcnow() - detector.process_power_measurement(30.0, timestamp) - - assert detector.state == 'starting' - assert len(events_received) == 0 # No event yet - - def test_starting_to_standby(self, detector, events_received): - """After 3s debounce with 20-100W → STANDBY.""" - start_time = datetime.utcnow() - - # T+0: 30W → STARTING - detector.process_power_measurement(30.0, start_time) - assert detector.state == 'starting' - - # T+1: 50W → still STARTING - detector.process_power_measurement(50.0, start_time + timedelta(seconds=1)) - assert detector.state == 'starting' - - # T+3: 50W → STANDBY (debounce passed) - detector.process_power_measurement(50.0, start_time + timedelta(seconds=3)) - - assert detector.state == 'standby' - assert len(events_received) == 1 - assert events_received[0]['event_type'] == 'session_started' - assert events_received[0]['payload']['start_power_w'] == 50.0 - - def test_starting_to_working(self, detector, events_received): - """After 3s debounce with >=100W → WORKING.""" - start_time = datetime.utcnow() - - # T+0: 30W → STARTING - detector.process_power_measurement(30.0, start_time) - - # T+1: 120W → still STARTING - detector.process_power_measurement(120.0, start_time + timedelta(seconds=1)) - - # T+3: 150W → WORKING (debounce passed) - detector.process_power_measurement(150.0, start_time + timedelta(seconds=3)) - - assert detector.state == 'standby' # Session starts in standby first - # Then immediately transitions to working if power high enough - - def test_false_start(self, detector, events_received): - """Power drops before debounce → back to IDLE.""" - start_time = datetime.utcnow() - - # T+0: 30W → STARTING - detector.process_power_measurement(30.0, start_time) - assert detector.state == 'starting' - - # T+1: 10W → back to IDLE - detector.process_power_measurement(10.0, start_time + timedelta(seconds=1)) - - assert detector.state == 'idle' - assert len(events_received) == 0 - - -class TestStateTransitions: - """Tests for state transitions during session.""" - - def test_standby_to_working(self, detector, events_received): - """STANDBY → WORKING when power >= 100W.""" - start_time = datetime.utcnow() - - # Start session in STANDBY - detector.process_power_measurement(30.0, start_time) - detector.process_power_measurement(50.0, start_time + timedelta(seconds=3)) - assert detector.state == 'standby' - - # Clear started event - events_received.clear() - - # Power increases to 150W → WORKING - detector.process_power_measurement(150.0, start_time + timedelta(seconds=10)) - - assert detector.state == 'working' - assert len(events_received) == 0 # No event, just state change - - def test_working_to_standby(self, detector, events_received): - """WORKING → STANDBY when power < 100W.""" - start_time = datetime.utcnow() - - # Start session with high power → WORKING - detector.process_power_measurement(150.0, start_time) - detector.process_power_measurement(150.0, start_time + timedelta(seconds=3)) - - # Transition to working - detector.process_power_measurement(150.0, start_time + timedelta(seconds=4)) - assert detector.state == 'working' or detector.state == 'standby' - - events_received.clear() - - # Power decreases to 60W → STANDBY - detector.process_power_measurement(60.0, start_time + timedelta(seconds=10)) - - assert detector.state in ['standby', 'working'] # Depending on transition - - -class TestHeartbeat: - """Tests for heartbeat aggregation.""" - - def test_heartbeat_emission(self, detector, events_received): - """Heartbeat emitted after heartbeat_interval_s.""" - start_time = datetime.utcnow() - - # Start session - detector.process_power_measurement(50.0, start_time) - detector.process_power_measurement(50.0, start_time + timedelta(seconds=3)) - - events_received.clear() - - # Send measurements for 30 seconds (heartbeat interval) - for i in range(30): - detector.process_power_measurement(50.0, start_time + timedelta(seconds=4 + i)) - - # Should have emitted heartbeat - heartbeats = [e for e in events_received if e['event_type'] == 'session_heartbeat'] - assert len(heartbeats) >= 1 - - if len(heartbeats) > 0: - hb = heartbeats[0] - assert 'interval_standby_s' in hb['payload'] - assert 'interval_working_s' in hb['payload'] - assert 'avg_power_w' in hb['payload'] - - -class TestSessionEnd: - """Tests for session end with debounce.""" - - def test_session_end_from_standby(self, detector, events_received): - """STANDBY → STOPPING → IDLE after 15s < 20W.""" - start_time = datetime.utcnow() - - # Start session - detector.process_power_measurement(50.0, start_time) - detector.process_power_measurement(50.0, start_time + timedelta(seconds=3)) - - events_received.clear() - - # Run for 10s in STANDBY - for i in range(10): - detector.process_power_measurement(50.0, start_time + timedelta(seconds=4 + i)) - - # Power drops < 20W → STOPPING - t_stopping = start_time + timedelta(seconds=14) - detector.process_power_measurement(10.0, t_stopping) - assert detector.state == 'stopping' - - # Run for 15s in STOPPING - for i in range(15): - detector.process_power_measurement(10.0, t_stopping + timedelta(seconds=1 + i)) - - # Should have ended session - ended = [e for e in events_received if e['event_type'] == 'session_ended'] - assert len(ended) >= 1 - assert detector.state == 'idle' - - -class TestTimeout: - """Tests for session timeout.""" - - def test_session_timeout(self, detector, events_received): - """Session times out after message_timeout_s without messages.""" - start_time = datetime.utcnow() - - # Start session - detector.process_power_measurement(50.0, start_time) - detector.process_power_measurement(50.0, start_time + timedelta(seconds=3)) - - assert detector.current_session_id is not None - events_received.clear() - - # Check timeout after 25 seconds (> 20s timeout) - detector.check_timeout(start_time + timedelta(seconds=28)) - - # Should have timed out - timeout_events = [e for e in events_received if e['event_type'] == 'session_timeout'] - assert len(timeout_events) >= 1 - assert detector.state == 'idle' - assert detector.current_session_id is None diff --git a/open_workshop_mqtt/models/__init__.py b/open_workshop_mqtt/models/__init__.py deleted file mode 100644 index 00f843b..0000000 --- a/open_workshop_mqtt/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# -*- coding: utf-8 -*- - -from . import mqtt_device -from . import mqtt_session -from . import iot_event diff --git a/open_workshop_mqtt/models/iot_event.py b/open_workshop_mqtt/models/iot_event.py deleted file mode 100644 index 5181819..0000000 --- a/open_workshop_mqtt/models/iot_event.py +++ /dev/null @@ -1,197 +0,0 @@ -# -*- coding: utf-8 -*- -""" -IoT Event Log Model -Logs all events received from IoT Bridge via REST API -""" - -from odoo import models, fields, api, _ -from odoo.exceptions import UserError, ValidationError -import json -import logging - -_logger = logging.getLogger(__name__) - - -class IotEvent(models.Model): - _name = 'ows.iot.event' - _description = 'IoT Event Log' - _rec_name = 'event_uid' - _order = 'timestamp desc' - _sql_constraints = [ - ('event_uid_unique', 'UNIQUE(event_uid)', 'Event UID must be unique!') - ] - - # ========== Event Identity ========== - event_uid = fields.Char( - string='Event UID', - required=True, - readonly=True, - index=True, - help='Unique event identifier from IoT Bridge (UUID)' - ) - - event_type = fields.Selection([ - ('session_started', 'Session Started'), - ('session_updated', 'Session Updated'), - ('session_stopped', 'Session Stopped'), - ('session_timeout', 'Session Timeout'), - ('session_heartbeat', 'Session Heartbeat'), - ('heartbeat', 'Heartbeat'), - ('power_change', 'Power Change'), - ], string='Event Type', required=True, readonly=True, index=True, - help='Type of event received from IoT Bridge') - - # ========== Timestamps ========== - timestamp = fields.Datetime( - string='Event Timestamp', - required=True, - readonly=True, - index=True, - help='Timestamp when event occurred (from IoT Bridge)' - ) - received_at = fields.Datetime( - string='Received At', - required=True, - readonly=True, - default=fields.Datetime.now, - help='Timestamp when Odoo received this event' - ) - - # ========== Device Info ========== - device_id = fields.Char( - string='Device ID', - required=True, - readonly=True, - index=True, - help='External device identifier from IoT Bridge' - ) - mqtt_device_id = fields.Many2one( - 'mqtt.device', - string='MQTT Device', - readonly=True, - index=True, - help='Linked MQTT device (if found)' - ) - - # ========== Session Info ========== - session_id = fields.Char( - string='Session ID', - readonly=True, - index=True, - help='Session identifier from IoT Bridge' - ) - mqtt_session_id = fields.Many2one( - 'mqtt.session', - string='MQTT Session', - readonly=True, - index=True, - help='Linked MQTT session (if found)' - ) - - # ========== Payload ========== - payload_json = fields.Text( - string='Payload JSON', - required=True, - readonly=True, - help='Full event payload as JSON from IoT Bridge' - ) - - # Extracted payload fields for easy filtering/searching - power_w = fields.Float( - string='Power (W)', - readonly=True, - digits=(8, 2), - help='Power value from payload (if applicable)' - ) - state = fields.Char( - string='State', - readonly=True, - help='Device/Session state from payload' - ) - - # ========== Processing Status ========== - processed = fields.Boolean( - string='Processed', - default=False, - index=True, - help='Whether this event has been processed' - ) - processing_error = fields.Text( - string='Processing Error', - readonly=True, - help='Error message if processing failed' - ) - - # ========== Compute Methods ========== - @api.model - def create(self, vals): - """Create event and auto-link to mqtt.device and mqtt.session""" - event = super().create(vals) - - # Try to link to mqtt.device by device_id - if event.device_id and not event.mqtt_device_id: - # Look for mqtt.device with matching device_id in strategy_config - devices = self.env['mqtt.device'].search([]) - for device in devices: - if device.strategy_config: - try: - config = json.loads(device.strategy_config) - if config.get('device_id') == event.device_id: - event.mqtt_device_id = device.id - break - except: - pass - - # Try to link to mqtt.session by session_id - if event.session_id and not event.mqtt_session_id: - session = self.env['mqtt.session'].search([ - ('session_id', '=', event.session_id) - ], limit=1) - if session: - event.mqtt_session_id = session.id - - # Extract payload fields - event._extract_payload_fields() - - return event - - def _extract_payload_fields(self): - """Extract common fields from payload JSON for easier querying""" - for event in self: - if not event.payload_json: - continue - - try: - payload = json.loads(event.payload_json) - - # Extract power if present - if 'power_w' in payload: - event.power_w = float(payload['power_w']) - elif 'current_power_w' in payload: - event.power_w = float(payload['current_power_w']) - - # Extract state if present - if 'state' in payload: - event.state = str(payload['state']) - elif 'current_state' in payload: - event.state = str(payload['current_state']) - - except (json.JSONDecodeError, ValueError, KeyError) as e: - _logger.warning(f"Failed to extract payload fields from event {event.event_uid}: {e}") - - # ========== Helper Methods ========== - def get_payload_dict(self): - """Return payload as Python dict""" - self.ensure_one() - try: - return json.loads(self.payload_json) - except json.JSONDecodeError: - return {} - - def mark_processed(self, error=None): - """Mark event as processed (or failed)""" - self.ensure_one() - self.write({ - 'processed': not bool(error), - 'processing_error': error - }) diff --git a/open_workshop_mqtt/models/mqtt_device.py b/open_workshop_mqtt/models/mqtt_device.py deleted file mode 100644 index 422cf35..0000000 --- a/open_workshop_mqtt/models/mqtt_device.py +++ /dev/null @@ -1,322 +0,0 @@ -# -*- coding: utf-8 -*- - -from odoo import models, fields, api, _ -from odoo.exceptions import UserError, ValidationError -import json -import logging - -_logger = logging.getLogger(__name__) - - -class MqttDevice(models.Model): - _name = 'mqtt.device' - _description = 'MQTT Device' - _rec_name = 'name' - _order = 'name' - - # ========== Basic Fields ========== - name = fields.Char( - string='Device Name', - required=True, - help='Friendly name for this device' - ) - device_id = fields.Char( - string='Device ID (External)', - required=True, - index=True, - help='External device identifier for IoT Bridge API (e.g., shellypmminig3-48f6eeb73a1c)' - ) - active = fields.Boolean( - default=True, - help='Deactivate to stop monitoring this device' - ) - - # ========== Status ========== - state = fields.Selection([ - ('online', 'Online'), - ('offline', 'Offline'), - ], string='Status', compute='_compute_state', store=False) - - last_seen = fields.Datetime( - string='Last Seen', - compute='_compute_state', - store=False, - help='Last message received from this device' - ) - - # ========== MQTT Configuration ========== - topic_pattern = fields.Char( - string='Topic Pattern', - required=True, - default='shaperorigin/#', - help='MQTT Topic Pattern aus config.yaml → topic_prefix + /#\n' - 'Beispiele:\n' - ' - shaperorigin/# (empfohlen für Shaper Origin)\n' - ' - device/+/status (mehrere Geräte)\n' - '# = alle Sub-Topics, + = ein Level' - ) - - # ========== Parser Configuration ========== - parser_type = fields.Selection([ - ('shelly_pm', 'Shelly PM Mini G3'), - ('tasmota', 'Tasmota'), - ('generic', 'Generic JSON'), - ], string='Parser Type', required=True, default='shelly_pm', - help='Device message parser type') - - # ========== Session Detection Strategy ========== - session_strategy = fields.Selection([ - ('power_threshold', 'Power Threshold (Dual)'), - ('last_will', 'MQTT Last Will Testament'), - ('manual', 'Manual Start/Stop'), - ], string='Session Strategy', default='power_threshold', required=True, - help='How to detect session start/end') - - strategy_config = fields.Text( - string='Strategy Configuration', - default='{"standby_threshold_w": 20, "working_threshold_w": 100, ' - '"start_debounce_s": 3, "stop_debounce_s": 15, ' - '"message_timeout_s": 20}', - help='JSON Konfiguration (entspricht config.yaml):\n' - ' - standby_threshold_w: Power-Schwelle für Session-Start (Watt)\n' - ' - working_threshold_w: Power-Schwelle für Working-Status (Watt)\n' - ' - start_debounce_s: Verzögerung bis Session-Start (Sekunden)\n' - ' - stop_debounce_s: Verzögerung bis Session-Ende (Sekunden)\n' - ' - message_timeout_s: Timeout ohne Nachricht = Session-Ende\n' - 'Beispiel Shaper Origin: standby=10, working=30' - ) - - # ========== Live Status (Runtime) ========== - state = fields.Selection([ - ('offline', 'Offline'), - ('idle', 'Idle'), - ('active', 'Active') - ], string='Status', default='offline', readonly=True, - help='Current device status') - - last_message_time = fields.Datetime( - string='Last Message', - readonly=True, - help='Timestamp of last received MQTT message' - ) - last_power_w = fields.Float( - string='Current Power (W)', - readonly=True, - digits=(8, 2), - help='Last measured power consumption' - ) - - # ========== Relations ========== - session_ids = fields.One2many( - 'mqtt.session', - 'device_id', - string='Sessions', - help='Runtime sessions for this device' - ) - - # ========== Computed Statistics ========== - session_count = fields.Integer( - string='Total Sessions', - compute='_compute_session_stats', - store=True - ) - running_session_count = fields.Integer( - string='Running Sessions', - compute='_compute_session_stats', - store=True - ) - total_runtime_hours = fields.Float( - string='Total Runtime (h)', - compute='_compute_session_stats', - store=True, - digits=(10, 2) - ) - avg_session_duration_hours = fields.Float( - string='Avg Session Duration (h)', - compute='_compute_session_stats', - store=True, - digits=(10, 2) - ) - - # ========== Compute Methods ========== - @api.depends('session_ids', 'session_ids.last_message_time') - def _compute_state(self): - """Compute device online/offline state based on last session activity""" - from datetime import datetime, timedelta - - for device in self: - # Find last session with recent activity - last_session = self.env['mqtt.session'].search([ - ('device_id', '=', device.id), - ('last_message_time', '!=', False) - ], limit=1, order='last_message_time desc') - - if last_session and last_session.last_message_time: - device.last_seen = last_session.last_message_time - # Device is online if last message < 60 seconds ago - threshold = datetime.now() - timedelta(seconds=60) - device.state = 'online' if last_session.last_message_time >= threshold else 'offline' - else: - device.last_seen = False - device.state = 'offline' - - @api.depends('session_ids', 'session_ids.status', 'session_ids.duration_hours') - def _compute_session_stats(self): - for device in self: - sessions = device.session_ids - completed_sessions = sessions.filtered(lambda s: s.status == 'completed') - - device.session_count = len(sessions) - device.running_session_count = len(sessions.filtered(lambda s: s.status == 'running')) - device.total_runtime_hours = sum(completed_sessions.mapped('duration_hours')) - - if completed_sessions: - device.avg_session_duration_hours = device.total_runtime_hours / len(completed_sessions) - else: - device.avg_session_duration_hours = 0.0 - - # ========== Constraints ========== - @api.constrains('strategy_config') - def _check_strategy_config(self): - """Validate JSON format of strategy config""" - for device in self: - if device.strategy_config: - try: - config = json.loads(device.strategy_config) - if not isinstance(config, dict): - raise ValidationError(_('Strategy configuration must be a JSON object')) - - # Validate power threshold strategy config - if device.session_strategy == 'power_threshold': - required_fields = ['standby_threshold_w', 'working_threshold_w'] - for field in required_fields: - if field not in config: - raise ValidationError( - _('Power threshold strategy requires "%s" in configuration') % field - ) - if not isinstance(config[field], (int, float)) or config[field] < 0: - raise ValidationError( - _('Field "%s" must be a positive number') % field - ) - - # Validate threshold order - if config['standby_threshold_w'] >= config['working_threshold_w']: - raise ValidationError( - _('Standby threshold must be less than working threshold') - ) - - except json.JSONDecodeError as e: - raise ValidationError(_('Invalid JSON format: %s') % str(e)) - - # ========== CRUD Hooks ========== - def write(self, vals): - """ - Auto-subscribe when device is added to running connection - or when topic_pattern changes - - DEPRECATED: Auto-subscribe disabled - use standalone IoT Bridge container - """ - result = super().write(vals) - return result - - # ========== Default Values ========== - @api.onchange('session_strategy') - def _onchange_session_strategy(self): - """Update default config when strategy changes""" - if self.session_strategy == 'power_threshold': - self.strategy_config = json.dumps({ - 'standby_threshold_w': 20, - 'working_threshold_w': 100, - 'start_debounce_s': 3, - 'stop_debounce_s': 15, - 'message_timeout_s': 20 - }, indent=2) - elif self.session_strategy == 'last_will': - self.strategy_config = json.dumps({ - 'lwt_topic': f'{self.device_id or "device"}/status', - 'lwt_offline_message': 'offline', - 'timeout_s': 60 - }, indent=2) - elif self.session_strategy == 'manual': - self.strategy_config = json.dumps({ - 'start_topic': f'{self.device_id or "device"}/session/start', - 'stop_topic': f'{self.device_id or "device"}/session/stop' - }, indent=2) - - @api.onchange('parser_type') - def _onchange_parser_type(self): - """Update topic pattern based on parser type""" - if self.parser_type == 'shelly_pm': - if not self.topic_pattern or self.topic_pattern == '#': - self.topic_pattern = f'{self.device_id or "device"}/#' - elif self.parser_type == 'tasmota': - if not self.topic_pattern or self.topic_pattern == '#': - self.topic_pattern = f'tele/{self.device_id or "device"}/#' - - # ========== Action Methods ========== - def action_view_sessions(self): - """Open sessions view for this device""" - self.ensure_one() - return { - 'name': _('Sessions - %s') % self.name, - 'type': 'ir.actions.act_window', - 'res_model': 'mqtt.session', - 'view_mode': 'tree,form,pivot,graph', - 'domain': [('device_id', '=', self.id)], - 'context': {'default_device_id': self.id}, - } - - def action_start_manual_session(self): - """Manually start a session""" - self.ensure_one() - - if self.session_strategy != 'manual': - raise UserError(_('Manual session start is only available for manual strategy')) - - # Check if there's already a running session - running = self.session_ids.filtered(lambda s: s.status == 'running') - if running: - raise UserError(_('Device already has a running session')) - - # TODO: Implement manual session start - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': _('Manual Session'), - 'message': _('Manual session start not yet implemented'), - 'type': 'warning', - } - } - - def action_stop_manual_session(self): - """Manually stop a session""" - self.ensure_one() - - if self.session_strategy != 'manual': - raise UserError(_('Manual session stop is only available for manual strategy')) - - running = self.session_ids.filtered(lambda s: s.status == 'running') - if not running: - raise UserError(_('No running session to stop')) - - # TODO: Implement manual session stop - return { - 'type': 'ir.actions.client', - 'tag': 'display_notification', - 'params': { - 'title': _('Manual Session'), - 'message': _('Manual session stop not yet implemented'), - 'type': 'warning', - } - } - - # ========== Helper Methods ========== - def get_strategy_config_dict(self): - """Parse strategy_config JSON and return as dict""" - self.ensure_one() - try: - return json.loads(self.strategy_config or '{}') - except json.JSONDecodeError: - _logger.error(f"Invalid JSON in strategy_config for device {self.id}") - return {} diff --git a/open_workshop_mqtt/models/mqtt_session.py b/open_workshop_mqtt/models/mqtt_session.py deleted file mode 100644 index f69f6a8..0000000 --- a/open_workshop_mqtt/models/mqtt_session.py +++ /dev/null @@ -1,326 +0,0 @@ -# -*- coding: utf-8 -*- - -from odoo import models, fields, api, _ -from odoo.exceptions import ValidationError -import json -import logging -import uuid -from datetime import datetime, timedelta - -_logger = logging.getLogger(__name__) - - -class MqttSession(models.Model): - _name = 'mqtt.session' - _description = 'MQTT Device Session' - _rec_name = 'session_id' - _order = 'start_time desc' - - # ========== Basic Fields ========== - session_id = fields.Char( - string='Session ID', - required=True, - readonly=True, - index=True, - default=lambda self: str(uuid.uuid4()), - help='Unique session identifier' - ) - device_id = fields.Many2one( - 'mqtt.device', - string='Device', - required=True, - readonly=True, - ondelete='cascade', - index=True, - help='MQTT device that generated this session' - ) - device_name = fields.Char( - string='Device Name', - related='device_id.name', - store=True, - readonly=True - ) - - # ========== Session Times ========== - start_time = fields.Datetime( - string='Start Time', - required=True, - readonly=True, - index=True, - help='Session start timestamp' - ) - end_time = fields.Datetime( - string='End Time', - readonly=True, - help='Session end timestamp (null if running)' - ) - - # ========== Durations (in seconds) ========== - total_duration_s = fields.Integer( - string='Total Duration (s)', - readonly=True, - help='Total session duration in seconds' - ) - standby_duration_s = fields.Integer( - string='Standby Duration (s)', - readonly=True, - help='Time in standby state (machine on, not working)' - ) - working_duration_s = fields.Integer( - string='Working Duration (s)', - readonly=True, - help='Time in working state (active work)' - ) - - # ========== Computed Durations (friendly format) ========== - duration_hours = fields.Float( - string='Duration (h)', - compute='_compute_duration_hours', - store=True, - digits=(10, 2), - help='Total duration in hours' - ) - duration_formatted = fields.Char( - string='Duration', - compute='_compute_duration_formatted', - store=True, - help='Human-readable duration format (e.g., 2h 15m)' - ) - standby_hours = fields.Float( - string='Standby (h)', - compute='_compute_duration_hours', - store=True, - digits=(10, 2) - ) - working_hours = fields.Float( - string='Working (h)', - compute='_compute_duration_hours', - store=True, - digits=(10, 2) - ) - - # ========== Power Data ========== - start_power_w = fields.Float( - string='Start Power (W)', - readonly=True, - digits=(8, 2), - help='Power consumption at session start' - ) - end_power_w = fields.Float( - string='End Power (W)', - readonly=True, - digits=(8, 2), - help='Power consumption at session end' - ) - current_power_w = fields.Float( - string='Current Power (W)', - readonly=True, - digits=(8, 2), - help='Current power consumption (live for running sessions)' - ) - - # ========== Session Status ========== - status = fields.Selection([ - ('running', 'Running'), - ('completed', 'Completed') - ], string='Status', required=True, default='running', readonly=True, - index=True, help='Session status') - - current_state = fields.Selection([ - ('idle', 'Idle'), - ('starting', 'Starting'), - ('standby', 'Standby'), - ('working', 'Working'), - ('stopping', 'Stopping'), - ], string='Current State', readonly=True, - help='Current state in session state machine (for running sessions)') - - last_message_time = fields.Datetime( - string='Last Message', - readonly=True, - help='Timestamp of last MQTT message received' - ) - - end_reason = fields.Selection([ - ('power_drop', 'Power Drop'), - ('timeout', 'Message Timeout'), - ('restart', 'Container Restart'), - ('last_will', 'Last Will Testament'), - ('manual', 'Manual Stop'), - ], string='End Reason', readonly=True, - help='Reason for session end') - - # ========== Metadata ========== - metadata = fields.Text( - string='Metadata', - readonly=True, - help='Additional session data in JSON format' - ) - - # ========== Compute Methods ========== - @api.depends('total_duration_s', 'standby_duration_s', 'working_duration_s') - def _compute_duration_hours(self): - """Convert seconds to hours""" - for session in self: - session.duration_hours = (session.total_duration_s / 3600.0) if session.total_duration_s else 0.0 - session.standby_hours = (session.standby_duration_s / 3600.0) if session.standby_duration_s else 0.0 - session.working_hours = (session.working_duration_s / 3600.0) if session.working_duration_s else 0.0 - - @api.depends('total_duration_s') - def _compute_duration_formatted(self): - """Format duration as human-readable string""" - for session in self: - if not session.total_duration_s: - session.duration_formatted = '-' - else: - total_s = session.total_duration_s - hours = total_s // 3600 - minutes = (total_s % 3600) // 60 - seconds = total_s % 60 - - if hours > 0: - session.duration_formatted = f"{hours}h {minutes}m" - elif minutes > 0: - session.duration_formatted = f"{minutes}m {seconds}s" - else: - session.duration_formatted = f"{seconds}s" - - # ========== Constraints ========== - _sql_constraints = [ - ('session_id_unique', 'UNIQUE(session_id)', - 'Session ID must be unique!'), - ] - - @api.constrains('start_time', 'end_time') - def _check_times(self): - """Validate session times""" - for session in self: - if session.end_time and session.start_time and session.end_time < session.start_time: - raise ValidationError(_('End time cannot be before start time')) - - # ========== Helper Methods ========== - def get_metadata_dict(self): - """Parse metadata JSON and return as dict""" - self.ensure_one() - try: - return json.loads(self.metadata or '{}') - except json.JSONDecodeError: - _logger.error(f"Invalid JSON in metadata for session {self.id}") - return {} - - # ========== CRUD Methods ========== - @api.model - def create_or_update_session(self, session_data): - """ - Create or update session from MQTT event - - Args: - session_data: Dict with session information - - session_id: str (required) - - device_id: int (required, Odoo ID) - - event_type: 'session_start' | 'session_end' - - start_time: datetime - - end_time: datetime (for session_end) - - total_duration_s: int - - standby_duration_s: int - - working_duration_s: int - - start_power_w: float - - end_power_w: float - - end_reason: str - - metadata: dict (optional) - - Returns: - mqtt.session record - """ - session_id = session_data.get('session_id') - if not session_id: - raise ValueError('session_id is required') - - existing = self.search([('session_id', '=', session_id)], limit=1) - - if session_data.get('event_type') == 'session_start': - if existing: - _logger.warning(f"Session {session_id} already exists, skipping create") - return existing - - # Create new session - values = { - 'session_id': session_id, - 'device_id': session_data['device_id'], - 'start_time': session_data['start_time'], - 'start_power_w': session_data.get('start_power_w', 0.0), - 'status': 'running', - 'metadata': json.dumps(session_data.get('metadata', {})) if session_data.get('metadata') else False, - } - return self.create(values) - - elif session_data.get('event_type') == 'session_end': - if not existing: - _logger.error(f"Cannot end non-existent session {session_id}") - # Create it anyway with end data - values = { - 'session_id': session_id, - 'device_id': session_data['device_id'], - 'start_time': session_data.get('start_time'), - 'end_time': session_data.get('end_time'), - 'total_duration_s': session_data.get('total_duration_s', 0), - 'standby_duration_s': session_data.get('standby_duration_s', 0), - 'working_duration_s': session_data.get('working_duration_s', 0), - 'start_power_w': session_data.get('start_power_w', 0.0), - 'end_power_w': session_data.get('end_power_w', 0.0), - 'end_reason': session_data.get('end_reason'), - 'status': 'completed', - 'metadata': json.dumps(session_data.get('metadata', {})) if session_data.get('metadata') else False, - } - return self.create(values) - - # Update existing session - update_values = { - 'end_time': session_data.get('end_time'), - 'total_duration_s': session_data.get('total_duration_s', 0), - 'standby_duration_s': session_data.get('standby_duration_s', 0), - 'working_duration_s': session_data.get('working_duration_s', 0), - 'end_power_w': session_data.get('end_power_w', 0.0), - 'end_reason': session_data.get('end_reason'), - 'status': 'completed', - } - - # Merge metadata if provided - if session_data.get('metadata'): - existing_metadata = existing.get_metadata_dict() - existing_metadata.update(session_data['metadata']) - update_values['metadata'] = json.dumps(existing_metadata) - - existing.write(update_values) - return existing - - else: - raise ValueError(f"Unknown event_type: {session_data.get('event_type')}") - - @api.model - def get_running_sessions(self, device_id=None): - """ - Get all running sessions, optionally filtered by device - - Args: - device_id: int (optional) - Filter by device ID - - Returns: - List of session dicts for state recovery - """ - domain = [('status', '=', 'running')] - if device_id: - domain.append(('device_id', '=', device_id)) - - sessions = self.search(domain) - - return [{ - 'session_id': s.session_id, - 'device_id': s.device_id.id, - 'machine_id': s.device_id.device_id, # For session_detector compatibility - 'machine_name': s.device_id.name, - 'start_time': s.start_time.isoformat() + 'Z', - 'start_power_w': s.start_power_w, - 'standby_duration_s': s.standby_duration_s or 0, - 'working_duration_s': s.working_duration_s or 0, - } for s in sessions] diff --git a/open_workshop_mqtt/python_prototype/.gitignore b/open_workshop_mqtt/python_prototype/.gitignore deleted file mode 100644 index 6e8b16f..0000000 --- a/open_workshop_mqtt/python_prototype/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Config with secrets -config.yaml - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -env/ -venv/ -ENV/ -build/ -dist/ -*.egg-info/ - -# Data & Logs -data/ -logs/ -*.log - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db diff --git a/open_workshop_mqtt/python_prototype/README.md b/open_workshop_mqtt/python_prototype/README.md deleted file mode 100644 index 9a0e82f..0000000 --- a/open_workshop_mqtt/python_prototype/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# Open Workshop IoT Bridge - Python Prototype - -MQTT-basierte IoT Bridge für Odoo 18 Community zur Erfassung von Maschinenlaufzeiten. - -## Projektstruktur - -``` -python_prototype/ -├── main.py # Haupteinstiegspunkt -├── config.yaml # Produktionskonfiguration (nicht in Git) -├── config.yaml.example # Beispielkonfiguration -│ -├── Core Components (Produktionscode) -├── mqtt_client.py # MQTT Client mit TLS -├── shelly_parser.py # Parser für Shelly PM Mini G3 -├── event_normalizer.py # Event Schema v1 -├── session_detector.py # Session Detection (Dual-Threshold) -├── event_storage.py # JSON Storage -│ -├── data/ # Laufzeitdaten -│ ├── events.jsonl # Event-Log -│ └── sessions.json # Session-Daten -│ -├── tests/ # Tests -│ ├── unit/ # Unit Tests (schnell, isoliert) -│ │ └── test_session_detector.py -│ ├── integration/ # Integration Tests (mit MQTT) -│ │ └── test_mqtt_integration.py -│ └── tools/ # Test-Hilfsprogramme -│ └── shelly_simulator.py -│ -└── venv/ # Virtual Environment -``` - -## Installation - -```bash -python3 -m venv venv -source venv/bin/activate -pip install -r requirements.txt - -cp config.yaml.example config.yaml -# config.yaml anpassen -``` - -## Tests - -```bash -# Unit Tests (schnell, ~0.05s) -pytest tests/unit/ -v - -# Integration Tests (mit MQTT, ~30-60s) -# Benötigt MQTT Konfiguration: -# Option 1: Nutzt existierende config.yaml -pytest tests/integration/ -v -s - -# Option 2: Mit Umgebungsvariablen -export MQTT_HOST=mqtt.majufilo.eu -export MQTT_PORT=8883 -export MQTT_USERNAME=mosquitto -export MQTT_PASSWORD=dein_passwort -pytest tests/integration/ -v -s - -# Alle Tests -pytest tests/ -v -``` - -**Wichtig:** Integration Tests lesen MQTT-Zugangsdaten aus: -1. Umgebungsvariablen (`MQTT_HOST`, `MQTT_PASSWORD`, etc.) ODER -2. Existierender `config.yaml` im Projektroot - -**Niemals Passwörter im Source Code committen!** - -## Betrieb - -```bash -# Bridge starten -python main.py - -# Mit alternativer Config -python main.py --config custom_config.yaml -``` - -## Manuelle Tests - -```bash -# Shelly Simulator für Tests -python tests/tools/shelly_simulator.py --scenario session_end -python tests/tools/shelly_simulator.py --scenario full_session -python tests/tools/shelly_simulator.py --scenario timeout - -python3 tests/tools/shelly_simulator.py --broker localhost --port 1883 --no-tls --username "" --password "" --scenario full_session -``` - -## Session Detection States - -- **IDLE**: Power < 20W -- **STARTING**: Power >= 20W, 3s debounce -- **STANDBY**: 20-100W (Maschine an, Spindel aus) -- **WORKING**: >= 100W (Spindel läuft) -- **STOPPING**: < 20W, 15s debounce -- **Timeout**: 20s keine Messages → Session-Ende - -## Odoo Integration - -**Deployment-Strategie:** Direkte Integration in Odoo (kein separater Container) - -**Begründung:** -- Werkstatt-Setup: maximal 10 Maschinen -- Performance: `check_timeouts()` alle 10s = ~10ms CPU-Zeit pro Durchlauf -- CPU-Last: < 0.1% bei 10 Maschinen → vernachlässigbar -- Vorteile: Direkter DB-Zugriff, ACID-Transaktionen, keine API-Overhead -- Einfachheit: Ein Container, keine zusätzliche Infrastruktur - -**Implementation in Odoo:** -- MQTT Client als Odoo Thread oder Cron-Job -- `check_timeouts()` als scheduled action alle 10 Sekunden -- Direkte Verwendung von `workshop.machine`, `workshop.session` Models - -**Alternative (nur bei >50 Maschinen nötig):** Separater Microservice-Container - -## Roadmap - -- [x] M0-M3: MQTT + Parser + Session Detection -- [x] Unit + Integration Tests -- [x] Timeout Detection mit check_timeouts() -- [x] M4: Multi-Device Support (2+ Maschinen parallel) -- [x] M5: Reconnect + Error Handling - - MQTT Auto-Reconnect mit Exponential Backoff (1s → 60s) - - State Recovery: Laufende Sessions werden nach Neustart wiederhergestellt - - Robustes Error Handling in allen Parsern - - Alle 21 Tests bestanden ✅ -- [ ] M6-M10: Odoo Integration diff --git a/open_workshop_mqtt/python_prototype/config.yaml.example b/open_workshop_mqtt/python_prototype/config.yaml.example deleted file mode 100644 index a6cd58c..0000000 --- a/open_workshop_mqtt/python_prototype/config.yaml.example +++ /dev/null @@ -1,70 +0,0 @@ -# MQTT Broker Configuration -mqtt: - host: "localhost" # MQTT Broker IP/Hostname - port: 1883 # Standard MQTT Port (1883 unencrypted, 8883 encrypted) - username: "" # Optional: MQTT Username - password: "" # Optional: MQTT Password - client_id: "ows_iot_bridge_prototype" - keepalive: 60 - - # Topics to subscribe - topics: - - "shellies/+/status" # Shelly Status Updates - - "shellypmminig3/events" # Shelly Events - - "shellies/+/online" # Shelly Online Status - # Add more topics as needed - -# Device Configuration -# Multi-Device Support: Jedes Shelly PM Mini G3 Gerät = 1 Maschine -# Unterscheide Geräte durch verschiedene topic_prefix in MQTT Topics -devices: - - shelly_id: "shellypmminig3-48f6eeb73a1c" - machine_name: "Shaper Origin" - machine_id: "shaper-origin-01" - device_type: "shelly_pm_mini_g3" - - # Dual Power Thresholds (in Watts) - # IDLE: Power < standby_threshold_w - # STANDBY: standby_threshold_w <= Power < working_threshold_w (Maschine an, Spindel aus) - # WORKING: Power >= working_threshold_w (Spindel läuft) - standby_threshold_w: 20 # Start tracking session - working_threshold_w: 100 # Active work detected - - # Debounce times (in seconds) - start_debounce_s: 3 # Power >= standby_threshold for X seconds → session_start - stop_debounce_s: 15 # Power < standby_threshold for Y seconds → session_end - - # Timeout detection (in seconds) - message_timeout_s: 20 # No message for X seconds → session_end (timeout) - - enabled: true - - # Zweite Maschine - anderes Shelly-Gerät, andere Thresholds - # Wichtig: Jedes Shelly muss eigenen topic_prefix haben! - - shelly_id: "shellypmminig3-48f6eeb73a2d" - machine_name: "CNC Fräse" - machine_id: "cnc-mill-01" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 30 # Höherer Standby-Wert für CNC - working_threshold_w: 150 # Spindel braucht mehr Power - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - -# Logging Configuration -logging: - level: "INFO" # DEBUG, INFO, WARNING, ERROR - format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - file: "logs/ows_iot_bridge.log" # Optional: log to file - console: true - -# Output Configuration -output: - # Where to store events and sessions - events_file: "data/events.json" - sessions_file: "data/sessions.json" - - # Console output - print_events: true - print_sessions: true diff --git a/open_workshop_mqtt/python_prototype/event_normalizer.py b/open_workshop_mqtt/python_prototype/event_normalizer.py deleted file mode 100644 index a99cfbd..0000000 --- a/open_workshop_mqtt/python_prototype/event_normalizer.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Event Normalizer for Open Workshop IoT Bridge - -Converts device-specific data formats into a unified Event Schema v1 -for consistent processing across different IoT device types. -""" - -import logging -import uuid -from datetime import datetime, timezone -from typing import Dict, Optional - - -class EventNormalizer: - """ - Normalizes device-specific events into unified Event Schema v1 - - Event Schema v1: - { - "event_id": "uuid4", - "event_type": "power_measurement", - "timestamp": "2026-01-22T18:30:45.123456Z", - "machine": { - "machine_id": "shaper-origin-01", - "machine_name": "Shaper Origin" - }, - "device": { - "device_id": "48f6eeb73a1c", - "device_type": "shelly_pm_mini_g3", - "topic_prefix": "shaperorigin" - }, - "metrics": { - "power_w": 52.9, - "voltage_v": 234.8, - "current_a": 0.267, - "frequency_hz": 49.9 - }, - "raw_data": { ... } # Optional: Original device data - } - """ - - def __init__(self, device_config: list = None): - """ - Initialize normalizer with device configuration - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build topic_prefix to machine mapping - self.prefix_machine_map = {} - for device in self.device_config: - topic_prefix = device.get('topic_prefix') - if topic_prefix: - self.prefix_machine_map[topic_prefix] = { - 'machine_id': device.get('machine_id'), - 'machine_name': device.get('machine_name'), - 'device_type': device.get('device_type', 'unknown') - } - - def normalize_shelly_event(self, shelly_data: Dict) -> Optional[Dict]: - """ - Convert Shelly PM Mini G3 data to Event Schema v1 - - Args: - shelly_data: Parsed Shelly data from ShellyParser - - Returns: - Normalized event dict or None if data incomplete - """ - try: - # Extract device ID and find topic prefix - device_id = shelly_data.get('device_id', 'unknown') - topic_prefix = self._find_topic_prefix(device_id, shelly_data) - - if not topic_prefix: - self.logger.warning(f"Could not determine topic_prefix for device {device_id}") - return None - - # Get machine info from config - machine_info = self.prefix_machine_map.get(topic_prefix) - if not machine_info: - self.logger.warning(f"No machine config found for topic_prefix: {topic_prefix}") - return None - - # Build normalized event - event = { - "event_id": str(uuid.uuid4()), - "event_type": self._determine_event_type(shelly_data), - "timestamp": self._normalize_timestamp(shelly_data.get('timestamp')), - "machine": { - "machine_id": machine_info['machine_id'], - "machine_name": machine_info['machine_name'] - }, - "device": { - "device_id": device_id, - "device_type": machine_info['device_type'], - "topic_prefix": topic_prefix - }, - "metrics": self._extract_metrics(shelly_data), - "raw_data": shelly_data # Keep original for debugging - } - - return event - - except Exception as e: - self.logger.error(f"Failed to normalize Shelly event: {e}", exc_info=True) - return None - - def _find_topic_prefix(self, device_id: str, shelly_data: Dict) -> Optional[str]: - """ - Find topic_prefix for device - - Device ID can be: - - topic_prefix itself (e.g. 'shaperorigin' from status messages) - - actual device ID (e.g. '48f6eeb73a1c' from RPC events) - """ - # Check if device_id is already a known topic_prefix - if device_id in self.prefix_machine_map: - return device_id - - # Otherwise, we need to infer it (currently only one device configured) - # For multiple devices, we'd need more sophisticated matching - if len(self.prefix_machine_map) == 1: - return list(self.prefix_machine_map.keys())[0] - - # TODO: For multi-device setups, implement device_id to topic_prefix mapping - self.logger.warning(f"Cannot map device_id {device_id} to topic_prefix") - return None - - def _determine_event_type(self, shelly_data: Dict) -> str: - """Determine event type from Shelly data""" - msg_type = shelly_data.get('message_type', 'unknown') - - if msg_type in ['status', 'event']: - return 'power_measurement' - - return 'unknown' - - def _normalize_timestamp(self, timestamp_str: Optional[str]) -> str: - """ - Normalize timestamp to ISO 8601 UTC format - - Args: - timestamp_str: ISO timestamp string or None - - Returns: - ISO 8601 UTC timestamp string - """ - if timestamp_str: - try: - # Parse and ensure UTC - dt = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00')) - return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z') - except Exception as e: - self.logger.warning(f"Failed to parse timestamp {timestamp_str}: {e}") - - # Fallback: current time in UTC - return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') - - def _extract_metrics(self, shelly_data: Dict) -> Dict: - """ - Extract metrics from Shelly data - - Returns dict with standardized metric names - """ - metrics = {} - - # Power (always present in our use case) - # Shelly uses 'apower' (active power) - if 'apower' in shelly_data: - metrics['power_w'] = shelly_data['apower'] - - # Voltage (only in status messages) - if 'voltage' in shelly_data and shelly_data['voltage'] is not None: - metrics['voltage_v'] = shelly_data['voltage'] - - # Current (in status and some events) - if 'current' in shelly_data and shelly_data['current'] is not None: - metrics['current_a'] = shelly_data['current'] - - # Frequency (only in status messages) - if 'frequency' in shelly_data and shelly_data['frequency'] is not None: - metrics['frequency_hz'] = shelly_data['frequency'] - - # Energy counters (if available) - if 'total_energy' in shelly_data and shelly_data['total_energy'] is not None: - metrics['energy_total_wh'] = shelly_data['total_energy'] - - return metrics diff --git a/open_workshop_mqtt/python_prototype/event_storage.py b/open_workshop_mqtt/python_prototype/event_storage.py deleted file mode 100644 index 16e21fc..0000000 --- a/open_workshop_mqtt/python_prototype/event_storage.py +++ /dev/null @@ -1,232 +0,0 @@ -""" -Event Storage Module - Persists events and sessions to JSON files -For later migration to Odoo database models -""" - -import json -import logging -from pathlib import Path -from typing import Dict, Optional, List -from datetime import datetime - - -class EventStorage: - """ - Stores events and sessions to JSON files - - Format designed for easy migration to Odoo models: - - events.jsonl: JSON Lines format (one event per line) → open_workshop.power_event - - sessions.json: JSON array of sessions → open_workshop.session - """ - - def __init__(self, events_file: str = "data/events.jsonl", - sessions_file: str = "data/sessions.json"): - self.logger = logging.getLogger(__name__) - self.events_file = Path(events_file) - self.sessions_file = Path(sessions_file) - - # Ensure data directory exists - self.events_file.parent.mkdir(parents=True, exist_ok=True) - - # Initialize sessions file if it doesn't exist - if not self.sessions_file.exists(): - self._write_sessions([]) - - def store_event(self, event: Dict) -> bool: - """ - Append event to JSONL file (one JSON object per line) - - Format for Odoo migration: - { - "event_id": "uuid", - "event_type": "power_measurement", - "timestamp": "ISO 8601", - "machine": {"machine_id": "...", "machine_name": "..."}, - "device": {...}, - "metrics": {"power_w": 45.7, "voltage_v": 230.2, ...} - } - """ - try: - with open(self.events_file, 'a', encoding='utf-8') as f: - # Write one JSON object per line (JSONL format) - json.dump(event, f, ensure_ascii=False) - f.write('\n') - - self.logger.debug(f"Event {event.get('event_id', 'N/A')} stored") - return True - - except Exception as e: - self.logger.error(f"Failed to store event: {e}") - return False - - def store_session_event(self, session_event: Dict) -> bool: - """ - Store session_start or session_end event - - Updates sessions.json with structured session data - - Format for Odoo migration (open_workshop.session): - { - "session_id": "uuid", - "machine_id": "shaper-origin-01", - "machine_name": "Shaper Origin", - "start_time": "2026-01-22T18:36:20.993Z", - "end_time": "2026-01-22T18:38:01.993Z", // null if running - "total_duration_s": 101, // null if running - "standby_duration_s": 80, // null if running (machine on, not working) - "working_duration_s": 21, // null if running (active work) - "start_power_w": 45.7, - "end_power_w": 0.0, // null if running - "end_reason": "power_drop", // or "timeout", null if running - """ - try: - sessions = self._read_sessions() - - session_id = session_event.get('session_id') - event_type = session_event.get('event_type') - machine = session_event.get('machine', {}) - session_data = session_event.get('session_data', {}) - - if event_type == 'session_start': - # Add new session - sessions.append({ - "session_id": session_id, - "machine_id": machine.get('machine_id'), - "machine_name": machine.get('machine_name'), - "start_time": session_data.get('start_time'), - "end_time": None, - "total_duration_s": None, - "standby_duration_s": None, - "working_duration_s": None, - "start_power_w": session_event.get('power_w'), - "end_power_w": None, - "end_reason": None, - "status": "running" - }) - - elif event_type == 'session_end': - # Update existing session - for session in sessions: - if session['session_id'] == session_id: - session.update({ - "end_time": session_data.get('end_time'), - "total_duration_s": session_data.get('total_duration_s'), - "standby_duration_s": session_data.get('standby_duration_s'), - "working_duration_s": session_data.get('working_duration_s'), - "end_power_w": session_event.get('power_w'), - "end_reason": session_data.get('end_reason'), - "status": "completed" - }) - break - - self._write_sessions(sessions) - self.logger.debug(f"Session {event_type} {session_id} stored") - return True - - except Exception as e: - self.logger.error(f"Failed to store session event: {e}", exc_info=True) - return False - - def load_sessions(self) -> List[Dict]: - """ - Load all sessions from sessions.json - - Returns: - List of session dicts (running and completed) - """ - try: - return self._read_sessions() - except Exception as e: - self.logger.error(f"Failed to load sessions: {e}") - return [] - - def get_running_sessions(self) -> List[Dict]: - """ - Get all currently running sessions (end_time is None) - - Returns: - List of running session dicts - """ - try: - sessions = self._read_sessions() - return [s for s in sessions if s.get('end_time') is None] - except Exception as e: - self.logger.error(f"Failed to get running sessions: {e}") - return [] - - def _handle_session_end(self, event: Dict) -> bool: - """Handle session_end event""" - sessions = self._read_sessions() - session_id = event['session_id'] - - # Find and update session - for session in sessions: - if session['session_id'] == session_id: - session_data = event.get('session_data', {}) - - session['end_time'] = session_data.get('end_time') - session['total_duration_s'] = session_data.get('total_duration_s') - session['standby_duration_s'] = session_data.get('standby_duration_s') - session['working_duration_s'] = session_data.get('working_duration_s') - session['end_power_w'] = event.get('power_w') - session['end_reason'] = session_data.get('end_reason', 'normal') - session['status'] = 'completed' - - self._write_sessions(sessions) - - total_min = session['total_duration_s'] / 60 if session['total_duration_s'] else 0 - standby_min = session['standby_duration_s'] / 60 if session['standby_duration_s'] else 0 - working_min = session['working_duration_s'] / 60 if session['working_duration_s'] else 0 - - self.logger.info( - f"Session {session_id[:8]}... completed ({session['end_reason']}) - " - f"Total: {total_min:.1f}min, Standby: {standby_min:.1f}min, Working: {working_min:.1f}min" - ) - return True - - self.logger.error(f"Session {session_id} not found for update") - return False - - def _read_sessions(self) -> list: - """Read all sessions from JSON file""" - try: - if self.sessions_file.exists(): - with open(self.sessions_file, 'r', encoding='utf-8') as f: - return json.load(f) - return [] - except Exception as e: - self.logger.error(f"Failed to read sessions: {e}") - return [] - - def _write_sessions(self, sessions: list) -> bool: - """Write sessions to JSON file""" - try: - with open(self.sessions_file, 'w', encoding='utf-8') as f: - json.dump(sessions, f, ensure_ascii=False, indent=2) - return True - except Exception as e: - self.logger.error(f"Failed to write sessions: {e}") - return False - - def get_active_sessions(self) -> list: - """Get all running sessions""" - sessions = self._read_sessions() - return [s for s in sessions if s['status'] == 'running'] - - def get_session_by_id(self, session_id: str) -> Optional[Dict]: - """Get session by ID""" - sessions = self._read_sessions() - for session in sessions: - if session['session_id'] == session_id: - return session - return None - - def get_sessions_by_machine(self, machine_id: str, limit: int = 10) -> list: - """Get recent sessions for a machine""" - sessions = self._read_sessions() - machine_sessions = [s for s in sessions if s['machine_id'] == machine_id] - - # Sort by start_time descending - machine_sessions.sort(key=lambda x: x['start_time'], reverse=True) - - return machine_sessions[:limit] diff --git a/open_workshop_mqtt/python_prototype/main.py b/open_workshop_mqtt/python_prototype/main.py deleted file mode 100644 index 02a412f..0000000 --- a/open_workshop_mqtt/python_prototype/main.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -""" -Open Workshop IoT Bridge - Python Prototype -Main entry point for MQTT to Odoo bridge - -Phase 1: Standalone prototype (without Odoo) -""" - -import sys -import logging -import yaml -import time -import signal -import json -import argparse -from pathlib import Path -from typing import Dict - -from mqtt_client import MQTTClient -from shelly_parser import ShellyParser -from event_normalizer import EventNormalizer -from session_detector import SessionDetector -from event_storage import EventStorage - - -class IoTBridge: - """Main IoT Bridge Application""" - - def __init__(self, config_path: str = 'config.yaml'): - """ - Initialize IoT Bridge - - Args: - config_path: Path to config.yaml file - """ - # Load configuration - self.config = self._load_config(config_path) - - # Setup logging - self._setup_logging() - - self.logger = logging.getLogger(__name__) - self.logger.info("=== Open Workshop IoT Bridge Starting ===") - - # Initialize Shelly Parser with device config - self.shelly_parser = ShellyParser(device_config=self.config.get('devices', [])) - - # Initialize Event Normalizer - self.event_normalizer = EventNormalizer(device_config=self.config.get('devices', [])) - - # Initialize Session Detector - self.session_detector = SessionDetector(device_config=self.config.get('devices', [])) - - # Initialize Event Storage - output_config = self.config.get('output', {}) - self.event_storage = EventStorage( - events_file=output_config.get('events_file', 'data/events.jsonl'), - sessions_file=output_config.get('sessions_file', 'data/sessions.json') - ) - - # Initialize MQTT Client - self.mqtt_client = MQTTClient( - config=self.config['mqtt'], - message_callback=self.on_mqtt_message - ) - - self.running = False - - # Setup signal handlers for graceful shutdown - signal.signal(signal.SIGINT, self._signal_handler) - signal.signal(signal.SIGTERM, self._signal_handler) - - def _load_config(self, config_path: str) -> dict: - """Load configuration from YAML file""" - config_file = Path(config_path) - - if not config_file.exists(): - print(f"Error: Config file not found: {config_path}") - print(f"Please copy config.yaml.example to config.yaml and adjust settings") - sys.exit(1) - - with open(config_file, 'r') as f: - config = yaml.safe_load(f) - - return config - - def _setup_logging(self): - """Setup logging configuration""" - log_config = self.config.get('logging', {}) - - # Create logs directory if logging to file - if log_config.get('file'): - log_file = Path(log_config['file']) - log_file.parent.mkdir(parents=True, exist_ok=True) - - # Configure logging - log_level = getattr(logging, log_config.get('level', 'INFO').upper()) - log_format = log_config.get('format', '%(asctime)s - %(name)s - %(levelname)s - %(message)s') - - handlers = [] - - # Console handler - if log_config.get('console', True): - console_handler = logging.StreamHandler() - console_handler.setFormatter(logging.Formatter(log_format)) - handlers.append(console_handler) - - # File handler - if log_config.get('file'): - file_handler = logging.FileHandler(log_config['file']) - file_handler.setFormatter(logging.Formatter(log_format)) - handlers.append(file_handler) - - logging.basicConfig( - level=log_level, - format=log_format, - handlers=handlers - ) - - def _signal_handler(self, signum, frame): - """Handle shutdown signals""" - self.logger.info(f"Received signal {signum}, shutting down...") - self.stop() - - def on_mqtt_message(self, topic: str, payload): - """ - Callback for incoming MQTT messages - - Args: - topic: MQTT topic - payload: Message payload (parsed JSON or raw string) - """ - # Log RAW topic for analysis - if 'shellypmminig3' in topic and 'debug' not in topic: - self.logger.info(f"RAW Topic: {topic}") - - # Only process JSON payloads - if not isinstance(payload, dict): - return - - # Parse Shelly message - parsed_data = self.shelly_parser.parse_message(topic, payload) - - if parsed_data: - # Normalize to Event Schema v1 - normalized_event = self.event_normalizer.normalize_shelly_event(parsed_data) - - if normalized_event: - # Store event (for later Odoo migration) - self.event_storage.store_event(normalized_event) - - # Log normalized event (condensed) - metrics = normalized_event.get('metrics', {}) - power_w = metrics.get('power_w', 'N/A') - self.logger.debug( - f"Event: {normalized_event['machine']['machine_name']} - " - f"Power: {power_w}W" - ) - - # Process through Session Detector - session_event = self.session_detector.process_event(normalized_event) - - if session_event: - # Session state change occurred! - self._log_session_event(session_event) - - # Store session event (for Odoo migration) - self.event_storage.store_session_event(session_event) - - def _log_session_event(self, session_event: Dict): - """Log session start/end events""" - self.logger.info("\n" + "=" * 70) - - if session_event['event_type'] == 'session_start': - self.logger.info("🚀 SESSION START") - else: - self.logger.info("🏁 SESSION END") - - self.logger.info("=" * 70) - self.logger.info(f"Session ID: {session_event['session_id']}") - self.logger.info(f"Machine: {session_event['machine']['machine_name']} ({session_event['machine']['machine_id']})") - self.logger.info(f"Timestamp: {session_event['timestamp']}") - self.logger.info(f"Power: {session_event['power_w']:.1f}W") - - session_data = session_event.get('session_data', {}) - self.logger.info(f"Start Time: {session_data.get('start_time')}") - - if 'end_time' in session_data: - self.logger.info(f"End Time: {session_data['end_time']}") - duration_s = session_data.get('duration_s', 0) - duration_min = duration_s / 60 - self.logger.info(f"Duration: {duration_s}s ({duration_min:.2f} min)") - - self.logger.info("=" * 70 + "\n") - - def _restore_running_sessions(self): - """Restore running sessions after bridge restart""" - self.logger.info("Checking for running sessions to restore...") - - running_sessions = self.event_storage.get_running_sessions() - - if running_sessions: - self.logger.info(f"Found {len(running_sessions)} running session(s)") - self.session_detector.restore_state(running_sessions) - else: - self.logger.info("No running sessions to restore") - - def start(self): - """Start the IoT Bridge""" - self.logger.info("Starting IoT Bridge...") - - # Restore running sessions after restart - self._restore_running_sessions() - - # Create data directory - data_dir = Path('data') - data_dir.mkdir(exist_ok=True) - - # Connect to MQTT - if not self.mqtt_client.connect(): - self.logger.error("Failed to connect to MQTT Broker") - return False - - # Start MQTT loop - self.mqtt_client.start() - - # Wait for connection - if not self.mqtt_client.wait_for_connection(timeout=10): - self.logger.error("MQTT connection timeout") - return False - - self.logger.info("IoT Bridge started successfully") - self.logger.info("Listening for MQTT messages... (Press Ctrl+C to stop)") - - self.running = True - - # Main loop - try: - while self.running: - time.sleep(1) - - # Check for session timeouts periodically - timeout_events = self.session_detector.check_timeouts() - for session_event in timeout_events: - self._log_session_event(session_event) - self.event_storage.store_session_event(session_event) - - except KeyboardInterrupt: - self.logger.info("Interrupted by user") - - return True - - def stop(self): - """Stop the IoT Bridge""" - self.running = False - self.logger.info("Stopping IoT Bridge...") - self.mqtt_client.stop() - self.logger.info("IoT Bridge stopped") - - -def main(): - """Main entry point""" - parser = argparse.ArgumentParser(description='Open Workshop IoT Bridge') - parser.add_argument('--config', default='config.yaml', help='Path to config file (default: config.yaml)') - args = parser.parse_args() - - # Check if config file exists - config_file = Path(args.config) - - if not config_file.exists(): - print("\n" + "="*60) - print(f"Configuration file not found: {config_file}") - print("="*60) - print("\nPlease create config.yaml from the example:") - print(" cp config.yaml.example config.yaml") - print("\nThen edit config.yaml with your MQTT broker settings.") - print("="*60 + "\n") - sys.exit(1) - - # Start bridge - bridge = IoTBridge(str(config_file)) - bridge.start() - - -if __name__ == '__main__': - main() diff --git a/open_workshop_mqtt/python_prototype/mqtt_client.py b/open_workshop_mqtt/python_prototype/mqtt_client.py deleted file mode 100644 index 1105d3c..0000000 --- a/open_workshop_mqtt/python_prototype/mqtt_client.py +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env python3 -""" -MQTT Client für Open Workshop IoT Bridge -Verbindet sich mit MQTT Broker und empfängt Device Events -""" - -import logging -import time -import json -import ssl -from typing import Callable, Optional -import paho.mqtt.client as mqtt - - -class MQTTClient: - """MQTT Client Wrapper für Device Event Empfang""" - - def __init__(self, config: dict, message_callback: Optional[Callable] = None): - """ - Initialize MQTT Client - - Args: - config: MQTT configuration dict from config.yaml - message_callback: Callback function for incoming messages - """ - self.config = config - self.message_callback = message_callback - self.logger = logging.getLogger(__name__) - - # Create MQTT Client - self.client = mqtt.Client( - client_id=config.get('client_id', 'ows_iot_bridge'), - protocol=mqtt.MQTTv5 - ) - - # Set callbacks - self.client.on_connect = self._on_connect - self.client.on_disconnect = self._on_disconnect - self.client.on_message = self._on_message - - # Set username/password if provided - if config.get('username') and config.get('password'): - self.client.username_pw_set( - config['username'], - config['password'] - ) - - # Enable TLS/SSL if port is 8883 - if config.get('port') == 8883: - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - self.logger.info("TLS/SSL enabled for port 8883") - - self.connected = False - self.topics = config.get('topics', []) - self.reconnect_delay = 1 # Start with 1 second - self.max_reconnect_delay = 60 # Max 60 seconds - self.reconnecting = False - - def _on_connect(self, client, userdata, flags, rc, properties=None): - """Callback when connected to MQTT broker""" - if rc == 0: - self.connected = True - self.reconnecting = False - self.reconnect_delay = 1 # Reset delay on successful connect - self.logger.info(f"Connected to MQTT Broker at {self.config['host']}:{self.config['port']}") - - # Subscribe to all configured topics - for topic in self.topics: - self.client.subscribe(topic) - self.logger.info(f"Subscribed to topic: {topic}") - else: - self.logger.error(f"Failed to connect to MQTT Broker, return code {rc}") - self.connected = False - self._schedule_reconnect() - - def _on_disconnect(self, client, userdata, rc, properties=None): - """Callback when disconnected from MQTT broker""" - self.connected = False - if rc != 0: - self.logger.warning(f"Unexpected disconnect from MQTT Broker (rc={rc})") - self._schedule_reconnect() - else: - self.logger.info("Disconnected from MQTT Broker") - - def _schedule_reconnect(self): - """Schedule automatic reconnection with exponential backoff""" - if self.reconnecting: - return # Already reconnecting - - self.reconnecting = True - self.logger.info(f"Attempting reconnect in {self.reconnect_delay}s...") - time.sleep(self.reconnect_delay) - - try: - self.client.reconnect() - self.logger.info("Reconnection initiated") - except Exception as e: - self.logger.error(f"Reconnection failed: {e}") - # Exponential backoff - self.reconnect_delay = min(self.reconnect_delay * 2, self.max_reconnect_delay) - self.reconnecting = False - - def _on_message(self, client, userdata, msg): - """Callback when message received""" - try: - topic = msg.topic - payload = msg.payload.decode('utf-8') - - # Log all shellypmminig3 topics (exclude debug) - if 'shellypmminig3' in topic and 'debug' not in topic: - self.logger.info(f"📡 TOPIC: {topic}") - - self.logger.debug(f"Message received on topic '{topic}'") - - # Try to parse as JSON - try: - payload_json = json.loads(payload) - except json.JSONDecodeError: - # Debug logs are expected to be non-JSON, only warn for non-debug topics - if 'debug' not in topic: - self.logger.warning(f"Message on '{topic}' is not valid JSON: {payload[:100]}") - payload_json = None - - # Call user callback if provided - if self.message_callback: - self.message_callback(topic, payload_json or payload) - else: - # Default: just log - self.logger.info(f"Topic: {topic}") - if payload_json: - self.logger.info(f"Payload: {json.dumps(payload_json, indent=2)}") - else: - self.logger.info(f"Payload: {payload}") - - except Exception as e: - self.logger.error(f"Error processing message: {e}", exc_info=True) - - def connect(self): - """Connect to MQTT broker""" - try: - self.logger.info(f"Connecting to MQTT Broker {self.config['host']}:{self.config['port']}...") - self.client.connect( - self.config['host'], - self.config['port'], - self.config.get('keepalive', 60) - ) - return True - except Exception as e: - self.logger.error(f"Failed to connect to MQTT Broker: {e}") - return False - - def start(self): - """Start the MQTT client loop (non-blocking)""" - self.client.loop_start() - - def stop(self): - """Stop the MQTT client loop""" - self.client.loop_stop() - self.client.disconnect() - self.logger.info("MQTT Client stopped") - - def publish(self, topic: str, payload: dict): - """ - Publish message to MQTT topic - - Args: - topic: MQTT topic - payload: Message payload (will be JSON encoded) - """ - try: - payload_json = json.dumps(payload) - result = self.client.publish(topic, payload_json) - if result.rc == mqtt.MQTT_ERR_SUCCESS: - self.logger.debug(f"Published to {topic}") - return True - else: - self.logger.error(f"Failed to publish to {topic}, rc={result.rc}") - return False - except Exception as e: - self.logger.error(f"Error publishing to {topic}: {e}") - return False - - def wait_for_connection(self, timeout: int = 10) -> bool: - """ - Wait for connection to be established - - Args: - timeout: Maximum seconds to wait - - Returns: - True if connected, False if timeout - """ - start_time = time.time() - while not self.connected and (time.time() - start_time) < timeout: - time.sleep(0.1) - - return self.connected diff --git a/open_workshop_mqtt/python_prototype/requirements.txt b/open_workshop_mqtt/python_prototype/requirements.txt deleted file mode 100644 index 63fd606..0000000 --- a/open_workshop_mqtt/python_prototype/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -# MQTT Client -paho-mqtt>=2.0.0 - -# Configuration -pyyaml>=6.0 - -# Logging & Utilities -python-dateutil>=2.8.2 - -# For UUID generation -uuid>=1.30 - -# Optional: für spätere JSON Schema Validation -jsonschema>=4.17.0 - -# Optional: für besseres Logging -colorlog>=6.7.0 diff --git a/open_workshop_mqtt/python_prototype/session_detector.py b/open_workshop_mqtt/python_prototype/session_detector.py deleted file mode 100644 index c8bb2a2..0000000 --- a/open_workshop_mqtt/python_prototype/session_detector.py +++ /dev/null @@ -1,514 +0,0 @@ -""" -Session Detection Engine for Open Workshop IoT Bridge - -Detects machine run sessions with dual power thresholds: -- STANDBY: Machine on, not working (e.g. 20-100W) -- WORKING: Active work (e.g. >= 100W) - -Includes timeout detection for when machine powers off (no MQTT messages). -""" - -import logging -import uuid -from datetime import datetime, timezone, timedelta -from typing import Dict, Optional, List -from enum import Enum - - -class SessionState(Enum): - """Session detection states""" - IDLE = "idle" # Machine off (power < standby_threshold) - STARTING = "starting" # Power above standby threshold, waiting for debounce - STANDBY = "standby" # Machine on, not working (standby <= power < working) - WORKING = "working" # Active work (power >= working threshold) - STOPPING = "stopping" # Power below standby threshold, waiting for debounce - - -class SessionDetector: - """ - Detects machine run sessions with dual thresholds and timeout detection - - State Machine: - IDLE -> STARTING -> STANDBY/WORKING -> STOPPING -> IDLE - - - IDLE: Power < standby_threshold - - STARTING: Power >= standby_threshold for < start_debounce_s - - STANDBY: Power >= standby_threshold < working_threshold (confirmed) - - WORKING: Power >= working_threshold (confirmed) - - STOPPING: Power < standby_threshold for < stop_debounce_s - - Back to IDLE: Power < standby_threshold for >= stop_debounce_s OR timeout - - Timeout: No MQTT message for > message_timeout_s → session_end (machine powered off) - """ - - def __init__(self, device_config: list = None): - """ - Initialize session detector - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build machine_id to config mapping - self.machine_config = {} - for device in self.device_config: - machine_id = device.get('machine_id') - if machine_id: - self.machine_config[machine_id] = { - 'standby_threshold_w': device.get('standby_threshold_w', 20), - 'working_threshold_w': device.get('working_threshold_w', 100), - 'start_debounce_s': device.get('start_debounce_s', 3), - 'stop_debounce_s': device.get('stop_debounce_s', 15), - 'message_timeout_s': device.get('message_timeout_s', 60), - 'machine_name': device.get('machine_name', 'Unknown'), - } - - # State tracking per machine - self.machine_states = {} # machine_id -> state info - - def restore_state(self, running_sessions: List[Dict]) -> None: - """ - Restore state from running sessions after bridge restart - - Args: - running_sessions: List of session dicts with end_time=None from EventStorage - """ - if not running_sessions: - self.logger.info("No running sessions to restore") - return - - self.logger.info(f"Restoring state for {len(running_sessions)} running sessions") - - for session in running_sessions: - machine_id = session.get('machine_id') - session_id = session.get('session_id') - - if not machine_id or not session_id: - self.logger.warning(f"Invalid session data, skipping: {session}") - continue - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - self.logger.warning(f"No config for machine {machine_id}, cannot restore") - continue - - # Parse start time - try: - start_time = datetime.fromisoformat(session['start_time'].replace('Z', '+00:00')) - except Exception as e: - self.logger.error(f"Invalid start_time in session {session_id}: {e}") - continue - - # Restore machine state - self.machine_states[machine_id] = { - 'state': SessionState.STANDBY, # Start in STANDBY, will update on next message - 'state_since': start_time, - 'current_session_id': session_id, - 'session_start_time': start_time, - 'last_power': session.get('start_power_w'), - 'last_message_time': start_time, - 'standby_duration_s': session.get('standby_duration_s', 0), - 'working_duration_s': session.get('working_duration_s', 0), - 'last_state_change': start_time, - } - - self.logger.info( - f"✓ Restored session {session_id} for {session.get('machine_name', machine_id)}" - ) - - def check_timeouts(self) -> List[Dict]: - """ - Check all machines for message timeouts and end sessions if needed. - Should be called periodically (e.g. every second). - - Returns: - List of session_end events for timed-out sessions - """ - timeout_events = [] - current_time = datetime.now(timezone.utc) - - for machine_id, state_info in self.machine_states.items(): - # Only check machines in active session states - if state_info['state'] not in [SessionState.STANDBY, SessionState.WORKING]: - continue - - # Skip if no last message time - if not state_info['last_message_time']: - continue - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - continue - - # Check if timeout exceeded - time_since_last_message = (current_time - state_info['last_message_time']).total_seconds() - if time_since_last_message > config['message_timeout_s']: - machine_name = config.get('machine_name', 'Unknown') - self.logger.warning( - f"⏱️ {machine_name}: Message timeout " - f"({time_since_last_message:.0f}s > {config['message_timeout_s']}s) → SESSION END" - ) - - # End session with timeout - timeout_event = self._end_session_timeout(machine_id, machine_name, current_time, config, state_info) - if timeout_event: - timeout_events.append(timeout_event) - - return timeout_events - - def process_event(self, event: Dict) -> Optional[Dict]: - """ - Process a normalized event and detect session changes - - Args: - event: Normalized event from EventNormalizer - - Returns: - Session event dict if state change occurred, None otherwise - - Session Event Format: - { - "session_id": "uuid4", - "event_type": "session_start" | "session_end", - "timestamp": "ISO 8601 UTC", - "machine": { "machine_id": "...", "machine_name": "..." }, - "power_w": 123.4, - "session_data": { - "start_time": "ISO 8601 UTC", - "end_time": "ISO 8601 UTC", # only for session_end - "duration_s": 123 # only for session_end - } - } - """ - try: - # Extract machine info - machine = event.get('machine', {}) - machine_id = machine.get('machine_id') - - if not machine_id: - self.logger.warning("Event missing machine_id, skipping") - return None - - # Get machine config - config = self.machine_config.get(machine_id) - if not config: - self.logger.warning(f"No config found for machine {machine_id}") - return None - - # Extract power measurement - metrics = event.get('metrics', {}) - power_w = metrics.get('power_w') - - if power_w is None: - self.logger.debug(f"Event missing power_w, skipping") - return None - - # Initialize machine state if needed - if machine_id not in self.machine_states: - self.machine_states[machine_id] = { - 'state': SessionState.IDLE, - 'state_since': datetime.now(timezone.utc), - 'current_session_id': None, - 'session_start_time': None, - 'last_power': None, - 'last_message_time': None, - 'standby_duration_s': 0, - 'working_duration_s': 0, - 'last_state_change': datetime.now(timezone.utc), - } - - state_info = self.machine_states[machine_id] - timestamp = datetime.fromisoformat(event['timestamp'].replace('Z', '+00:00')) - machine_name = machine.get('machine_name', 'Unknown') - - # Check for timeout (no message received) - if state_info['last_message_time']: - time_since_last_message = (timestamp - state_info['last_message_time']).total_seconds() - if time_since_last_message > config['message_timeout_s']: - if state_info['state'] in [SessionState.STANDBY, SessionState.WORKING]: - self.logger.warning( - f"{machine_name}: Message timeout " - f"({time_since_last_message:.0f}s > {config['message_timeout_s']}s) → SESSION END" - ) - return self._end_session_timeout(machine_id, machine_name, timestamp, config, state_info) - - # Update last message time - state_info['last_message_time'] = timestamp - - # Update last power - state_info['last_power'] = power_w - - # Process state machine - return self._process_state_machine( - machine_id=machine_id, - machine_name=machine.get('machine_name'), - power_w=power_w, - timestamp=timestamp, - config=config, - state_info=state_info - ) - - except Exception as e: - self.logger.error(f"Error processing event: {e}", exc_info=True) - return None - - def _process_state_machine( - self, - machine_id: str, - machine_name: str, - power_w: float, - timestamp: datetime, - config: Dict, - state_info: Dict - ) -> Optional[Dict]: - """ - Process state machine logic with dual thresholds - - Returns session event if state change occurred - """ - current_state = state_info['state'] - standby_threshold = config['standby_threshold_w'] - working_threshold = config['working_threshold_w'] - start_debounce = config['start_debounce_s'] - stop_debounce = config['stop_debounce_s'] - - time_in_state = (timestamp - state_info['state_since']).total_seconds() - - # Update duration tracking for active states - if current_state == SessionState.STANDBY: - time_since_last_update = (timestamp - state_info['last_state_change']).total_seconds() - state_info['standby_duration_s'] += time_since_last_update - state_info['last_state_change'] = timestamp - elif current_state == SessionState.WORKING: - time_since_last_update = (timestamp - state_info['last_state_change']).total_seconds() - state_info['working_duration_s'] += time_since_last_update - state_info['last_state_change'] = timestamp - - # State machine transitions - if current_state == SessionState.IDLE: - if power_w >= standby_threshold: - # Transition to STARTING - self.logger.info(f"🟡 {machine_name}: Power {power_w:.1f}W >= {standby_threshold}W → STARTING") - state_info['state'] = SessionState.STARTING - state_info['state_since'] = timestamp - - elif current_state == SessionState.STARTING: - if power_w < standby_threshold: - # False start, back to IDLE - self.logger.info(f"⚪ {machine_name}: Power dropped before debounce → IDLE") - state_info['state'] = SessionState.IDLE - state_info['state_since'] = timestamp - - elif time_in_state >= start_debounce: - # Debounce passed, transition to STANDBY or WORKING - session_id = str(uuid.uuid4()) - state_info['current_session_id'] = session_id - state_info['session_start_time'] = timestamp - state_info['standby_duration_s'] = 0 - state_info['working_duration_s'] = 0 - state_info['last_state_change'] = timestamp - - if power_w >= working_threshold: - state_info['state'] = SessionState.WORKING - self.logger.info(f"🔵 {machine_name}: Session START → WORKING (debounce {time_in_state:.1f}s) - Power: {power_w:.1f}W") - else: - state_info['state'] = SessionState.STANDBY - self.logger.info(f"🟢 {machine_name}: Session START → STANDBY (debounce {time_in_state:.1f}s) - Power: {power_w:.1f}W") - - state_info['state_since'] = timestamp - - # Generate session_start event - return { - 'session_id': session_id, - 'event_type': 'session_start', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': power_w, - 'session_data': { - 'start_time': timestamp.isoformat().replace('+00:00', 'Z') - } - } - - elif current_state == SessionState.STANDBY: - if power_w < standby_threshold: - # Transition to STOPPING - self.logger.info(f"🟠 {machine_name}: Power {power_w:.1f}W < {standby_threshold}W → STOPPING") - state_info['state'] = SessionState.STOPPING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif power_w >= working_threshold: - # Transition to WORKING - self.logger.info(f"🔵 {machine_name}: Power {power_w:.1f}W >= {working_threshold}W → WORKING") - state_info['state'] = SessionState.WORKING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif current_state == SessionState.WORKING: - if power_w < standby_threshold: - # Transition to STOPPING - self.logger.info(f"🟠 {machine_name}: Power {power_w:.1f}W < {standby_threshold}W → STOPPING") - state_info['state'] = SessionState.STOPPING - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif power_w < working_threshold: - # Transition to STANDBY - self.logger.info(f"🟢 {machine_name}: Power {power_w:.1f}W < {working_threshold}W → STANDBY") - state_info['state'] = SessionState.STANDBY - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - elif current_state == SessionState.STOPPING: - if power_w >= standby_threshold: - # Power back up, cancel STOPPING - if power_w >= working_threshold: - self.logger.info(f"🔵 {machine_name}: Power back up → WORKING") - state_info['state'] = SessionState.WORKING - else: - self.logger.info(f"🟢 {machine_name}: Power back up → STANDBY") - state_info['state'] = SessionState.STANDBY - state_info['state_since'] = timestamp - state_info['last_state_change'] = timestamp - - else: - # Power still low, check if debounce passed - if time_in_state >= stop_debounce: - # Debounce passed, session ended - return self._end_session_normal(machine_id, machine_name, power_w, timestamp, state_info) - - return None - - def _end_session_normal( - self, - machine_id: str, - machine_name: str, - power_w: float, - timestamp: datetime, - state_info: Dict - ) -> Dict: - """End session normally (power drop)""" - session_id = state_info['current_session_id'] - start_time = state_info['session_start_time'] - duration_s = (timestamp - start_time).total_seconds() - - standby_duration = state_info.get('standby_duration_s') or 0 - working_duration = state_info.get('working_duration_s') or 0 - - self.logger.info( - f"🔴 {machine_name}: Session END (power drop) - " - f"Total: {duration_s:.0f}s, Standby: {standby_duration:.0f}s, Working: {working_duration:.0f}s" - ) - - # Generate session_end event - session_event = { - 'session_id': session_id, - 'event_type': 'session_end', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': power_w, - 'session_data': { - 'start_time': start_time.isoformat().replace('+00:00', 'Z'), - 'end_time': timestamp.isoformat().replace('+00:00', 'Z'), - 'total_duration_s': int(duration_s), - 'standby_duration_s': int(standby_duration), - 'working_duration_s': int(working_duration), - 'end_reason': 'power_drop' - } - } - - # Reset to IDLE - self._reset_session_state(state_info, timestamp) - - return session_event - - def _end_session_timeout( - self, - machine_id: str, - machine_name: str, - timestamp: datetime, - config: Dict, - state_info: Dict - ) -> Dict: - """End session due to timeout (no messages)""" - session_id = state_info['current_session_id'] - start_time = state_info['session_start_time'] - duration_s = (timestamp - start_time).total_seconds() - - standby_duration = state_info.get('standby_duration_s') or 0 - working_duration = state_info.get('working_duration_s') or 0 - - self.logger.warning( - f"⏱️ {machine_name}: Session END (TIMEOUT) - " - f"Total: {duration_s:.0f}s, Standby: {standby_duration:.0f}s, Working: {working_duration:.0f}s" - ) - - # Generate session_end event - session_event = { - 'session_id': session_id, - 'event_type': 'session_end', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': machine_name - }, - 'power_w': 0.0, # Assume power is 0 on timeout - 'session_data': { - 'start_time': start_time.isoformat().replace('+00:00', 'Z'), - 'end_time': timestamp.isoformat().replace('+00:00', 'Z'), - 'total_duration_s': int(duration_s), - 'standby_duration_s': int(standby_duration), - 'working_duration_s': int(working_duration), - 'end_reason': 'timeout' - } - } - - # Reset to IDLE - self._reset_session_state(state_info, timestamp) - - return session_event - - def _reset_session_state(self, state_info: Dict, timestamp: datetime): - """Reset session state to IDLE""" - state_info['state'] = SessionState.IDLE - state_info['state_since'] = timestamp - state_info['current_session_id'] = None - state_info['session_start_time'] = None - state_info['standby_duration_s'] = 0 - state_info['working_duration_s'] = 0 - state_info['last_state_change'] = timestamp - - def get_machine_state(self, machine_id: str) -> Optional[str]: - """Get current state of a machine""" - state_info = self.machine_states.get(machine_id) - if state_info: - return state_info['state'].value - return None - - def get_active_sessions(self) -> Dict: - """ - Get all currently active sessions - - Returns dict: machine_id -> session info - """ - active_sessions = {} - - for machine_id, state_info in self.machine_states.items(): - if state_info['state'] == SessionState.RUNNING: - active_sessions[machine_id] = { - 'session_id': state_info['current_session_id'], - 'start_time': state_info['session_start_time'].isoformat().replace('+00:00', 'Z'), - 'current_power': state_info['last_power'] - } - - return active_sessions diff --git a/open_workshop_mqtt/python_prototype/setup.sh b/open_workshop_mqtt/python_prototype/setup.sh deleted file mode 100755 index a02a885..0000000 --- a/open_workshop_mqtt/python_prototype/setup.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/bin/bash -# Setup script for Open Workshop IoT Bridge Python Prototype - -echo "===================================" -echo "Open Workshop IoT Bridge - Setup" -echo "===================================" -echo "" - -# Check if Python 3 is installed -if ! command -v python3 &> /dev/null; then - echo "Error: Python 3 is not installed" - exit 1 -fi - -echo "Python version: $(python3 --version)" -echo "" - -# Create virtual environment -echo "Creating virtual environment..." -python3 -m venv venv - -if [ $? -ne 0 ]; then - echo "Error: Failed to create virtual environment" - exit 1 -fi - -echo "✓ Virtual environment created" -echo "" - -# Activate virtual environment -echo "Activating virtual environment..." -source venv/bin/activate - -# Upgrade pip -echo "Upgrading pip..." -pip install --upgrade pip - -# Install requirements -echo "" -echo "Installing dependencies from requirements.txt..." -pip install -r requirements.txt - -if [ $? -ne 0 ]; then - echo "Error: Failed to install requirements" - exit 1 -fi - -echo "" -echo "✓ Dependencies installed" -echo "" - -# Check if config.yaml exists -if [ ! -f "config.yaml" ]; then - echo "Creating config.yaml from template..." - cp config.yaml.example config.yaml - echo "✓ config.yaml created" - echo "" - echo "⚠️ Please edit config.yaml with your MQTT broker settings!" - echo "" -fi - -# Create data and logs directories -mkdir -p data logs - -echo "===================================" -echo "Setup complete!" -echo "===================================" -echo "" -echo "Next steps:" -echo " 1. Edit config.yaml with your MQTT broker settings" -echo " 2. Activate venv: source venv/bin/activate" -echo " 3. Run the bridge: python main.py" -echo "" diff --git a/open_workshop_mqtt/python_prototype/shelly_parser.py b/open_workshop_mqtt/python_prototype/shelly_parser.py deleted file mode 100644 index bc8b0f9..0000000 --- a/open_workshop_mqtt/python_prototype/shelly_parser.py +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env python3 -""" -Shelly PM Mini G3 Parser -Parst MQTT Messages vom Shelly PM Mini G3 -""" - -import logging -from typing import Dict, Optional -from datetime import datetime - - -class ShellyParser: - """Parser für Shelly PM Mini G3 MQTT Messages""" - - def __init__(self, device_config: list = None): - """ - Initialize parser - - Args: - device_config: List of device configurations from config.yaml - """ - self.logger = logging.getLogger(__name__) - self.device_config = device_config or [] - - # Build topic-prefix to machine mapping - self.prefix_machine_map = {} - for device in self.device_config: - topic_prefix = device.get('topic_prefix') - if topic_prefix: - self.prefix_machine_map[topic_prefix] = { - 'machine_id': device.get('machine_id'), - 'machine_name': device.get('machine_name'), - 'device_id': None # Will be extracted from messages - } - - def parse_message(self, topic: str, payload: dict) -> Optional[Dict]: - """ - Parse Shelly MQTT message - - Args: - topic: MQTT topic - payload: Message payload (already parsed as JSON) - - Returns: - Parsed data dict or None if not a relevant message - """ - try: - # Ignore debug logs - if 'debug/log' in topic: - return None - - # Parse different message types - if '/status/pm1:0' in topic: - return self._parse_status_message(topic, payload) - elif '/events/rpc' in topic: - return self._parse_rpc_event(topic, payload) - elif '/telemetry' in topic: - return self._parse_telemetry(topic, payload) - - return None - - except Exception as e: - self.logger.error(f"Error parsing message from {topic}: {e}", exc_info=True) - return None - - def _parse_status_message(self, topic: str, payload: dict) -> Dict: - """ - Parse full status message - Topic: shaperorigin/status/pm1:0 - - Payload format (Shelly PM Mini G3): - { - "id": 0, - "voltage": 230.0, - "current": 0.217, - "apower": 50.0, - "freq": 50.0, - "aenergy": {"total": 12345.6, "by_minute": [...], "minute_ts": 1234567890}, - "temperature": {"tC": 35.2, "tF": 95.4} - } - """ - try: - # Extract device ID from topic prefix - device_id = self._extract_device_id_from_topic(topic) - - data = { - 'message_type': 'status', - 'device_id': device_id, - 'timestamp': datetime.utcnow().isoformat() + 'Z', - 'voltage': payload.get('voltage'), - 'current': payload.get('current'), - 'apower': payload.get('apower'), # Active Power in Watts - 'frequency': payload.get('freq'), - 'total_energy': payload.get('aenergy', {}).get('total'), - } - - self.logger.debug(f"Parsed status message: apower={data['apower']}W") - return data - - except Exception as e: - self.logger.error(f"Error parsing status message: {e}", exc_info=True) - return None - - def _parse_rpc_event(self, topic: str, payload: dict) -> Optional[Dict]: - """ - Parse RPC NotifyStatus event - Topic: shellypmminig3/events/rpc - """ - try: - if payload.get('method') != 'NotifyStatus': - return None - - device_id = payload.get('src', '').replace('shellypmminig3-', '') - params = payload.get('params', {}) - pm_data = params.get('pm1:0', {}) - - # Get timestamp from params or use current time - ts = params.get('ts') - if ts: - timestamp = datetime.utcfromtimestamp(ts).isoformat() + 'Z' - else: - timestamp = datetime.utcnow().isoformat() + 'Z' - - data = { - 'message_type': 'event', - 'device_id': device_id, - 'timestamp': timestamp, - 'apower': pm_data.get('apower'), - 'current': pm_data.get('current'), - 'voltage': pm_data.get('voltage'), - } - - # Only return if we have actual data - if data['apower'] is not None or data['current'] is not None: - self.logger.debug(f"Parsed RPC event: {pm_data}") - return data - - return None - - except Exception as e: - self.logger.error(f"Error parsing RPC event: {e}", exc_info=True) - return None - - def _parse_telemetry(self, topic: str, payload: dict) -> Dict: - """ - Parse telemetry message - Topic: shelly/pmmini/shellypmminig3-48f6eeb73a1c/telemetry - """ - # Extract device ID from topic - parts = topic.split('/') - device_id = parts[2] if len(parts) > 2 else 'unknown' - device_id = device_id.replace('shellypmminig3-', '') - - # Get timestamp - ts = payload.get('ts') - if ts: - timestamp = datetime.utcfromtimestamp(ts).isoformat() + 'Z' - else: - timestamp = datetime.utcnow().isoformat() + 'Z' - - data = { - 'message_type': 'telemetry', - 'device_id': device_id, - 'timestamp': timestamp, - 'voltage': payload.get('voltage_v'), - 'current': payload.get('current_a'), - 'apower': payload.get('power_w'), # Active Power in Watts - 'frequency': payload.get('freq_hz'), - 'total_energy': payload.get('energy_wh'), - } - - self.logger.debug(f"Parsed telemetry: apower={data['apower']}W") - return data - - def _extract_device_id_from_topic(self, topic: str) -> str: - """Extract device ID (topic prefix) from topic string""" - # Topic format: shaperorigin/status/pm1:0 - # Extract the prefix (shaperorigin) - parts = topic.split('/') - if len(parts) > 0: - topic_prefix = parts[0] - # Check if this prefix is in our config - if topic_prefix in self.prefix_machine_map: - # Use the actual device_id if we've learned it, otherwise use prefix - device_info = self.prefix_machine_map[topic_prefix] - return device_info.get('device_id') or topic_prefix - return topic_prefix - return 'unknown' - - def get_power_value(self, parsed_data: Dict) -> Optional[float]: - """ - Extract power value from parsed data - - Returns: - Power in Watts or None - """ - return parsed_data.get('apower') - - def get_device_id(self, parsed_data: Dict) -> str: - """Get device ID from parsed data""" - return parsed_data.get('device_id', 'unknown') diff --git a/open_workshop_mqtt/python_prototype/tests/__init__.py b/open_workshop_mqtt/python_prototype/tests/__init__.py deleted file mode 100644 index 73e40f0..0000000 --- a/open_workshop_mqtt/python_prototype/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Tests für Open Workshop IoT Bridge -""" diff --git a/open_workshop_mqtt/python_prototype/tests/integration/__init__.py b/open_workshop_mqtt/python_prototype/tests/integration/__init__.py deleted file mode 100644 index 343db61..0000000 --- a/open_workshop_mqtt/python_prototype/tests/integration/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Integration Tests - Testen mit echten externen Services (MQTT Broker, Storage) -""" diff --git a/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py b/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py deleted file mode 100644 index ce61021..0000000 --- a/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py +++ /dev/null @@ -1,550 +0,0 @@ -#!/usr/bin/env python3 -""" -Integration Tests mit echtem MQTT Broker - -Testet die komplette Pipeline: -MQTT → Parser → Normalizer → SessionDetector → Storage - -Usage: - pytest test_mqtt_integration.py -v -s -""" - -import pytest -import subprocess -import time -import json -import signal -import os -from pathlib import Path -from datetime import datetime -import paho.mqtt.client as mqtt_client -import ssl - - -@pytest.fixture(scope="session") -def mqtt_config(): - """MQTT Broker Konfiguration aus Umgebungsvariablen oder config.yaml""" - - # Option 1: Aus Umgebungsvariablen - if os.getenv('MQTT_HOST'): - return { - 'host': os.getenv('MQTT_HOST'), - 'port': int(os.getenv('MQTT_PORT', '8883')), - 'username': os.getenv('MQTT_USERNAME'), - 'password': os.getenv('MQTT_PASSWORD'), - 'topic_prefix': os.getenv('MQTT_TEST_TOPIC_PREFIX', 'pytest-test') - } - - # Option 2: Aus config.yaml lesen - config_path = Path(__file__).parent.parent.parent / 'config.yaml' - if config_path.exists(): - import yaml - with open(config_path) as f: - config = yaml.safe_load(f) - - mqtt_conf = config.get('mqtt', {}) - return { - 'host': mqtt_conf.get('host'), - 'port': mqtt_conf.get('port', 8883), - 'username': mqtt_conf.get('username'), - 'password': mqtt_conf.get('password'), - 'topic_prefix': 'pytest-test' # Immer separates Topic für Tests - } - - pytest.skip("Keine MQTT Konfiguration gefunden. Setze Umgebungsvariablen oder erstelle config.yaml") - - -@pytest.fixture(scope="session") -def workspace_dir(): - """Workspace-Verzeichnis""" - return Path(__file__).parent - - -@pytest.fixture(scope="session") -def test_config_file(workspace_dir, mqtt_config): - """Erstellt temporäre test_config.yaml""" - # Relative Pfade zu den Test Storage Files (relativ zum Projektroot) - config_content = f""" -mqtt: - host: "{mqtt_config['host']}" - port: {mqtt_config['port']} - username: "{mqtt_config['username']}" - password: "{mqtt_config['password']}" - client_id: "ows_iot_bridge_pytest" - keepalive: 60 - topics: - - "{mqtt_config['topic_prefix']}-m1/#" - - "{mqtt_config['topic_prefix']}-m2/#" -devices: - - topic_prefix: "{mqtt_config['topic_prefix']}-m1" - machine_name: "PyTest Machine" - machine_id: "pytest-machine-01" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 20 - working_threshold_w: 100 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - - topic_prefix: "{mqtt_config['topic_prefix']}-m2" - machine_name: "PyTest CNC" - machine_id: "pytest-machine-02" - device_type: "shelly_pm_mini_g3" - standby_threshold_w: 30 - working_threshold_w: 150 - start_debounce_s: 3 - stop_debounce_s: 15 - message_timeout_s: 20 - enabled: true - -logging: - level: "INFO" - console: true - -output: - events_file: "data/test_events.jsonl" - sessions_file: "data/test_sessions.json" -""" - - config_path = workspace_dir / "test_config.yaml" - config_path.write_text(config_content) - - yield config_path - - # Cleanup - if config_path.exists(): - config_path.unlink() - - -@pytest.fixture(scope="session") -def test_storage_files(workspace_dir): - """Gibt Pfade zu Test-Storage-Dateien zurück (werden von Bridge erstellt)""" - # Test-Dateien im Projektroot data/ Verzeichnis - project_root = workspace_dir.parent.parent - data_dir = project_root / "data" - - events_file = data_dir / "test_events.jsonl" - sessions_file = data_dir / "test_sessions.json" - - # NICHT löschen - zum Debugging beibehalten! - - yield { - 'events': events_file, - 'sessions': sessions_file - } - - # Cleanup nach allen Tests (optional auskommentiert) - # if events_file.exists(): - # events_file.unlink() - # if sessions_file.exists(): - # sessions_file.unlink() - - -@pytest.fixture(scope="module") -def bridge_process(workspace_dir, test_config_file, test_storage_files): - """Startet die IoT Bridge als Subprocess""" - - # Bridge starten mit test_config.yaml - # cwd muss das Projektroot sein, nicht tests/integration/ - project_root = workspace_dir.parent.parent - config_path = workspace_dir / 'test_config.yaml' - - env = os.environ.copy() - env['PYTHONUNBUFFERED'] = '1' - - # Use python from venv explicitly - python_exe = project_root / 'venv' / 'bin' / 'python' - bridge_log = workspace_dir / 'bridge_output.log' - - with open(bridge_log, 'w') as log_file: - process = subprocess.Popen( - [str(python_exe), 'main.py', '--config', str(config_path)], - cwd=project_root, - env=env, - stdout=log_file, - stderr=subprocess.STDOUT, - text=True - ) - - # Warten bis Bridge gestartet ist - time.sleep(3) - - # Prüfen ob Prozess läuft - if process.poll() is not None: - with open(bridge_log, 'r') as f: - output = f.read() - pytest.fail(f"Bridge konnte nicht gestartet werden:\n{output}") - - # Debug: Bridge Output ausgeben - with open(bridge_log, 'r') as f: - output = f.read() - if output: - print(f"\n=== Bridge Output ===\n{output}\n===\n") - - yield process - - # Bridge sauber beenden - process.send_signal(signal.SIGTERM) - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - - -@pytest.fixture -def mqtt_sender(mqtt_config): - """MQTT Client zum Senden von Test-Messages""" - - client_id = f"pytest-sender-{int(time.time())}" - client = mqtt_client.Client(client_id=client_id, protocol=mqtt_client.MQTTv5) - client.username_pw_set(mqtt_config['username'], mqtt_config['password']) - - # TLS/SSL - client.tls_set(cert_reqs=ssl.CERT_NONE) - client.tls_insecure_set(True) - - client.connect(mqtt_config['host'], mqtt_config['port'], keepalive=60) - client.loop_start() - time.sleep(1) - - yield client - - client.loop_stop() - client.disconnect() - - -def send_shelly_message(mqtt_sender, topic: str, power_w: float): - """Sendet eine Shelly PM Mini G3 Status Message""" - message = { - "id": 0, - "voltage": 230.0, - "current": round(power_w / 230.0, 3), - "apower": power_w, - "freq": 50.0, - "aenergy": { - "total": 12345.6, - "by_minute": [0.0, 0.0, 0.0], - "minute_ts": int(time.time()) - }, - "temperature": { - "tC": 35.2, - "tF": 95.4 - } - } - - mqtt_sender.publish(topic, json.dumps(message), qos=1) - time.sleep(0.1) # Kurze Pause zwischen Messages - - -def read_sessions(sessions_file: Path): - """Liest sessions.json""" - if not sessions_file.exists(): - return [] - - content = sessions_file.read_text().strip() - if not content or content == "[]": - return [] - - return json.loads(content) - - -def wait_for_session_count(sessions_file: Path, expected_count: int, timeout: int = 10): - """Wartet bis die erwartete Anzahl Sessions vorhanden ist""" - start_time = time.time() - - while time.time() - start_time < timeout: - sessions = read_sessions(sessions_file) - if len(sessions) >= expected_count: - return sessions - time.sleep(0.5) - - sessions = read_sessions(sessions_file) - pytest.fail( - f"Timeout: Erwartete {expected_count} Sessions, gefunden: {len(sessions)}\n" - f"Sessions: {json.dumps(sessions, indent=2)}" - ) - - -class TestMQTTIntegration: - """Integration Tests mit echtem MQTT""" - - def test_bridge_is_running(self, bridge_process): - """Bridge läuft erfolgreich""" - assert bridge_process.poll() is None, "Bridge Prozess ist abgestürzt" - - def test_session_start_standby(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Session Start → STANDBY""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Anzahl Sessions vor dem Test - initial_count = len(read_sessions(sessions_file)) - - # Power ansteigen lassen - wie reale Maschine - send_shelly_message(mqtt_sender, topic, 0) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 30) # STARTING - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) # Nach 3s → STANDBY - - # Warten auf Session-Start (3s debounce + Verarbeitung) - time.sleep(3) - - # Session prüfen - sessions = wait_for_session_count(sessions_file, initial_count + 1, timeout=5) - - # Neueste Session - latest = sessions[-1] - assert latest['machine_id'] == 'pytest-machine-01' - assert latest['status'] == 'running' - assert latest['start_power_w'] >= 20 - - def test_session_end_with_stop_debounce(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Session Ende mit stop_debounce (15s unter 20W)""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - # Session starten - send_shelly_message(mqtt_sender, topic, 0) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) # Session sollte jetzt laufen - - # STANDBY für 3 Sekunden - for _ in range(3): - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - - # Power runterfahren → STOPPING - send_shelly_message(mqtt_sender, topic, 15) - - # STOPPING für 16 Sekunden (> 15s stop_debounce) - for i in range(16): - send_shelly_message(mqtt_sender, topic, 10 - i * 0.5) - time.sleep(1) - - # Warten auf Session-Ende - time.sleep(2) - - # Prüfen ob Session beendet wurde - sessions = read_sessions(sessions_file) - - # Finde die Session die gerade beendet wurde - completed_sessions = [s for s in sessions if s.get('status') == 'completed' and s.get('end_reason') == 'power_drop'] - - assert len(completed_sessions) > 0, f"Keine completed Session mit power_drop gefunden. Sessions: {json.dumps(sessions[-3:], indent=2)}" - - latest = completed_sessions[-1] - assert latest['end_reason'] == 'power_drop' - assert latest['total_duration_s'] is not None - assert latest['standby_duration_s'] is not None - - def test_standby_to_working_transition(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """STANDBY → WORKING Transition""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Session im STANDBY starten - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) - - # STANDBY halten - for _ in range(3): - send_shelly_message(mqtt_sender, topic, 60) - time.sleep(1) - - # → WORKING - send_shelly_message(mqtt_sender, topic, 120) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 150) - - # WORKING halten - for _ in range(5): - send_shelly_message(mqtt_sender, topic, 150) - time.sleep(1) - - # Session beenden - send_shelly_message(mqtt_sender, topic, 10) - for _ in range(16): - send_shelly_message(mqtt_sender, topic, 5) - time.sleep(1) - - time.sleep(2) - - # Prüfen - sessions = read_sessions(sessions_file) - completed = [s for s in sessions if s.get('status') == 'completed'] - - assert len(completed) > 0 - latest = completed[-1] - - # Sollte sowohl STANDBY als auch WORKING Zeit haben - assert latest.get('standby_duration_s', 0) > 0, "Keine STANDBY Zeit" - assert latest.get('working_duration_s', 0) > 0, "Keine WORKING Zeit" - - def test_timeout_detection(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Timeout Detection (20s keine Messages)""" - topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - # Session starten - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(1) - send_shelly_message(mqtt_sender, topic, 50) - time.sleep(2) - - # Letzte Message - send_shelly_message(mqtt_sender, topic, 50) - - # 25 Sekunden warten (> 20s timeout) - print("\n⏱️ Warte 25 Sekunden für Timeout Detection...") - time.sleep(25) - - # Prüfen ob Session mit timeout beendet wurde - sessions = read_sessions(sessions_file) - timeout_sessions = [s for s in sessions if s.get('end_reason') == 'timeout'] - - assert len(timeout_sessions) > 0, f"Keine timeout Session gefunden. Sessions: {json.dumps(sessions[-3:], indent=2)}" - - def test_multi_device_parallel_sessions(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Zwei Maschinen starten parallel Sessions""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - initial_count = len(read_sessions(sessions_file)) - - print("\n🔄 Starte parallele Sessions auf 2 Maschinen...") - - # Maschine 1: Session starten (20W threshold) - send_shelly_message(mqtt_sender, topic_machine1, 50) - time.sleep(0.5) - - # Maschine 2: Session starten (30W threshold) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(0.5) - - # Beide Maschinen halten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine1, 60) - send_shelly_message(mqtt_sender, topic_machine2, 100) - time.sleep(1) - - # Warten auf Sessions - time.sleep(3) - - # Beide Maschinen sollten jetzt laufende Sessions haben - sessions = read_sessions(sessions_file) - new_sessions = [s for s in sessions if len(sessions) > initial_count][-2:] # Letzte 2 neue Sessions - running_sessions = [s for s in new_sessions if s.get('status') == 'running'] - - assert len(running_sessions) == 2, f"Erwartet exakt 2 neue laufende Sessions, gefunden: {len(running_sessions)}" - - # Prüfen dass beide machine_ids vorhanden sind - machine_ids = [s['machine_id'] for s in running_sessions] - assert 'pytest-machine-01' in machine_ids, "Maschine 1 Session fehlt" - assert 'pytest-machine-02' in machine_ids, "Maschine 2 Session fehlt" - - print(f"✅ 2 parallele Sessions erfolgreich: {machine_ids}") - - def test_multi_device_independent_timeouts(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Maschine 1 Timeout, Maschine 2 läuft weiter""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - print("\n⏱️ Teste unabhängige Timeouts...") - - # Beide Maschinen starten - send_shelly_message(mqtt_sender, topic_machine1, 50) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - for _ in range(3): - send_shelly_message(mqtt_sender, topic_machine1, 50) - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - time.sleep(2) - - # Maschine 1: Letzte Message, dann Timeout - send_shelly_message(mqtt_sender, topic_machine1, 50) - - # Maschine 2: Läuft weiter - for _ in range(26): # 26 Sekunden - send_shelly_message(mqtt_sender, topic_machine2, 80) - time.sleep(1) - - # Prüfen - sessions = read_sessions(sessions_file) - - # Maschine 1 sollte timeout haben - m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01'] - timeout_session = [s for s in m1_sessions if s.get('end_reason') == 'timeout'] - assert len(timeout_session) > 0, "Maschine 1 sollte Timeout-Session haben" - - # Maschine 2 sollte noch laufen - m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02'] - running_session = [s for s in m2_sessions if s.get('status') == 'running'] - assert len(running_session) > 0, "Maschine 2 sollte noch laufen" - - print("✅ Unabhängige Timeouts funktionieren") - - def test_multi_device_different_thresholds(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): - """Unterschiedliche Power-Thresholds pro Maschine""" - topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" - topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" - sessions_file = test_storage_files['sessions'] - - # Cleanup: Alte Sessions löschen für sauberen Test - if sessions_file.exists(): - sessions_file.unlink() - - print("\n🔧 Teste unterschiedliche Thresholds...") - - # Maschine 1: 25W (über 20W threshold) → sollte starten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine1, 25) - time.sleep(1) - - time.sleep(3) - - # Maschine 2: 25W (unter 30W threshold) → sollte NICHT starten - for _ in range(4): - send_shelly_message(mqtt_sender, topic_machine2, 25) - time.sleep(1) - - time.sleep(3) - - sessions = read_sessions(sessions_file) - - # Maschine 1 sollte Session haben - m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01' and s.get('status') == 'running'] - assert len(m1_sessions) > 0, "Maschine 1 sollte bei 25W Session haben (threshold 20W)" - - # Maschine 2 sollte KEINE Session haben - m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02' and s.get('status') == 'running'] - assert len(m2_sessions) == 0, "Maschine 2 sollte bei 25W KEINE Session haben (threshold 30W)" - - print("✅ Unterschiedliche Thresholds funktionieren") - - -if __name__ == '__main__': - pytest.main([__file__, '-v', '-s']) diff --git a/open_workshop_mqtt/python_prototype/tests/tools/__init__.py b/open_workshop_mqtt/python_prototype/tests/tools/__init__.py deleted file mode 100644 index ccc6070..0000000 --- a/open_workshop_mqtt/python_prototype/tests/tools/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Test Tools - Hilfsprogramme für manuelle Tests und Debugging -""" diff --git a/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py b/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py deleted file mode 100755 index 50fdf15..0000000 --- a/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -Test MQTT Client - Simuliert Shelly PM Mini G3 Verhalten - -Sendet MQTT Messages auf das Topic 'shaperorigin/status/pm1:0' -mit verschiedenen Test-Szenarien für die Session Detection. - -Usage: - python test_shelly_simulator.py --scenario standby - python test_shelly_simulator.py --scenario working - python test_shelly_simulator.py --scenario session_end - python test_shelly_simulator.py --scenario full_session -""" - -import argparse -import json -import os -import sys -import time -import ssl -from datetime import datetime -from pathlib import Path -from paho.mqtt import client as mqtt_client -import yaml - - -class ShellySimulator: - """Simuliert Shelly PM Mini G3 MQTT Messages""" - - def __init__(self, broker_host: str, broker_port: int, username: str, password: str, topic_prefix: str = "testshelly"): - self.broker_host = broker_host - self.broker_port = broker_port - self.username = username - self.password = password - self.topic_prefix = topic_prefix - self.topic = f"{topic_prefix}/status/pm1:0" - self.client = None - - def connect(self): - """Verbindung zum MQTT Broker herstellen""" - client_id = f"shelly-simulator-{int(time.time())}" - self.client = mqtt_client.Client(client_id=client_id, protocol=mqtt_client.MQTTv5) - self.client.username_pw_set(self.username, self.password) - - # TLS/SSL - self.client.tls_set(cert_reqs=ssl.CERT_NONE) - self.client.tls_insecure_set(True) - - print(f"Verbinde zu MQTT Broker {self.broker_host}:{self.broker_port}...") - self.client.connect(self.broker_host, self.broker_port, keepalive=60) - self.client.loop_start() - time.sleep(1) - print("✓ Verbunden") - - def disconnect(self): - """Verbindung trennen""" - if self.client: - self.client.loop_stop() - self.client.disconnect() - print("✓ Verbindung getrennt") - - def send_power_message(self, power_w: float, interval_s: int = 1): - """ - Sendet eine Shelly Status Message im echten Shelly PM Mini G3 Format - - Args: - power_w: Leistung in Watt - interval_s: Pause nach dem Senden in Sekunden - """ - # Echtes Shelly PM Mini G3 Format (ohne result wrapper) - message = { - "id": 0, - "voltage": 230.0, - "current": round(power_w / 230.0, 3), - "apower": power_w, - "freq": 50.0, - "aenergy": { - "total": round(12345.6 + (power_w * interval_s / 3600), 1), - "by_minute": [0.0, 0.0, 0.0], - "minute_ts": int(time.time()) - }, - "temperature": { - "tC": 35.2, - "tF": 95.4 - } - } - - payload = json.dumps(message) - self.client.publish(self.topic, payload, qos=1) - - timestamp = datetime.now().strftime("%H:%M:%S") - print(f"[{timestamp}] 📤 Gesendet: {power_w:.1f}W") - - if interval_s > 0: - time.sleep(interval_s) - - def scenario_standby(self): - """Szenario: Maschine im STANDBY (20-100W)""" - print("\n=== SZENARIO: STANDBY (20-100W) ===") - print("Simuliert: Maschine an, Spindel aus\n") - - # Anfahren - print("Phase 1: Anfahren (0 → 50W)") - self.send_power_message(0, 1) - self.send_power_message(10, 1) - self.send_power_message(30, 1) # > 20W → STARTING - self.send_power_message(45, 1) - self.send_power_message(50, 2) # Nach 3s debounce → STANDBY - - # STANDBY halten - print("\nPhase 2: STANDBY halten (50W für 20s)") - for i in range(20): - self.send_power_message(50 + (i % 5) * 2, 1) # 50-58W variieren - - print("\n✓ Szenario abgeschlossen") - - def scenario_working(self): - """Szenario: Maschine WORKING (>=100W)""" - print("\n=== SZENARIO: WORKING (>=100W) ===") - print("Simuliert: Spindel läuft\n") - - # Anfahren direkt zu WORKING - print("Phase 1: Anfahren (0 → 150W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) # > 20W → STARTING - self.send_power_message(80, 1) - self.send_power_message(120, 1) - self.send_power_message(150, 2) # Nach 3s debounce → WORKING - - # WORKING halten - print("\nPhase 2: WORKING halten (150W für 20s)") - for i in range(20): - self.send_power_message(150 + (i % 10) * 5, 1) # 150-195W variieren - - print("\n✓ Szenario abgeschlossen") - - def scenario_session_end(self): - """Szenario: Session sauber beenden mit stop_debounce""" - print("\n=== SZENARIO: SESSION END (mit stop_debounce 15s) ===") - print("Simuliert: Maschine einschalten → arbeiten → ausschalten\n") - - # Start: STANDBY - print("Phase 1: Session Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) # STARTING - self.send_power_message(50, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # Nach 3s → STANDBY - - # STANDBY 10s - print("\nPhase 2: STANDBY halten (50W für 10s)") - for _ in range(10): - self.send_power_message(50, 1) - - # Runterfahren - print("\nPhase 3: Herunterfahren (50W → 0W)") - self.send_power_message(40, 1) - self.send_power_message(25, 1) - self.send_power_message(15, 1) # < 20W → STOPPING - - # STOPPING für 15 Sekunden (stop_debounce) - print("\nPhase 4: STOPPING (< 20W für 15s = stop_debounce)") - for i in range(16): - self.send_power_message(10 - i * 0.6, 1) # 10W → ~0W über 15s - print(f" STOPPING seit {i+1}s / 15s") - - print("\n✓ Session sollte jetzt beendet sein (nach stop_debounce)") - print(" Erwartung: Session END mit end_reason='power_drop'") - - def scenario_full_session(self): - """Szenario: Komplette Session mit STANDBY→WORKING→STANDBY→END""" - print("\n=== SZENARIO: FULL SESSION ===") - print("Simuliert: Start → STANDBY → WORKING → STANDBY → END\n") - - # Start → STANDBY - print("Phase 1: Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # → STANDBY - - # STANDBY 5s - print("\nPhase 2: STANDBY (50W für 5s)") - for _ in range(5): - self.send_power_message(55, 1) - - # STANDBY → WORKING - print("\nPhase 3: STANDBY → WORKING (50W → 150W)") - self.send_power_message(70, 1) - self.send_power_message(90, 1) - self.send_power_message(110, 1) # >= 100W → WORKING - self.send_power_message(130, 1) - self.send_power_message(150, 1) - - # WORKING 10s - print("\nPhase 4: WORKING (150W für 10s)") - for i in range(10): - self.send_power_message(150 + (i % 5) * 10, 1) - - # WORKING → STANDBY - print("\nPhase 5: WORKING → STANDBY (150W → 60W)") - self.send_power_message(120, 1) - self.send_power_message(90, 1) # < 100W → STANDBY - self.send_power_message(70, 1) - self.send_power_message(60, 1) - - # STANDBY 5s - print("\nPhase 6: STANDBY (60W für 5s)") - for _ in range(5): - self.send_power_message(60, 1) - - # Session END - print("\nPhase 7: Session END (60W → 0W mit stop_debounce)") - self.send_power_message(40, 1) - self.send_power_message(20, 1) - self.send_power_message(10, 1) # < 20W → STOPPING - - print("\nPhase 8: STOPPING (15s debounce)") - for i in range(16): - self.send_power_message(5 - i * 0.3, 1) - print(f" STOPPING seit {i+1}s / 15s") - - print("\n✓ Full Session abgeschlossen") - print(" Erwartung:") - print(" - Session START") - print(" - STANDBY → WORKING → STANDBY Transitionen") - print(" - Session END nach stop_debounce") - print(" - standby_duration_s und working_duration_s getrackt") - - def scenario_timeout(self): - """Szenario: Timeout Detection (keine Messages für 20s)""" - print("\n=== SZENARIO: TIMEOUT (message_timeout_s = 20s) ===") - print("Simuliert: Maschine läuft → plötzlicher Stromausfall (keine Messages mehr)\n") - - # Start → STANDBY - print("Phase 1: Session Start → STANDBY (50W)") - self.send_power_message(0, 1) - self.send_power_message(30, 1) - self.send_power_message(50, 1) - self.send_power_message(50, 2) # → STANDBY - - # STANDBY 10s - print("\nPhase 2: STANDBY (50W für 10s)") - for _ in range(10): - self.send_power_message(50, 1) - - # Keine Messages mehr (Stromausfall simulieren) - print("\n⚡ STROMAUSFALL: Keine Messages mehr für 25 Sekunden") - print(" (message_timeout_s = 20s)") - for i in range(25): - time.sleep(1) - print(f" Keine Messages seit {i+1}s / 20s", end="\r") - - print("\n\n✓ Timeout Szenario abgeschlossen") - print(" Erwartung: Session END nach 20s mit end_reason='timeout'") - - -def main(): - parser = argparse.ArgumentParser(description="Shelly PM Mini G3 MQTT Simulator") - parser.add_argument( - "--scenario", - choices=["standby", "working", "session_end", "full_session", "timeout", "loop"], - required=True, - help="Test-Szenario" - ) - parser.add_argument("--host", default=None, help="MQTT Broker Host (default: from env/config.yaml)") - parser.add_argument("--port", type=int, default=None, help="MQTT Broker Port (default: from env/config.yaml)") - parser.add_argument("--username", default=None, help="MQTT Username (default: from env/config.yaml)") - parser.add_argument("--password", default=None, help="MQTT Password (default: from env/config.yaml)") - parser.add_argument("--topic-prefix", default="testshelly", help="MQTT Topic Prefix (default: testshelly)") - - args = parser.parse_args() - - # Load credentials from environment or config.yaml if not provided via CLI - mqtt_config = {} - - if not all([args.host, args.port, args.username, args.password]): - # Try environment variables first - mqtt_config = { - 'host': os.getenv('MQTT_HOST'), - 'port': int(os.getenv('MQTT_PORT', 0)) if os.getenv('MQTT_PORT') else None, - 'username': os.getenv('MQTT_USERNAME'), - 'password': os.getenv('MQTT_PASSWORD') - } - - # If not all available, try config.yaml - if not all(mqtt_config.values()): - config_path = Path(__file__).parent.parent.parent / "config.yaml" - print(f"Lade MQTT Credentials aus {config_path}...") - if config_path.exists(): - try: - with open(config_path, 'r') as f: - config = yaml.safe_load(f) - mqtt_section = config.get('mqtt', {}) - mqtt_config = { - 'host': mqtt_section.get('host'), - 'port': mqtt_section.get('port'), - 'username': mqtt_section.get('username'), - 'password': mqtt_section.get('password') - } - except Exception as e: - print(f"⚠️ Warnung: Fehler beim Lesen von config.yaml: {e}") - - # Use CLI args or fallback to loaded config - host = args.host or mqtt_config.get('host') - port = args.port or mqtt_config.get('port') - username = args.username or mqtt_config.get('username') - password = args.password or mqtt_config.get('password') - - # Validate that we have all required credentials - if not all([host, port, username, password]): - print("❌ Fehler: MQTT Credentials fehlen!") - print("") - print("Bitte setze die Credentials über:") - print(" 1) Kommandozeilen-Parameter:") - print(" --host --port --username --password ") - print("") - print(" 2) Umgebungsvariablen:") - print(" export MQTT_HOST=mqtt.majufilo.eu") - print(" export MQTT_PORT=8883") - print(" export MQTT_USERNAME=mosquitto") - print(" export MQTT_PASSWORD=xxx") - print("") - print(" 3) config.yaml im Projektroot") - print("") - sys.exit(1) - - print("=" * 60) - print("SHELLY PM MINI G3 SIMULATOR") - print("=" * 60) - - simulator = ShellySimulator( - broker_host=host, - broker_port=port, - username=username, - password=password, - topic_prefix=args.topic_prefix - ) - - try: - simulator.connect() - - # Szenario ausführen - if args.scenario == "standby": - simulator.scenario_standby() - elif args.scenario == "working": - simulator.scenario_working() - elif args.scenario == "session_end": - simulator.scenario_session_end() - elif args.scenario == "full_session": - simulator.scenario_full_session() - elif args.scenario == "timeout": - simulator.scenario_timeout() - elif args.scenario == "loop": - print("Starte Endlosschleife (STRG+C zum Beenden)...") - while True: - simulator.scenario_full_session() - else: - print(f"❌ Unbekanntes Szenario: {args.scenario}") - exit(1) - - print("\n" + "=" * 60) - print("Test abgeschlossen. Prüfe sessions.json für Ergebnisse.") - print("=" * 60) - - except KeyboardInterrupt: - print("\n\n⚠️ Abgebrochen") - except Exception as e: - print(f"\n❌ Fehler: {e}") - finally: - simulator.disconnect() - - -if __name__ == "__main__": - main() diff --git a/open_workshop_mqtt/python_prototype/tests/unit/__init__.py b/open_workshop_mqtt/python_prototype/tests/unit/__init__.py deleted file mode 100644 index 8ff464d..0000000 --- a/open_workshop_mqtt/python_prototype/tests/unit/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unit Tests - Testen einzelne Komponenten isoliert ohne externe Dependencies -""" diff --git a/open_workshop_mqtt/python_prototype/tests/unit/test_session_detector.py b/open_workshop_mqtt/python_prototype/tests/unit/test_session_detector.py deleted file mode 100644 index af7ef5b..0000000 --- a/open_workshop_mqtt/python_prototype/tests/unit/test_session_detector.py +++ /dev/null @@ -1,396 +0,0 @@ -#!/usr/bin/env python3 -""" -Unit Tests für SessionDetector - -Testet die Dual-Threshold Session Detection ohne MQTT Broker. -Direktes Testen der Logik mit simulierten Events. - -Usage: - pytest test_session_detector.py -v - pytest test_session_detector.py::test_standby_to_working -v -""" - -import pytest -from datetime import datetime, timezone, timedelta -from session_detector import SessionDetector, SessionState - - -@pytest.fixture -def detector(): - """SessionDetector mit Test-Konfiguration""" - config = [ - { - 'machine_id': 'test-machine-01', - 'machine_name': 'Test Machine', - 'standby_threshold_w': 20, - 'working_threshold_w': 100, - 'start_debounce_s': 3, - 'stop_debounce_s': 15, - 'message_timeout_s': 20, - } - ] - return SessionDetector(device_config=config) - - -def create_event(power_w: float, machine_id: str = 'test-machine-01', timestamp: datetime = None): - """Helper: Erstellt ein normalisiertes Event""" - if timestamp is None: - timestamp = datetime.now(timezone.utc) - - return { - 'event_id': 'test-event', - 'event_type': 'power_measurement', - 'timestamp': timestamp.isoformat().replace('+00:00', 'Z'), - 'machine': { - 'machine_id': machine_id, - 'machine_name': 'Test Machine' - }, - 'metrics': { - 'power_w': power_w - } - } - - -class TestSessionStart: - """Tests für Session-Start mit start_debounce""" - - def test_idle_to_starting(self, detector): - """Power >= 20W → STARTING""" - event = create_event(power_w=30) - result = detector.process_event(event) - - assert result is None # Noch kein Session-Start - assert detector.get_machine_state('test-machine-01') == 'starting' - - def test_starting_to_standby(self, detector): - """Nach 3s debounce mit 20-100W → STANDBY""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - result1 = detector.process_event(event1) - assert result1 is None - - # T+1: Power 50W → noch STARTING - event2 = create_event(power_w=50, timestamp=start_time + timedelta(seconds=1)) - result2 = detector.process_event(event2) - assert result2 is None - - # T+3: Power 50W → STANDBY (debounce passed) - event3 = create_event(power_w=50, timestamp=start_time + timedelta(seconds=3)) - result3 = detector.process_event(event3) - - assert result3 is not None - assert result3['event_type'] == 'session_start' - assert result3['session_data']['start_time'] is not None - assert detector.get_machine_state('test-machine-01') == 'standby' - - def test_starting_to_working(self, detector): - """Nach 3s debounce mit >=100W → WORKING""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - detector.process_event(event1) - - # T+1: Power 120W → noch STARTING - event2 = create_event(power_w=120, timestamp=start_time + timedelta(seconds=1)) - detector.process_event(event2) - - # T+3: Power 150W → WORKING (debounce passed) - event3 = create_event(power_w=150, timestamp=start_time + timedelta(seconds=3)) - result3 = detector.process_event(event3) - - assert result3 is not None - assert result3['event_type'] == 'session_start' - assert detector.get_machine_state('test-machine-01') == 'working' - - def test_false_start(self, detector): - """Power fällt vor debounce → zurück zu IDLE""" - start_time = datetime.now(timezone.utc) - - # T+0: Power 30W → STARTING - event1 = create_event(power_w=30, timestamp=start_time) - detector.process_event(event1) - - # T+1: Power 10W → zurück zu IDLE (false start) - event2 = create_event(power_w=10, timestamp=start_time + timedelta(seconds=1)) - result2 = detector.process_event(event2) - - assert result2 is None - assert detector.get_machine_state('test-machine-01') == 'idle' - - -class TestStateTransitions: - """Tests für Zustandsübergänge während Session""" - - def test_standby_to_working(self, detector): - """STANDBY → WORKING bei Power >= 100W""" - start_time = datetime.now(timezone.utc) - - # Session starten im STANDBY - detector.process_event(create_event(power_w=30, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - assert detector.get_machine_state('test-machine-01') == 'standby' - - # Power auf 150W → WORKING - event = create_event(power_w=150, timestamp=start_time + timedelta(seconds=10)) - result = detector.process_event(event) - - assert result is None # Keine session_end/start, nur Transition - assert detector.get_machine_state('test-machine-01') == 'working' - - def test_working_to_standby(self, detector): - """WORKING → STANDBY bei Power < 100W""" - start_time = datetime.now(timezone.utc) - - # Session starten im WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - assert detector.get_machine_state('test-machine-01') == 'working' - - # Power auf 60W → STANDBY - event = create_event(power_w=60, timestamp=start_time + timedelta(seconds=10)) - result = detector.process_event(event) - - assert result is None - assert detector.get_machine_state('test-machine-01') == 'standby' - - -class TestSessionEnd: - """Tests für Session-Ende mit stop_debounce""" - - def test_session_end_from_standby(self, detector): - """STANDBY → STOPPING → IDLE nach 15s < 20W""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY für 10s - for i in range(10): - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i))) - - # Power fällt unter 20W → STOPPING - t_stopping = start_time + timedelta(seconds=13) - event_stopping = create_event(power_w=10, timestamp=t_stopping) - result_stopping = detector.process_event(event_stopping) - - assert result_stopping is None - assert detector.get_machine_state('test-machine-01') == 'stopping' - - # STOPPING für 14s → noch keine Session-End - for i in range(14): - result = detector.process_event( - create_event(power_w=10, timestamp=t_stopping + timedelta(seconds=i + 1)) - ) - assert result is None # Noch kein Session-Ende - - # T+15: stop_debounce passed → SESSION END - event_end = create_event(power_w=5, timestamp=t_stopping + timedelta(seconds=15)) - result_end = detector.process_event(event_end) - - assert result_end is not None - assert result_end['event_type'] == 'session_end' - assert result_end['session_data']['end_reason'] == 'power_drop' - assert detector.get_machine_state('test-machine-01') == 'idle' - - def test_stopping_canceled_power_back_up(self, detector): - """STOPPING abgebrochen wenn Power wieder >= 20W""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # Power fällt → STOPPING - t_stopping = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=10, timestamp=t_stopping)) - assert detector.get_machine_state('test-machine-01') == 'stopping' - - # Nach 5s: Power wieder hoch → zurück zu STANDBY - event_back = create_event(power_w=60, timestamp=t_stopping + timedelta(seconds=5)) - result = detector.process_event(event_back) - - assert result is None # Kein Session-Ende - assert detector.get_machine_state('test-machine-01') == 'standby' - - -class TestDurationTracking: - """Tests für Zeiterfassung in STANDBY und WORKING""" - - def test_standby_duration_tracked(self, detector): - """standby_duration_s wird korrekt akkumuliert""" - start_time = datetime.now(timezone.utc) - - # Session Start → STANDBY - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY für 10 Sekunden - for i in range(10): - detector.process_event( - create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # Session beenden - t_end = start_time + timedelta(seconds=20) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden (stop_debounce) - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None # Noch kein Session-Ende - - # T+15: stop_debounce passed → SESSION END - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - assert result['session_data']['standby_duration_s'] >= 9 # ~10s STANDBY - assert result['session_data']['working_duration_s'] == 0 # Kein WORKING - - def test_working_duration_tracked(self, detector): - """working_duration_s wird korrekt akkumuliert""" - start_time = datetime.now(timezone.utc) - - # Session Start → WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - - # WORKING für 10 Sekunden - for i in range(10): - detector.process_event( - create_event(power_w=150, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # Session beenden - t_end = start_time + timedelta(seconds=20) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None - - # T+15: stop_debounce passed - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - assert result['session_data']['working_duration_s'] >= 9 # ~10s WORKING - assert result['session_data']['standby_duration_s'] == 0 # Kein STANDBY - - def test_mixed_standby_working_duration(self, detector): - """STANDBY und WORKING Zeiten separat getrackt""" - start_time = datetime.now(timezone.utc) - - # Start → STANDBY - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # STANDBY 5s - for i in range(5): - detector.process_event( - create_event(power_w=50, timestamp=start_time + timedelta(seconds=3 + i)) - ) - - # → WORKING - t_working = start_time + timedelta(seconds=8) - detector.process_event(create_event(power_w=150, timestamp=t_working)) - - # WORKING 5s - for i in range(5): - detector.process_event( - create_event(power_w=150, timestamp=t_working + timedelta(seconds=i + 1)) - ) - - # → STANDBY - t_standby2 = t_working + timedelta(seconds=6) - detector.process_event(create_event(power_w=60, timestamp=t_standby2)) - - # STANDBY 5s - for i in range(5): - detector.process_event( - create_event(power_w=60, timestamp=t_standby2 + timedelta(seconds=i + 1)) - ) - - # Session beenden - t_end = t_standby2 + timedelta(seconds=6) - detector.process_event(create_event(power_w=10, timestamp=t_end)) - - # STOPPING für genau 15 Sekunden - for i in range(14): - result = detector.process_event( - create_event(power_w=5, timestamp=t_end + timedelta(seconds=i + 1)) - ) - assert result is None - - # T+15: stop_debounce passed - result = detector.process_event( - create_event(power_w=0, timestamp=t_end + timedelta(seconds=15)) - ) - - assert result is not None - # ~10s STANDBY (5s + 5s) - assert result['session_data']['standby_duration_s'] >= 9 - assert result['session_data']['standby_duration_s'] <= 12 - # ~5s WORKING - assert result['session_data']['working_duration_s'] >= 4 - assert result['session_data']['working_duration_s'] <= 7 - - -class TestTimeoutDetection: - """Tests für Timeout-basierte Session-Beendigung""" - - def test_timeout_from_standby(self, detector): - """Timeout nach 20s ohne Messages im STANDBY""" - start_time = datetime.now(timezone.utc) - - # Session starten - detector.process_event(create_event(power_w=50, timestamp=start_time)) - detector.process_event(create_event(power_w=50, timestamp=start_time + timedelta(seconds=3))) - - # Letzte Message - last_msg = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=50, timestamp=last_msg)) - - # 21 Sekunden später → TIMEOUT - timeout_event = create_event(power_w=50, timestamp=last_msg + timedelta(seconds=21)) - result = detector.process_event(timeout_event) - - assert result is not None - assert result['event_type'] == 'session_end' - assert result['session_data']['end_reason'] == 'timeout' - assert result['power_w'] == 0.0 # Bei Timeout wird 0W angenommen - - def test_timeout_from_working(self, detector): - """Timeout nach 20s ohne Messages im WORKING""" - start_time = datetime.now(timezone.utc) - - # Session starten im WORKING - detector.process_event(create_event(power_w=150, timestamp=start_time)) - detector.process_event(create_event(power_w=150, timestamp=start_time + timedelta(seconds=3))) - - # Letzte Message - last_msg = start_time + timedelta(seconds=10) - detector.process_event(create_event(power_w=150, timestamp=last_msg)) - - # 21 Sekunden später → TIMEOUT - timeout_event = create_event(power_w=150, timestamp=last_msg + timedelta(seconds=21)) - result = detector.process_event(timeout_event) - - assert result is not None - assert result['event_type'] == 'session_end' - assert result['session_data']['end_reason'] == 'timeout' - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/open_workshop_mqtt/run-tests.sh b/open_workshop_mqtt/run-tests.sh deleted file mode 100755 index 3287efe..0000000 --- a/open_workshop_mqtt/run-tests.sh +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/bash -# Odoo Module Tests - OWS_MQTT Database -# Container: hobbyhimmel_odoo_18-dev, DB: hobbyhimmel_odoo_18_db -set -e - -MODULE="open_workshop_mqtt" -DB_NAME="OWS_MQTT" -TIMEOUT=120 -DB_CONTAINER="hobbyhimmel_odoo_18-dev_db" -ODOO_CONTAINER="hobbyhimmel_odoo_18-dev" - -# Log im Projektverzeichnis -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOG_FILE="${SCRIPT_DIR}/test_$(date +%Y%m%d_%H%M%S).log" - -echo "=== Checking containers ===" -if ! docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then - echo "✗ DB Container ${DB_CONTAINER} not running!" - exit 1 -fi - -if ! docker ps --format '{{.Names}}' | grep -q "^${ODOO_CONTAINER}$"; then - echo "✗ Odoo Container ${ODOO_CONTAINER} not running!" - exit 1 -fi - -echo "✓ Containers running: ${ODOO_CONTAINER} + ${DB_CONTAINER}" - -echo "=== Waiting for database ===" -until docker exec "${DB_CONTAINER}" pg_isready -U odoo > /dev/null 2>&1; do - echo "Waiting for PostgreSQL..." - sleep 2 -done -echo "✓ PostgreSQL ready" - -echo "=== Running tests on ${DB_NAME} (timeout: ${TIMEOUT}s) ===" -echo "(Output logged to: $LOG_FILE)" -timeout "$TIMEOUT" docker exec "${ODOO_CONTAINER}" \ - odoo \ - -d "${DB_NAME}" \ - --test-enable \ - --stop-after-init \ - -u "$MODULE" \ - --log-level=test \ - --http-port=0 \ - > "$LOG_FILE" 2>&1 - -EXIT_CODE=$? - -# Handle timeout -if [ $EXIT_CODE -eq 124 ]; then - echo "" - echo "✗ TIMEOUT after ${TIMEOUT}s" - docker kill "${ODOO_CONTAINER}" 2>/dev/null || true - EXIT_CODE=1 -fi - -# Show results -echo "" -echo "=== Test Results ===" - -# Parse Odoo's final test summary line -# Format: "0 failed, 1 error(s) of 22 tests" -if grep -q "failed.*error(s) of.*tests" "$LOG_FILE"; then - TEST_SUMMARY=$(grep "failed.*error(s) of.*tests" "$LOG_FILE" | tail -1) - echo "$TEST_SUMMARY" - - # Extract numbers - FAILED=$(echo "$TEST_SUMMARY" | grep -oP '\d+(?= failed)' || echo "0") - ERRORS=$(echo "$TEST_SUMMARY" | grep -oP '\d+(?= error)' || echo "0") - TOTAL=$(echo "$TEST_SUMMARY" | grep -oP '\d+(?= tests)' || echo "0") - PASSED=$((TOTAL - FAILED - ERRORS)) - - echo "" - echo " Total: $TOTAL tests" - echo " ✓ Passed: $PASSED" - echo " ✗ Failed: $FAILED" - echo " ✗ Errors: $ERRORS" - - # Show error details - if [ "$FAILED" -gt 0 ] || [ "$ERRORS" -gt 0 ]; then - echo "" - echo "=== Error Details ===" - grep -E "^ERROR:|^FAIL:" "$LOG_FILE" | head -20 || true - fi - - # Set exit code based on test results - if [ "$FAILED" -gt 0 ] || [ "$ERRORS" -gt 0 ]; then - EXIT_CODE=1 - else - EXIT_CODE=0 - fi -else - echo "✗ Could not parse test results" - echo "" - echo "Last 30 lines of log:" - tail -30 "$LOG_FILE" - EXIT_CODE=1 -fi - -echo "" -echo "Full log: $LOG_FILE" - -# Final result banner -echo "" -echo "========================================" -if [ $EXIT_CODE -eq 0 ]; then - echo "✓✓✓ ALL TESTS PASSED ✓✓✓" - echo "========================================" -else - echo "✗✗✗ TESTS FAILED ✗✗✗" - echo "========================================" -fi - -exit $EXIT_CODE diff --git a/open_workshop_mqtt/security/ir.model.access.csv b/open_workshop_mqtt/security/ir.model.access.csv deleted file mode 100644 index 020ac1c..0000000 --- a/open_workshop_mqtt/security/ir.model.access.csv +++ /dev/null @@ -1,7 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_mqtt_device_user,mqtt.device.user,model_mqtt_device,base.group_user,1,1,1,1 -access_mqtt_session_user,mqtt.session.user,model_mqtt_session,base.group_user,1,0,0,0 -access_iot_event_user,ows.iot.event.user,model_ows_iot_event,base.group_user,1,0,0,0 -access_mqtt_device_system,mqtt.device.system,model_mqtt_device,base.group_system,1,1,1,1 -access_mqtt_session_system,mqtt.session.system,model_mqtt_session,base.group_system,1,1,1,1 -access_iot_event_system,ows.iot.event.system,model_ows_iot_event,base.group_system,1,1,1,1 diff --git a/open_workshop_mqtt/test-direct.sh b/open_workshop_mqtt/test-direct.sh deleted file mode 100644 index 77cedd9..0000000 --- a/open_workshop_mqtt/test-direct.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash -# Direct Odoo Test - runs inside container -set -e - -DB="OWS_MQTT" -MODULE="open_workshop_mqtt" - -echo "=== Running Odoo Tests for ${MODULE} in DB ${DB} ===" - -python3 /usr/bin/odoo \ - --addons-path=/mnt/extra-addons,/usr/lib/python3/dist-packages/odoo/addons \ - -d "$DB" \ - --db_host=hobbyhimmel_odoo_18-dev_db \ - --db_port=5432 \ - --db_user=odoo \ - --db_password=odoo \ - --test-enable \ - --stop-after-init \ - --load=base,web \ - -u "$MODULE" \ - --log-level=test diff --git a/open_workshop_mqtt/test-output.txt b/open_workshop_mqtt/test-output.txt deleted file mode 100644 index f32af71..0000000 --- a/open_workshop_mqtt/test-output.txt +++ /dev/null @@ -1,6 +0,0 @@ -=== Checking containers === -✓ Containers running: hobbyhimmel_odoo_18-dev + hobbyhimmel_odoo_18-dev_db -=== Waiting for database === -✓ PostgreSQL ready -=== Running tests on OWS_MQTT (timeout: 120s) === -(Output logged to: /home/lotzm/gitea.hobbyhimmel/odoo/extra-addons/open_workshop/open_workshop_mqtt/test_20260130_174133.log) diff --git a/open_workshop_mqtt/tests/__init__.py b/open_workshop_mqtt/tests/__init__.py deleted file mode 100644 index 19b1fdb..0000000 --- a/open_workshop_mqtt/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -*- coding: utf-8 -*- - -# Old service-based tests removed - using new REST API architecture -# See iot_bridge/tests/ for standalone bridge tests diff --git a/open_workshop_mqtt/tests/common.py b/open_workshop_mqtt/tests/common.py deleted file mode 100644 index 99249ce..0000000 --- a/open_workshop_mqtt/tests/common.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Common test infrastructure for MQTT module - -DEPRECATED: Old service-based tests removed. -Use iot_bridge/tests/ for standalone bridge tests. -Use REST API endpoints for integration tests. -""" - -from odoo.tests import TransactionCase -import logging - -_logger = logging.getLogger(__name__) - - -class MqttTestCase(TransactionCase): - """Base test case for MQTT module - legacy support only""" - - @classmethod - def setUpClass(cls): - super().setUpClass() - _logger.warning("MqttTestCase is deprecated - use REST API tests instead") diff --git a/open_workshop_mqtt/tests/test_device_status.py b/open_workshop_mqtt/tests/test_device_status.py deleted file mode 100644 index e91be23..0000000 --- a/open_workshop_mqtt/tests/test_device_status.py +++ /dev/null @@ -1,84 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Test Device Status Updates -""" - -import unittest -from odoo.tests import tagged -from .common import MQTTTestCase -from datetime import datetime -import json - - -@unittest.skip("HANGS: Real MQTT broker + TransactionCase incompatible - see TODO.md M8") -@tagged('post_install', '-at_install', 'mqtt') -class TestDeviceStatus(MQTTTestCase): - """Test device status tracking (online/offline)""" - - def test_01_device_updates_last_message_time(self): - """Test device last_message_time is updated on message""" - self.assertFalse(self.device.last_message_time) - - # Simulate message - payload = json.dumps({"id": 0, "apower": 25.0}) - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - self.device.invalidate_recordset() - - # Verify updated - self.assertTrue(self.device.last_message_time) - self.assertEqual(self.device.last_power_w, 25.0) - - def test_02_device_state_computed_from_last_message(self): - """Test device state is computed based on last message time""" - from datetime import timedelta - - # Recent message → online - recent_time = datetime.now() - timedelta(seconds=30) - self.device.write({'last_message_time': recent_time}) - self.device.invalidate_recordset(['state']) - - # State should be computed as online - # Note: depends on _compute_state() implementation - - # Old message → offline - old_time = datetime.now() - timedelta(minutes=5) - self.device.write({'last_message_time': old_time}) - self.device.invalidate_recordset(['state']) - - # State should be computed as offline - - def test_03_device_power_tracking(self): - """Test device tracks current power consumption""" - # Send different power values - for power in [10.0, 25.0, 50.0, 100.0, 0.0]: - payload = json.dumps({"id": 0, "apower": power}) - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - self.device.invalidate_recordset() - self.assertEqual(self.device.last_power_w, power) - - def test_04_device_session_count(self): - """Test device session count is computed""" - # Create sessions - for i in range(3): - self.env['mqtt.session'].create({ - 'device_id': self.device.id, - 'status': 'completed', - 'start_time': datetime.now(), - 'end_time': datetime.now(), - 'total_duration_s': 3600, - }) - - # Running session - self.env['mqtt.session'].create({ - 'device_id': self.device.id, - 'status': 'running', - 'start_time': datetime.now(), - }) - - self.device.invalidate_recordset() - - self.assertEqual(self.device.session_count, 4) - self.assertEqual(self.device.running_session_count, 1) - self.assertEqual(self.device.total_runtime_hours, 3.0) diff --git a/open_workshop_mqtt/tests/test_session_detection.py b/open_workshop_mqtt/tests/test_session_detection.py deleted file mode 100644 index 8edb1c5..0000000 --- a/open_workshop_mqtt/tests/test_session_detection.py +++ /dev/null @@ -1,115 +0,0 @@ -# -*- coding: utf-8 -*- -""" -Test Session Detection Logic -""" - -import unittest -from odoo.tests import tagged -from .common import MQTTTestCase -from datetime import datetime -import json - - -@unittest.skip("HANGS: Real MQTT broker + TransactionCase incompatible - see TODO.md M8") -@tagged('post_install', '-at_install', 'mqtt') -class TestSessionDetection(MQTTTestCase): - """Test session start/stop detection""" - - def test_01_session_starts_on_power_above_threshold(self): - """Test session starts when power > 0""" - # Setup: connection running - self.connection.write({'state': 'connected'}) - - # Simulate MQTT message with power > 0 - payload = json.dumps({ - "id": 0, - "voltage": 230.0, - "current": 0.5, - "apower": 50.0, # Above threshold - "freq": 50.0, - }) - - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - # Verify session created - session = self.env['mqtt.session'].search([ - ('device_id', '=', self.device.id), - ('status', '=', 'running'), - ]) - - self.assertEqual(len(session), 1) - self.assertEqual(session.start_power_w, 50.0) - self.assertTrue(session.session_id) # UUID generated - - def test_02_session_ends_on_power_zero(self): - """Test session ends when power drops to 0""" - # Setup: create running session - session = self.env['mqtt.session'].create({ - 'device_id': self.device.id, - 'status': 'running', - 'start_time': datetime.now(), - 'start_power_w': 50.0, - }) - - # Simulate power drop - payload = json.dumps({ - "id": 0, - "voltage": 230.0, - "current": 0.0, - "apower": 0.0, # Power off - "freq": 50.0, - }) - - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - # Verify session completed - session.invalidate_recordset() - self.assertEqual(session.status, 'completed') - self.assertTrue(session.end_time) - self.assertEqual(session.end_reason, 'power_drop') - self.assertGreater(session.total_duration_s, 0) - - def test_03_no_duplicate_sessions(self): - """Test no duplicate running sessions are created""" - # Create first session - payload = json.dumps({"id": 0, "apower": 50.0}) - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - # Send another message with power > 0 - payload = json.dumps({"id": 0, "apower": 60.0}) - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - # Should still be only 1 running session - sessions = self.env['mqtt.session'].search([ - ('device_id', '=', self.device.id), - ('status', '=', 'running'), - ]) - - self.assertEqual(len(sessions), 1) - - def test_04_session_duration_calculated(self): - """Test session duration is calculated correctly""" - from datetime import timedelta - - start_time = datetime.now() - timedelta(hours=2, minutes=30) - - session = self.env['mqtt.session'].create({ - 'device_id': self.device.id, - 'status': 'running', - 'start_time': start_time, - 'start_power_w': 50.0, - }) - - # End session - payload = json.dumps({"id": 0, "apower": 0.0}) - self.simulate_mqtt_message('testdevice/status/pm1:0', payload) - - session.invalidate_recordset() - - # Duration should be ~2.5 hours = 9000 seconds - self.assertGreater(session.total_duration_s, 9000) - self.assertLess(session.total_duration_s, 9100) # Allow 100s tolerance - - # Check computed fields - self.assertAlmostEqual(session.duration_hours, 2.5, places=1) - self.assertIn('h', session.duration_formatted) diff --git a/open_workshop_mqtt/tests/test_start_stop.py b/open_workshop_mqtt/tests/test_start_stop.py deleted file mode 100644 index a2b860e..0000000 --- a/open_workshop_mqtt/tests/test_start_stop.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python3 -""" -Test Start/Stop Button functionality -Run from Docker container: - docker exec -it hobbyhimmel_odoo_18-dev python3 /mnt/extra-addons/open_workshop/open_workshop_mqtt/tests/test_start_stop.py - -SKIPPED: This is not an Odoo test, it's a standalone script. -""" - -import unittest -import psycopg2 -import time - -DB_NAME = 'mqtt' -DB_USER = 'odoo' -DB_PASSWORD = 'odoo' -DB_HOST = 'localhost' -DB_PORT = '5432' - -def test_stop_button(): - """Test stopping connection via action_stop()""" - conn = psycopg2.connect( - dbname=DB_NAME, - user=DB_USER, - password=DB_PASSWORD, - host=DB_HOST, - port=DB_PORT - ) - cur = conn.cursor() - - # Check initial state - cur.execute("SELECT id, name, state FROM mqtt_connection WHERE id=1") - result = cur.fetchone() - print(f"Before stop: ID={result[0]}, Name={result[1]}, State={result[2]}") - - # Simulate action_stop() - what should happen: - # 1. Call service.stop_connection(connection_id) - # 2. MqttClient.disconnect() should be called - # 3. client.loop_stop() should be called - # 4. state should be set to 'stopped' - - print("\n[INFO] To actually test, you need to:") - print("1. Open Odoo UI: http://localhost:9018") - print("2. Go to: MQTT Machine > Connections") - print("3. Click 'Stop' button") - print("4. Watch terminal: docker logs -f hobbyhimmel_odoo_18-dev") - print("5. Expected log: 'Stopping MQTT connection 1'") - print("6. Expected log: 'Disconnecting MQTT client 1'") - print("7. Expected log: 'MQTT client 1 disconnected'") - - print("\n[CHECK] Wait 5 seconds and check if messages still arriving...") - time.sleep(5) - - cur.execute("SELECT COUNT(*) FROM mqtt_message WHERE create_date > NOW() - INTERVAL '5 seconds'") - recent_count = cur.fetchone()[0] - print(f"Messages received in last 5 seconds: {recent_count}") - - if recent_count > 0: - print("[WARNING] Messages still arriving - Stop might not be working!") - else: - print("[OK] No recent messages - Stop might be working") - - cur.close() - conn.close() - -if __name__ == '__main__': - test_stop_button() diff --git a/open_workshop_mqtt/views/iot_event_views.xml b/open_workshop_mqtt/views/iot_event_views.xml deleted file mode 100644 index 6ffbd4b..0000000 --- a/open_workshop_mqtt/views/iot_event_views.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - ows.iot.event.list - ows.iot.event - - - - - - - - - - - - - - - ows.iot.event.form - ows.iot.event - -
- - - - - - - - - - - - - - - - - - - - - - - - -
-
-
- - - - ows.iot.event.search - ows.iot.event - - - - - - - - - - - - - - - - - - - - - - - - - - - - - IoT Events - ows.iot.event - list,form - {'search_default_filter_unprocessed': 1} - -

- No IoT Events yet -

-

- Events are automatically created by the IoT Bridge when it detects
- session state changes (started, updated, stopped, heartbeat). -

-
-
- -
diff --git a/open_workshop_mqtt/views/mqtt_device_views.xml b/open_workshop_mqtt/views/mqtt_device_views.xml deleted file mode 100644 index d854d43..0000000 --- a/open_workshop_mqtt/views/mqtt_device_views.xml +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - mqtt.device.list - mqtt.device - - - - - - - - - - - - - - - - mqtt.device.form - mqtt.device - -
-
-
- - -
- - -
- - - - - - - - - - - - - - - - - - - -
- Configuration is sent to IoT Bridge via REST API -
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-
- - - - mqtt.device.kanban - mqtt.device - - - - - - - - - - - -
-
-
- - - -
- -
-
-
- -
-
-
-
-
- - -
-
- - W -
-
-
-
-
- sessions -
-
- h -
-
-
-
-
-
-
-
- - - - mqtt.device.search - mqtt.device - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - MQTT Devices - mqtt.device - kanban,list,form - -

- Add your first MQTT Device -

-

- Configure IoT devices (Shelly, Tasmota, etc.) to track their runtime and sessions. -

-
-
- -
diff --git a/open_workshop_mqtt/views/mqtt_menus.xml b/open_workshop_mqtt/views/mqtt_menus.xml deleted file mode 100644 index ddc41d9..0000000 --- a/open_workshop_mqtt/views/mqtt_menus.xml +++ /dev/null @@ -1,41 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/open_workshop_mqtt/views/mqtt_session_views.xml b/open_workshop_mqtt/views/mqtt_session_views.xml deleted file mode 100644 index 21fec9c..0000000 --- a/open_workshop_mqtt/views/mqtt_session_views.xml +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - mqtt.session.list - mqtt.session - - - - - - - - - - - - - - - - - - - - - mqtt.session.form - mqtt.session - -
-
- -
- - - - - -
-

- -

-

- -

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- - - - mqtt.session.pivot - mqtt.session - - - - - - - - - - - - - - mqtt.session.graph - mqtt.session - - - - - - - - - - - mqtt.session.graph.line - mqtt.session - - - - - - - - - - - mqtt.session.search - mqtt.session - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - MQTT Sessions - mqtt.session - list,form,pivot,graph - {'search_default_this_week': 1} - -

- No sessions recorded yet -

-

- Sessions will appear here once your MQTT devices start reporting data. -

-
-
- -