feat(odoo): IoT Bridge Phase 2 - Odoo REST API
Phase 2.1 - Models: - ows.iot.event Model für Event-Logging - event_uid (unique constraint) für Duplikat-Prävention - event_type (session_started/updated/stopped/timeout/heartbeat) - payload_json mit auto-extraction (power_w, state) - Auto-Linking zu mqtt.device und mqtt.session - Processing-Status tracking - mqtt.device erweitert mit device_id (External ID für API) - Existing mqtt.session bereits perfekt strukturiert Phase 2.2 - REST API Controller: - GET /ows/iot/config - Returns aktive Devices mit session_config als JSON - Public auth (später API-Key optional) - POST /ows/iot/event - JSON-RPC Format (Odoo type='json') - Schema-Validation (required: event_uid, event_type, device_id, timestamp) - Duplikat-Check: 409 Conflict für idempotency - Auto-Processing: Session create/update/complete - Response codes: 201/409/400/500 Phase 2.3 - Bridge OdooClient: - Echte REST API Implementation - get_config(): GET /ows/iot/config - send_event(): POST /ows/iot/event (JSON-RPC) - Duplicate handling (409 = success) - HTTP Session mit requests library - config.py: OdooConfig mit base_url, database, username, api_key - main.py: Conditional OdooClient/MockOdooClient based on use_mock Testing Guide: - PHASE2_TESTING.md mit kompletter Anleitung - Manuelle API Tests (curl examples) - Integration Test Steps (Odoo + Mosquitto + Bridge) - Success Criteria Checklist Bereit für Phase 2.4 Integration Testing!
This commit is contained in:
parent
989720ed21
commit
c55b0e59d2
|
|
@ -124,50 +124,96 @@
|
|||
|
||||
---
|
||||
|
||||
## 🎯 Phase 2: Odoo REST API (parallel zu Phase 1)
|
||||
## 🎯 Phase 2: Odoo REST API
|
||||
|
||||
**Ziel:** Odoo REST API für Bridge-Config und Event-Empfang
|
||||
|
||||
### 2.1 Models anpassen
|
||||
- [ ] `ows.iot.device` Model:
|
||||
- `device_id`, `mqtt_topic`, `parser_type` Felder
|
||||
- `session_config` (JSON Field)
|
||||
- `heartbeat_interval_s` Field
|
||||
- [ ] `ows.iot.event` Model:
|
||||
- [x] **ows.iot.event** Model erstellt (`models/iot_event.py`)
|
||||
- `event_uid` (unique constraint)
|
||||
- `event_type` (selection mit neuen Typen)
|
||||
- `event_type` (selection: session_started/updated/stopped/timeout/heartbeat/power_change)
|
||||
- `payload_json` (JSON Field)
|
||||
- [ ] `ows.machine.session` Model:
|
||||
- `session_id` (external, unique)
|
||||
- `total_working_time_s`, `total_standby_time_s` (Float)
|
||||
- `billing_units` (Computed Field)
|
||||
- `state` (selection: running/stopped/timeout)
|
||||
- `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 upgraden, Demo-Daten anlegen
|
||||
**Test:** ✅ Models erstellt, Security Rules aktualisiert
|
||||
|
||||
---
|
||||
|
||||
### 2.2 REST API Controller
|
||||
- [ ] `controllers/iot_api.py` erstellen
|
||||
- [ ] `GET /ows/iot/config`:
|
||||
- Devices mit session_config zurückgeben
|
||||
- Optional: Token-Auth (später)
|
||||
- [ ] `POST /ows/iot/event`:
|
||||
- Schema-Validation (event_type, payload)
|
||||
- Event-UID Duplikat-Check → 409
|
||||
- Event speichern
|
||||
- Session updaten (oder erstellen)
|
||||
- [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:**
|
||||
**Test:** ✅ Controller erstellt, bereit für manuellen Test
|
||||
```bash
|
||||
curl http://localhost:8069/ows/iot/config
|
||||
curl -X POST -d '{...}' http://localhost:8069/ows/iot/event
|
||||
curl -X POST http://localhost:8069/ows/iot/event -d '{...}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Session-Logik in Odoo
|
||||
- [ ] `session_started` Handler:
|
||||
### 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
|
||||
- [ ] Odoo Modul upgraden (Models + Controller laden)
|
||||
- [ ] mqtt.device in UI anlegen mit `device_id`
|
||||
- [ ] API manuell testen (curl GET /config, POST /event)
|
||||
- [ ] `config.yaml.dev`: `use_mock=false` setzen
|
||||
- [ ] Docker Compose starten: Odoo + Mosquitto + Bridge
|
||||
- [ ] End-to-End: MQTT → Bridge → Odoo → Session erstellt
|
||||
- [ ] Logs prüfen: Keine Errors, Events erfolgreich gesendet
|
||||
|
||||
**Test:** ⏳ Bereit zum Testen - siehe PHASE2_TESTING.md
|
||||
- Neue Session erstellen
|
||||
- `session_id` von Event übernehmen
|
||||
- [ ] `session_heartbeat` Handler:
|
||||
|
|
|
|||
235
open_workshop_mqtt/PHASE2_TESTING.md
Normal file
235
open_workshop_mqtt/PHASE2_TESTING.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# Phase 2 - Odoo REST API Testing Guide
|
||||
|
||||
## ✅ Was wurde implementiert
|
||||
|
||||
### Models (Phase 2.1)
|
||||
- **ows.iot.event**: Event Log mit unique `event_uid`
|
||||
- Alle Events von der Bridge werden geloggt
|
||||
- Auto-Linking zu mqtt.device und mqtt.session
|
||||
- Payload-Extraktion (power_w, state)
|
||||
|
||||
- **mqtt.device**: Erweitert mit `device_id` (External ID)
|
||||
- Für API-Zugriff durch Bridge
|
||||
|
||||
### REST API Controller (Phase 2.2)
|
||||
- **GET /ows/iot/config**
|
||||
- Returns: Liste aller aktiven Devices mit session_config
|
||||
- Auth: public (später API-Key)
|
||||
|
||||
- **POST /ows/iot/event**
|
||||
- Empfängt Events von Bridge
|
||||
- Duplikat-Check via event_uid (409 Conflict)
|
||||
- Auto-Processing: Session erstellen/updaten
|
||||
- Returns: 201 Created, 409 Duplicate, 400 Bad Request, 500 Error
|
||||
|
||||
### Bridge Client (Phase 2.3)
|
||||
- **OdooClient**: Echte REST API Calls
|
||||
- `get_config()` → GET /ows/iot/config
|
||||
- `send_event()` → POST /ows/iot/event (JSON-RPC)
|
||||
- Retry-Logic über EventQueue
|
||||
|
||||
## 🧪 Testing-Schritte
|
||||
|
||||
### 1. Odoo Modul upgraden
|
||||
|
||||
```bash
|
||||
cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo
|
||||
docker compose -f docker-compose.dev.yaml exec odoo-dev odoo --upgrade open_workshop_mqtt
|
||||
```
|
||||
|
||||
Oder über UI: Apps → open_workshop_mqtt → Upgrade
|
||||
|
||||
### 2. MQTT Device in Odoo anlegen
|
||||
|
||||
UI: MQTT → Devices → Create
|
||||
|
||||
**Beispiel-Konfiguration:**
|
||||
- **Name**: Shaper Origin
|
||||
- **Device ID**: `shellypmminig3-48f6eeb73a1c`
|
||||
- **MQTT Connection**: (wähle bestehende oder erstelle neue für local Mosquitto)
|
||||
- **Topic Pattern**: `shaperorigin/#`
|
||||
- **Parser Type**: Shelly PM Mini G3
|
||||
- **Session Strategy**: Power Threshold (Dual)
|
||||
- **Strategy Configuration**:
|
||||
```json
|
||||
{
|
||||
"standby_threshold_w": 20,
|
||||
"working_threshold_w": 100,
|
||||
"start_debounce_s": 3,
|
||||
"stop_debounce_s": 15,
|
||||
"message_timeout_s": 20,
|
||||
"heartbeat_interval_s": 300
|
||||
}
|
||||
```
|
||||
|
||||
### 3. API manuell testen
|
||||
|
||||
**Test 1: GET /ows/iot/config**
|
||||
```bash
|
||||
curl http://localhost:8069/ows/iot/config
|
||||
```
|
||||
|
||||
Expected Response:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"devices": [
|
||||
{
|
||||
"device_id": "shellypmminig3-48f6eeb73a1c",
|
||||
"mqtt_topic": "shaperorigin/status/pm1:0",
|
||||
"parser_type": "shelly_pm",
|
||||
"machine_name": "Shaper Origin",
|
||||
"session_config": {
|
||||
"strategy": "power_threshold",
|
||||
"standby_threshold_w": 20,
|
||||
...
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": "2026-02-05T15:30:00.000000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Test 2: POST /ows/iot/event**
|
||||
```bash
|
||||
curl -X POST http://localhost:8069/ows/iot/event \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "call",
|
||||
"params": {
|
||||
"event_uid": "test-' $(uuidgen) '",
|
||||
"event_type": "session_started",
|
||||
"device_id": "shellypmminig3-48f6eeb73a1c",
|
||||
"session_id": "test-session-123",
|
||||
"timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ)'",
|
||||
"payload": {
|
||||
"power_w": 47.9,
|
||||
"state": "starting"
|
||||
}
|
||||
},
|
||||
"id": null
|
||||
}'
|
||||
```
|
||||
|
||||
Expected Response:
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"result": {
|
||||
"status": "success",
|
||||
"message": "Event received and processed",
|
||||
"event_uid": "test-...",
|
||||
"event_id": 1,
|
||||
"code": 201
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Test 3: Duplicate Event (sollte 409 zurückgeben)**
|
||||
```bash
|
||||
# Gleichen curl nochmal ausführen → sollte code: 409 zurückgeben
|
||||
```
|
||||
|
||||
### 4. config.yaml.dev für echtes Odoo anpassen
|
||||
|
||||
```yaml
|
||||
odoo:
|
||||
base_url: "http://odoo-dev:8069"
|
||||
database: "your-db-name" # ← WICHTIG: Deinen DB-Namen eintragen
|
||||
username: "admin"
|
||||
api_key: "" # Aktuell nicht benötigt (auth='public')
|
||||
use_mock: false # ← VON true AUF false ÄNDERN
|
||||
```
|
||||
|
||||
### 5. Bridge im Docker Compose starten
|
||||
|
||||
```bash
|
||||
cd /home/lotzm/gitea.hobbyhimmel/odoo/odoo
|
||||
docker compose -f docker-compose.dev.yaml up -d iot-bridge
|
||||
docker compose -f docker-compose.dev.yaml logs -f iot-bridge
|
||||
```
|
||||
|
||||
**Erwartete Logs:**
|
||||
```
|
||||
Loading configuration from /app/config.yaml
|
||||
INFO: bridge_started config_file=/app/config.yaml devices=1
|
||||
INFO: using_real_odoo_client base_url=http://odoo-dev:8069 database=...
|
||||
INFO: config_loaded device_count=1
|
||||
INFO: event_queue_started
|
||||
INFO: session_detector_initialized device=shellypmminig3-48f6eeb73a1c
|
||||
INFO: connected_to_mqtt broker=mosquitto port=1883
|
||||
INFO: subscribed_to_topic topic=shaperorigin/status/pm1:0
|
||||
INFO: bridge_ready status=running
|
||||
```
|
||||
|
||||
### 6. End-to-End Test
|
||||
|
||||
**Option A: Mit echtem Shelly Device**
|
||||
- Sende MQTT-Nachrichten an lokalen Mosquitto
|
||||
- Bridge empfängt → Session Detection → Events an Odoo
|
||||
|
||||
**Option B: Mit MQTT Simulator**
|
||||
```bash
|
||||
mosquitto_pub -h localhost -t "shaperorigin/status/pm1:0" \
|
||||
-m '{"apower": 45.5, "timestamp": 1234567890}'
|
||||
```
|
||||
|
||||
**Prüfen in Odoo:**
|
||||
1. IoT Events: `ows.iot.event` Records
|
||||
2. MQTT Sessions: `mqtt.session` Records
|
||||
3. Logs: Bridge Logs zeigen erfolgreiche API Calls
|
||||
|
||||
### 7. Troubleshooting
|
||||
|
||||
**Problem: "ModuleNotFoundError: No module named 'requests'"**
|
||||
→ Docker Image neu bauen (ist schon in requirements.txt)
|
||||
|
||||
**Problem: "Connection refused to odoo-dev"**
|
||||
→ Prüfe dass Odoo Container läuft: `docker compose ps`
|
||||
|
||||
**Problem: "Database not found"**
|
||||
→ Korrekten DB-Namen in config.yaml.dev eintragen
|
||||
|
||||
**Problem: "Event UID constraint violation"**
|
||||
→ Normal bei Restarts - Bridge sendet Events nochmal
|
||||
→ API returned 409 (duplicate) → wird als Success behandelt
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Odoo UI
|
||||
- **IoT Events**: Settings → Technical → IoT Events
|
||||
- **MQTT Sessions**: MQTT → Sessions
|
||||
- **MQTT Devices**: MQTT → Devices
|
||||
|
||||
### Bridge Logs
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yaml logs -f iot-bridge
|
||||
```
|
||||
|
||||
### MQTT Messages (Debug)
|
||||
```bash
|
||||
mosquitto_sub -h localhost -t "shaperorigin/#" -v
|
||||
```
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] GET /ows/iot/config returns device list
|
||||
- [ ] POST /ows/iot/event creates ows.iot.event record
|
||||
- [ ] Duplicate event_uid returns 409
|
||||
- [ ] Bridge starts with use_mock=false
|
||||
- [ ] Bridge fetches config from Odoo
|
||||
- [ ] Bridge sends events to Odoo
|
||||
- [ ] mqtt.session is created for session_started event
|
||||
- [ ] mqtt.session is updated for session_updated event
|
||||
- [ ] mqtt.session is completed for session_stopped event
|
||||
- [ ] No errors in Bridge or Odoo logs
|
||||
|
||||
## 🎯 Nächste Schritte
|
||||
|
||||
Nach erfolgreichem Test:
|
||||
- Phase 2 Commit
|
||||
- Optional: API-Key Authentication hinzufügen
|
||||
- Optional: Health-Check Endpoint für Bridge
|
||||
- Phase 3: Production Deployment
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
|
|
|||
3
open_workshop_mqtt/controllers/__init__.py
Normal file
3
open_workshop_mqtt/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import iot_api
|
||||
273
open_workshop_mqtt/controllers/iot_api.py
Normal file
273
open_workshop_mqtt/controllers/iot_api.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# -*- 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
|
||||
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 ==========
|
||||
@http.route('/ows/iot/config', type='http', auth='public', methods=['GET'], csrf=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 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 ==========
|
||||
@http.route('/ows/iot/event', type='json', auth='public', methods=['POST'], csrf=False)
|
||||
def receive_iot_event(self, **kw):
|
||||
"""
|
||||
Receive IoT event from Bridge
|
||||
|
||||
Expected JSON Body:
|
||||
{
|
||||
"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:
|
||||
# Parse JSON body
|
||||
data = request.jsonrequest
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['event_uid', 'event_type', 'device_id', 'timestamp']
|
||||
missing_fields = [f for f in required_fields if f not in data]
|
||||
if missing_fields:
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': f"Missing required fields: {', '.join(missing_fields)}",
|
||||
'code': 400
|
||||
}
|
||||
|
||||
# Check for duplicate event_uid
|
||||
existing_event = request.env['ows.iot.event'].sudo().search([
|
||||
('event_uid', '=', data['event_uid'])
|
||||
], limit=1)
|
||||
|
||||
if existing_event:
|
||||
_logger.info(f"Duplicate event UID: {data['event_uid']} - returning 409")
|
||||
return {
|
||||
'status': 'duplicate',
|
||||
'message': 'Event UID already exists',
|
||||
'event_uid': data['event_uid'],
|
||||
'code': 409
|
||||
}
|
||||
|
||||
# Create event record
|
||||
event_vals = {
|
||||
'event_uid': data['event_uid'],
|
||||
'event_type': data['event_type'],
|
||||
'device_id': data['device_id'],
|
||||
'session_id': data.get('session_id'),
|
||||
'timestamp': data['timestamp'],
|
||||
'payload_json': json.dumps(data.get('payload', {}), 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 == 'session_updated' and session:
|
||||
# Update existing session
|
||||
update_vals = {
|
||||
'last_message_time': event.timestamp,
|
||||
'current_power_w': payload.get('power_w', session.current_power_w),
|
||||
}
|
||||
|
||||
# Update durations if present
|
||||
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'])
|
||||
if 'state' in payload:
|
||||
update_vals['current_state'] = payload['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))
|
||||
|
|
@ -18,8 +18,10 @@ class MQTTConfig:
|
|||
|
||||
@dataclass
|
||||
class OdooConfig:
|
||||
url: Optional[str] = None
|
||||
token: Optional[str] = None
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -91,8 +91,15 @@ def main():
|
|||
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", url=config.odoo.url)
|
||||
odoo_client = OdooClient(config.odoo.url, config.odoo.token)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -76,19 +76,109 @@ class MockOdooClient:
|
|||
|
||||
|
||||
class OdooClient:
|
||||
"""Real Odoo API client (to be implemented)."""
|
||||
"""Real Odoo API client using REST API."""
|
||||
|
||||
def __init__(self, url: str, token: str):
|
||||
self.url = url
|
||||
self.token = token
|
||||
logger.info("OdooClient initialized for %s", url)
|
||||
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."""
|
||||
# TODO: Implement HTTP GET to /ows/iot/config
|
||||
raise NotImplementedError("Real Odoo client not yet implemented")
|
||||
"""
|
||||
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."""
|
||||
# TODO: Implement HTTP POST to /ows/iot/event
|
||||
raise NotImplementedError("Real Odoo client not yet implemented")
|
||||
"""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,3 +4,4 @@ from . import mqtt_connection
|
|||
from . import mqtt_device
|
||||
from . import mqtt_session
|
||||
from . import mqtt_message
|
||||
from . import iot_event
|
||||
|
|
|
|||
196
open_workshop_mqtt/models/iot_event.py
Normal file
196
open_workshop_mqtt/models/iot_event.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# -*- 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'),
|
||||
('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
|
||||
})
|
||||
|
|
@ -20,6 +20,12 @@ class MqttDevice(models.Model):
|
|||
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'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ access_mqtt_connection_user,mqtt.connection.user,model_mqtt_connection,base.grou
|
|||
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_mqtt_message_user,mqtt.message.user,model_mqtt_message,base.group_user,1,0,0,1
|
||||
access_iot_event_user,ows.iot.event.user,model_ows_iot_event,base.group_user,1,0,0,0
|
||||
access_mqtt_connection_system,mqtt.connection.system,model_mqtt_connection,base.group_system,1,1,1,1
|
||||
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_mqtt_message_system,mqtt.message.system,model_mqtt_message,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
|
||||
|
|
|
|||
|
Loading…
Reference in New Issue
Block a user