open_workshop_mqtt entfernt

This commit is contained in:
Matthias Lotz 2026-02-11 21:04:10 +01:00
parent 9b9b8d025e
commit f834d70859
70 changed files with 0 additions and 10725 deletions

View File

@ -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. 1664 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
}
}
```

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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!

View File

@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View File

@ -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,
}

View File

@ -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}")

View File

@ -1,3 +0,0 @@
# -*- coding: utf-8 -*-
from . import iot_api

View File

@ -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))

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -1 +0,0 @@
"""IoT Bridge Tests"""

View File

@ -1 +0,0 @@
# Integration tests for iot_bridge

View File

@ -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'])

View File

@ -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'])

View File

@ -1 +0,0 @@
"""Test Tools"""

View File

@ -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()

View File

@ -1 +0,0 @@
"""Unit Tests"""

View File

@ -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'])

View File

@ -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

View File

@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
from . import mqtt_device
from . import mqtt_session
from . import iot_event

View File

@ -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
})

View File

@ -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 {}

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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')

View File

@ -1,3 +0,0 @@
"""
Tests für Open Workshop IoT Bridge
"""

View File

@ -1,3 +0,0 @@
"""
Integration Tests - Testen mit echten externen Services (MQTT Broker, Storage)
"""

View File

@ -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'])

View File

@ -1,3 +0,0 @@
"""
Test Tools - Hilfsprogramme für manuelle Tests und Debugging
"""

View File

@ -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()

View File

@ -1,3 +0,0 @@
"""
Unit Tests - Testen einzelne Komponenten isoliert ohne externe Dependencies
"""

View File

@ -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'])

View File

@ -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

View File

@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mqtt_device_user mqtt.device.user model_mqtt_device base.group_user 1 1 1 1
3 access_mqtt_session_user mqtt.session.user model_mqtt_session base.group_user 1 0 0 0
4 access_iot_event_user ows.iot.event.user model_ows_iot_event base.group_user 1 0 0 0
5 access_mqtt_device_system mqtt.device.system model_mqtt_device base.group_system 1 1 1 1
6 access_mqtt_session_system mqtt.session.system model_mqtt_session base.group_system 1 1 1 1
7 access_iot_event_system ows.iot.event.system model_ows_iot_event base.group_system 1 1 1 1

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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', '&gt;=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
<filter name="this_week" string="This Week"
domain="[('start_time', '&gt;=', (context_today() - relativedelta(weeks=1)).strftime('%Y-%m-%d'))]"/>
<filter name="this_month" string="This Month"
domain="[('start_time', '&gt;=', (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>