open_workshop_mqtt entfernt
This commit is contained in:
parent
9b9b8d025e
commit
f834d70859
|
|
@ -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 <BRIDGE_API_TOKEN>` (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 <BRIDGE_API_TOKEN>` (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/<machine_id>/state`
|
||||
- Maschinenereignisse:
|
||||
`hobbyhimmel/machines/<machine_id>/event`
|
||||
- Waage:
|
||||
`hobbyhimmel/scales/<scale_id>/event`
|
||||
- Geräte-Status (optional):
|
||||
`hobbyhimmel/devices/<device_id>/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 <token>" http://localhost:8069/ows/iot/config`
|
||||
- `curl -X POST -H "Authorization: Bearer <token>" -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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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` = `<generated-token>`
|
||||
|
||||
Add to `.env`:
|
||||
```
|
||||
IOT_BRIDGE_TOKEN=<same-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
|
||||
|
|
@ -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 <token>" http://localhost:8069/ows/iot/config
|
||||
|
||||
# Event senden
|
||||
curl -X POST -H "Authorization: Bearer <token>" \
|
||||
-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!
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}")
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import iot_api
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
48
open_workshop_mqtt/iot_bridge/.gitignore
vendored
48
open_workshop_mqtt/iot_bridge/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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 <token>
|
||||
|
||||
{
|
||||
"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)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""IoT Bridge Tests"""
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Integration tests for iot_bridge
|
||||
|
|
@ -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'])
|
||||
|
|
@ -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'])
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Test Tools"""
|
||||
|
|
@ -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()
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""Unit Tests"""
|
||||
|
|
@ -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'])
|
||||
|
|
@ -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
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import mqtt_device
|
||||
from . import mqtt_session
|
||||
from . import iot_event
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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]
|
||||
31
open_workshop_mqtt/python_prototype/.gitignore
vendored
31
open_workshop_mqtt/python_prototype/.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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')
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Tests für Open Workshop IoT Bridge
|
||||
"""
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Integration Tests - Testen mit echten externen Services (MQTT Broker, Storage)
|
||||
"""
|
||||
|
|
@ -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'])
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Test Tools - Hilfsprogramme für manuelle Tests und Debugging
|
||||
"""
|
||||
|
|
@ -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 <host> --port <port> --username <user> --password <pass>")
|
||||
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()
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
"""
|
||||
Unit Tests - Testen einzelne Komponenten isoliert ohne externe Dependencies
|
||||
"""
|
||||
|
|
@ -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'])
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ========== List View ========== -->
|
||||
<record id="view_ows_iot_event_list" model="ir.ui.view">
|
||||
<field name="name">ows.iot.event.list</field>
|
||||
<field name="model">ows.iot.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<list create="false" delete="false">
|
||||
<field name="timestamp"/>
|
||||
<field name="event_type" widget="badge"/>
|
||||
<field name="device_id"/>
|
||||
<field name="session_id"/>
|
||||
<field name="mqtt_device_id"/>
|
||||
<field name="processed" widget="boolean_toggle"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Form View ========== -->
|
||||
<record id="view_ows_iot_event_form" model="ir.ui.view">
|
||||
<field name="name">ows.iot.event.form</field>
|
||||
<field name="model">ows.iot.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false" delete="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Event Info">
|
||||
<field name="event_uid"/>
|
||||
<field name="event_type"/>
|
||||
<field name="timestamp"/>
|
||||
</group>
|
||||
<group string="Related Records">
|
||||
<field name="device_id"/>
|
||||
<field name="session_id"/>
|
||||
<field name="mqtt_device_id"/>
|
||||
<field name="mqtt_session_id"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Processing">
|
||||
<field name="processed"/>
|
||||
<field name="processing_error" invisible="not processing_error"/>
|
||||
</group>
|
||||
|
||||
<group string="Payload">
|
||||
<field name="payload_json" widget="text" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Search View ========== -->
|
||||
<record id="view_ows_iot_event_search" model="ir.ui.view">
|
||||
<field name="name">ows.iot.event.search</field>
|
||||
<field name="model">ows.iot.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="event_uid"/>
|
||||
<field name="device_id"/>
|
||||
<field name="session_id"/>
|
||||
<field name="event_type"/>
|
||||
|
||||
<filter string="Session Started"
|
||||
name="filter_session_started"
|
||||
domain="[('event_type', '=', 'session_started')]"/>
|
||||
<filter string="Session Heartbeat"
|
||||
name="filter_heartbeat"
|
||||
domain="[('event_type', '=', 'session_heartbeat')]"/>
|
||||
<filter string="Session Stopped"
|
||||
name="filter_session_stopped"
|
||||
domain="[('event_type', 'in', ['session_stopped', 'session_timeout'])]"/>
|
||||
|
||||
<separator/>
|
||||
<filter string="Processed"
|
||||
name="filter_processed"
|
||||
domain="[('processed', '=', True)]"/>
|
||||
<filter string="Unprocessed"
|
||||
name="filter_unprocessed"
|
||||
domain="[('processed', '=', False)]"/>
|
||||
<filter string="Errors"
|
||||
name="filter_errors"
|
||||
domain="[('processing_error', '!=', False)]"/>
|
||||
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Event Type" name="group_event_type" context="{'group_by': 'event_type'}"/>
|
||||
<filter string="Device" name="group_device" context="{'group_by': 'device_id'}"/>
|
||||
<filter string="Session" name="group_session" context="{'group_by': 'session_id'}"/>
|
||||
<filter string="Date" name="group_date" context="{'group_by': 'timestamp:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Action ========== -->
|
||||
<record id="action_ows_iot_event" model="ir.actions.act_window">
|
||||
<field name="name">IoT Events</field>
|
||||
<field name="res_model">ows.iot.event</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_filter_unprocessed': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No IoT Events yet
|
||||
</p>
|
||||
<p>
|
||||
Events are automatically created by the IoT Bridge when it detects<br/>
|
||||
session state changes (started, updated, stopped, heartbeat).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ========== List View ========== -->
|
||||
<record id="view_mqtt_device_list" model="ir.ui.view">
|
||||
<field name="name">mqtt.device.list</field>
|
||||
<field name="model">mqtt.device</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="device_id"/>
|
||||
<field name="parser_type"/>
|
||||
<field name="state"
|
||||
decoration-success="state == 'online'"
|
||||
decoration-danger="state == 'offline'"
|
||||
widget="badge"/>
|
||||
<field name="last_seen"/>
|
||||
<field name="session_count"/>
|
||||
<field name="total_runtime_hours" optional="hide"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Form View ========== -->
|
||||
<record id="view_mqtt_device_form" model="ir.ui.view">
|
||||
<field name="name">mqtt.device.form</field>
|
||||
<field name="model">mqtt.device</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_start_manual_session"
|
||||
string="Start Session"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="session_strategy != 'manual'"/>
|
||||
<button name="action_stop_manual_session"
|
||||
string="Stop Session"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="session_strategy != 'manual'"/>
|
||||
<field name="state" widget="statusbar"/>
|
||||
</header>
|
||||
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_sessions"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-clock-o">
|
||||
<field name="session_count"
|
||||
widget="statinfo"
|
||||
string="Sessions"/>
|
||||
</button>
|
||||
<button name="action_view_sessions"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-hourglass">
|
||||
<field name="total_runtime_hours"
|
||||
widget="statinfo"
|
||||
string="Runtime (h)"/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<widget name="web_ribbon"
|
||||
title="Active"
|
||||
bg_color="bg-success"
|
||||
invisible="state != 'active'"/>
|
||||
<widget name="web_ribbon"
|
||||
title="Offline"
|
||||
bg_color="bg-danger"
|
||||
invisible="state != 'offline'"/>
|
||||
|
||||
<group>
|
||||
<group string="Device Information">
|
||||
<field name="name"/>
|
||||
<field name="device_id" string="External Device ID"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group string="MQTT and Parser Configuration">
|
||||
<field name="topic_pattern" placeholder="e.g., device/+/status"/>
|
||||
<field name="parser_type"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Session Detection Strategy">
|
||||
<group>
|
||||
<field name="session_strategy"/>
|
||||
<div class="text-muted" colspan="2">
|
||||
Configuration is sent to IoT Bridge via REST API
|
||||
</div>
|
||||
</group>
|
||||
<group>
|
||||
<field name="strategy_config"
|
||||
widget="text"
|
||||
placeholder='{"standby_threshold_w": 20, "working_threshold_w": 100, "start_debounce_s": 3, "stop_debounce_s": 15, "message_timeout_s": 20, "heartbeat_interval_s": 300, "strategy": "power_threshold"}'
|
||||
nolabel="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Live Status" invisible="state == 'offline'">
|
||||
<group>
|
||||
<field name="last_message_time"/>
|
||||
<field name="last_power_w"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="running_session_count"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<notebook>
|
||||
<page string="Sessions" name="sessions">
|
||||
<field name="session_ids"
|
||||
context="{'default_device_id': id}">
|
||||
<list>
|
||||
<field name="session_id"/>
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<field name="duration_formatted"/>
|
||||
<field name="status" widget="badge"/>
|
||||
<field name="end_reason"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<page string="Statistics" name="statistics">
|
||||
<group>
|
||||
<group string="Session Stats">
|
||||
<field name="session_count"/>
|
||||
<field name="running_session_count"/>
|
||||
</group>
|
||||
<group string="Runtime Stats">
|
||||
<field name="total_runtime_hours"/>
|
||||
<field name="avg_session_duration_hours"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Kanban View ========== -->
|
||||
<record id="view_mqtt_device_kanban" model="ir.ui.view">
|
||||
<field name="name">mqtt.device.kanban</field>
|
||||
<field name="model">mqtt.device</field>
|
||||
<field name="arch" type="xml">
|
||||
<kanban>
|
||||
<field name="id"/>
|
||||
<field name="name"/>
|
||||
<field name="state"/>
|
||||
<field name="last_power_w"/>
|
||||
<field name="session_count"/>
|
||||
<field name="total_runtime_hours"/>
|
||||
<field name="parser_type"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_global_click">
|
||||
<div class="o_kanban_record_top">
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title">
|
||||
<field name="name"/>
|
||||
</strong>
|
||||
<div class="o_kanban_record_subtitle">
|
||||
<field name="parser_type"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_top_right">
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'active'"
|
||||
decoration-info="state == 'idle'"
|
||||
decoration-muted="state == 'offline'"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<i class="fa fa-microchip"/>
|
||||
<field name="parser_type"/>
|
||||
</div>
|
||||
<div class="col-6" t-if="record.state.raw_value != 'offline'">
|
||||
<i class="fa fa-bolt"/>
|
||||
<field name="last_power_w"/> W
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_kanban_record_bottom">
|
||||
<div class="oe_kanban_bottom_left">
|
||||
<span><i class="fa fa-clock-o"/> <field name="session_count"/> sessions</span>
|
||||
</div>
|
||||
<div class="oe_kanban_bottom_right">
|
||||
<span><field name="total_runtime_hours"/> h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Search View ========== -->
|
||||
<record id="view_mqtt_device_search" model="ir.ui.view">
|
||||
<field name="name">mqtt.device.search</field>
|
||||
<field name="model">mqtt.device</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="device_id"/>
|
||||
<separator/>
|
||||
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
|
||||
<filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter name="state_active" string="Active" domain="[('state', '=', 'active')]"/>
|
||||
<filter name="state_idle" string="Idle" domain="[('state', '=', 'idle')]"/>
|
||||
<filter name="state_offline" string="Offline" domain="[('state', '=', 'offline')]"/>
|
||||
<separator/>
|
||||
<filter name="parser_shelly" string="Shelly" domain="[('parser_type', '=', 'shelly_pm')]"/>
|
||||
<filter name="parser_tasmota" string="Tasmota" domain="[('parser_type', '=', 'tasmota')]"/>
|
||||
<separator/>
|
||||
<filter name="has_sessions" string="Has Sessions" domain="[('session_count', '>', 0)]"/>
|
||||
<filter name="running_sessions" string="Running Sessions" domain="[('running_session_count', '>', 0)]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_parser" string="Parser" context="{'group_by': 'parser_type'}"/>
|
||||
<filter name="group_strategy" string="Strategy" context="{'group_by': 'session_strategy'}"/>
|
||||
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Action ========== -->
|
||||
<record id="action_mqtt_device" model="ir.actions.act_window">
|
||||
<field name="name">MQTT Devices</field>
|
||||
<field name="res_model">mqtt.device</field>
|
||||
<field name="view_mode">kanban,list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add your first MQTT Device
|
||||
</p>
|
||||
<p>
|
||||
Configure IoT devices (Shelly, Tasmota, etc.) to track their runtime and sessions.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ========== Root Menu ========== -->
|
||||
<menuitem id="menu_mqtt_root"
|
||||
name="MQTT"
|
||||
sequence="50"
|
||||
web_icon="mqtt,static/description/icon.png"/>
|
||||
|
||||
<!-- ========== Main Menu Items ========== -->
|
||||
<menuitem id="menu_mqtt_devices"
|
||||
name="Devices"
|
||||
parent="menu_mqtt_root"
|
||||
action="action_mqtt_device"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_mqtt_sessions"
|
||||
name="Sessions"
|
||||
parent="menu_mqtt_root"
|
||||
action="action_mqtt_session"
|
||||
sequence="30"/>
|
||||
|
||||
<menuitem id="menu_mqtt_events"
|
||||
name="IoT Events"
|
||||
parent="menu_mqtt_root"
|
||||
action="action_ows_iot_event"
|
||||
sequence="35"/>
|
||||
|
||||
<!-- ========== Configuration Submenu ========== -->
|
||||
<menuitem id="menu_mqtt_config"
|
||||
name="Configuration"
|
||||
parent="menu_mqtt_root"
|
||||
sequence="100"/>
|
||||
|
||||
<menuitem id="menu_mqtt_config_devices"
|
||||
name="Devices"
|
||||
parent="menu_mqtt_config"
|
||||
action="action_mqtt_device"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ========== List View ========== -->
|
||||
<record id="view_mqtt_session_list" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.list</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="device_name"/>
|
||||
<field name="session_id" optional="hide"/>
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<field name="duration_formatted"/>
|
||||
<field name="duration_hours" optional="show" sum="Total Hours"/>
|
||||
<field name="standby_hours" optional="hide"/>
|
||||
<field name="working_hours" optional="hide"/>
|
||||
<field name="start_power_w" optional="hide"/>
|
||||
<field name="end_power_w" optional="hide"/>
|
||||
<field name="status"
|
||||
decoration-info="status == 'running'"
|
||||
decoration-success="status == 'completed'"
|
||||
widget="badge"/>
|
||||
<field name="end_reason" optional="show"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Form View ========== -->
|
||||
<record id="view_mqtt_session_form" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.form</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<form create="false" edit="false">
|
||||
<header>
|
||||
<field name="status" widget="statusbar"/>
|
||||
</header>
|
||||
|
||||
<sheet>
|
||||
<widget name="web_ribbon"
|
||||
title="Running"
|
||||
bg_color="bg-info"
|
||||
invisible="status != 'running'"/>
|
||||
<widget name="web_ribbon"
|
||||
title="Completed"
|
||||
bg_color="bg-success"
|
||||
invisible="status != 'completed'"/>
|
||||
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="device_name"/>
|
||||
</h1>
|
||||
<h3>
|
||||
<field name="session_id"/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<group>
|
||||
<group string="Session Times">
|
||||
<field name="start_time"/>
|
||||
<field name="end_time"/>
|
||||
<field name="duration_formatted" string="Duration"/>
|
||||
</group>
|
||||
<group string="Device">
|
||||
<field name="device_id"/>
|
||||
<field name="end_reason" invisible="status == 'running'"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Durations (Detailed)">
|
||||
<group>
|
||||
<field name="total_duration_s" string="Total (seconds)"/>
|
||||
<field name="duration_hours" string="Total (hours)"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="standby_duration_s" string="Standby (seconds)"/>
|
||||
<field name="standby_hours" string="Standby (hours)"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="working_duration_s" string="Working (seconds)"/>
|
||||
<field name="working_hours" string="Working (hours)"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Power Measurements">
|
||||
<group>
|
||||
<field name="start_power_w"/>
|
||||
</group>
|
||||
<group invisible="not end_power_w">
|
||||
<field name="end_power_w"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<group string="Metadata" invisible="not metadata">
|
||||
<field name="metadata" widget="ace" options="{'mode': 'json'}" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Pivot View (Analytics) ========== -->
|
||||
<record id="view_mqtt_session_pivot" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.pivot</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Session Analytics">
|
||||
<field name="device_name" type="row"/>
|
||||
<field name="start_time" interval="day" type="col"/>
|
||||
<field name="duration_hours" type="measure"/>
|
||||
<field name="working_hours" type="measure"/>
|
||||
<field name="standby_hours" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Graph View (Bar Chart) ========== -->
|
||||
<record id="view_mqtt_session_graph" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.graph</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Session Statistics" type="bar">
|
||||
<field name="device_name"/>
|
||||
<field name="duration_hours" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Graph View (Line Chart - Timeline) ========== -->
|
||||
<record id="view_mqtt_session_graph_line" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.graph.line</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Runtime Timeline" type="line">
|
||||
<field name="start_time" interval="day"/>
|
||||
<field name="duration_hours" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Search View ========== -->
|
||||
<record id="view_mqtt_session_search" model="ir.ui.view">
|
||||
<field name="name">mqtt.session.search</field>
|
||||
<field name="model">mqtt.session</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="device_id"/>
|
||||
<field name="device_name"/>
|
||||
<field name="session_id"/>
|
||||
<field name="start_time"/>
|
||||
<separator/>
|
||||
<filter name="running" string="Running" domain="[('status', '=', 'running')]"/>
|
||||
<filter name="completed" string="Completed" domain="[('status', '=', 'completed')]"/>
|
||||
<separator/>
|
||||
<filter name="today" string="Today"
|
||||
domain="[('start_time', '>=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<filter name="this_week" string="This Week"
|
||||
domain="[('start_time', '>=', (context_today() - relativedelta(weeks=1)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter name="this_month" string="This Month"
|
||||
domain="[('start_time', '>=', (context_today() - relativedelta(months=1)).strftime('%Y-%m-%d'))]"/>
|
||||
<separator/>
|
||||
<filter name="end_power_drop" string="Power Drop" domain="[('end_reason', '=', 'power_drop')]"/>
|
||||
<filter name="end_timeout" string="Timeout" domain="[('end_reason', '=', 'timeout')]"/>
|
||||
<separator/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_device" string="Device" context="{'group_by': 'device_id'}"/>
|
||||
<filter name="group_status" string="Status" context="{'group_by': 'status'}"/>
|
||||
<filter name="group_end_reason" string="End Reason" context="{'group_by': 'end_reason'}"/>
|
||||
<filter name="group_start_day" string="Start Day" context="{'group_by': 'start_time:day'}"/>
|
||||
<filter name="group_start_week" string="Start Week" context="{'group_by': 'start_time:week'}"/>
|
||||
<filter name="group_start_month" string="Start Month" context="{'group_by': 'start_time:month'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- ========== Action ========== -->
|
||||
<record id="action_mqtt_session" model="ir.actions.act_window">
|
||||
<field name="name">MQTT Sessions</field>
|
||||
<field name="res_model">mqtt.session</field>
|
||||
<field name="view_mode">list,form,pivot,graph</field>
|
||||
<field name="context">{'search_default_this_week': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No sessions recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Sessions will appear here once your MQTT devices start reporting data.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Loading…
Reference in New Issue
Block a user