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:
Matthias Lotz 2026-02-05 16:43:26 +01:00
parent 989720ed21
commit c55b0e59d2
12 changed files with 903 additions and 41 deletions

View File

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

View 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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -4,3 +4,4 @@ from . import mqtt_connection
from . import mqtt_device
from . import mqtt_session
from . import mqtt_message
from . import iot_event

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

View File

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

View File

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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_mqtt_device_user mqtt.device.user model_mqtt_device base.group_user 1 1 1 1
4 access_mqtt_session_user mqtt.session.user model_mqtt_session base.group_user 1 0 0 0
5 access_mqtt_message_user mqtt.message.user model_mqtt_message base.group_user 1 0 0 1
6 access_iot_event_user ows.iot.event.user model_ows_iot_event base.group_user 1 0 0 0
7 access_mqtt_connection_system mqtt.connection.system model_mqtt_connection base.group_system 1 1 1 1
8 access_mqtt_device_system mqtt.device.system model_mqtt_device base.group_system 1 1 1 1
9 access_mqtt_session_system mqtt.session.system model_mqtt_session base.group_system 1 1 1 1
10 access_mqtt_message_system mqtt.message.system model_mqtt_message base.group_system 1 1 1 1
11 access_iot_event_system ows.iot.event.system model_ows_iot_event base.group_system 1 1 1 1