IOT Bridge noch nicht so richtig funktionsfähig. Odoo MQTT muss noch aufgeräumt werden

This commit is contained in:
Matthias Lotz 2026-02-07 19:45:48 +01:00
parent d0b7a5737d
commit 9b9b8d025e
18 changed files with 347 additions and 899 deletions

46
Checkliste.md Normal file
View File

@ -0,0 +1,46 @@
# ✅ OpenWorkshop Test Checkliste
## 🔹 1. Migration
- [x] `migrate_existing_partners()` erzeugt zu jedem Partner genau einen `ows.user`.
- [x] `migrate_existing_partners()` übernimmt korrekt alte `vvow_*`-Felder.
- [x] `migrate_machine_access_from_old_fields()` erstellt korrekte Einträge in `ows.machine.access`.
- [x] `migrate_machine_access_from_old_fields()` übernimmt das Änderungsdatum aus `mail.tracking.value`.
## 🔹 2. Kontakte Backend
- [x] Beim Anlegen eines neuen Partners wird automatisch ein `ows.user` angelegt.
- [ ] Beim Speichern eines Partners wird überprüft, ob ein `ows.user` existiert, und ggf. angelegt.
- [x] Änderungen an Geburtstag, RFID, Haftung in Partner-Formular schreiben korrekt in `ows.user`.
- [x] Die Werte aus `ows.user` werden korrekt im Partnerformular angezeigt (via `compute`).
- [x] Das HTML-Widget mit Maschinenfreigaben (`machine_access_html`) wird korrekt dargestellt.
## 🔹 3. POS-Integration
- [x] Felder aus `ows.user` (Geburtstag, RFID etc.) erscheinen im POS-Kunden-Popup.
- [x] Maschinenfreigaben erscheinen im POS-Layout korrekt gruppiert nach Bereichen.
- [x] Farben der Maschinenbereiche werden korrekt aus `color_hex` übernommen.
## 🔹 4. Maschinenverwaltung
- [x] Maschinen-Formular zeigt Nutzungs- und Einweisungsprodukte korrekt an.
- [?] Drop-downs in den Produktlisten zeigen nur Produkte der richtigen Kategorie.
- [?] Neue Zuordnungen können direkt in den Tree-Ansichten editiert werden.
- [?] Filter greifen korrekt (Maschinennutzung / Einweisungen).
- [ ] In der Ansicht Wartung - Ausrüstung - Tab Offene Werkstatt (hobbyhimmel) wird unter Nutzungsprodukte und Einweisungsprodukte nur die Auswahl des Produkts angezeigt, eine Zuordnung zu einer Maschine ist automatisch die aktelle ausgewählte Maschine des Formulars.
## 🔹 5. Menüstruktur
- [ ] Menüeinträge "Nutzungsprodukte" und "Einweisungsprodukte" erscheinen unter Wartung > Konfiguration > Zuordnungen.
## 🔹 6. CSV/XML Demo-/Initialdaten
- [x] Maschinenbereiche (`ows.machine.area`) sind korrekt aus `data.xml` geladen.
- [x] Maschinen und ihre Produkt-Zuordnungen sind vollständig.
- [x] Kategorien und Produkte sind korrekt verknüpft (`product.category`, `product.product`).
## 🔹 7. Systemweite Konsistenz
- [x] Es gibt keine doppelten `ows.user`-Einträge.
- [bedingt] Kein Partner existiert ohne zugehörigen `ows.user`.
- [bedingt] `res.partner.ows_user_id` ist immer gefüllt.
## 🔹 8. Technische Qualität
- [ ] Kein `@api.depends('id')` mehr vorhanden.
- [ ] Commit wird in Migrationsfunktionen korrekt gesetzt (`self.env.cr.commit()`).
- [ ] Keine toten `vvow_*` Felder mehr im Modell (wenn auf ows.user umgestellt).
- [ ] post-init und pre-load Skripte laufen fehlerfrei bei Neuinstallation.

View File

@ -1,11 +0,0 @@
{
"folders": [
{
"path": "."
},
{
"path": "../../../usr/lib/python3/dist-packages/odoo"
}
],
"settings": {}
}

View File

@ -205,25 +205,66 @@ curl -X POST http://localhost:8069/ows/iot/event -d '{...}'
---
### 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
- [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
**Test:** ⏳ Bereit zum Testen - siehe PHASE2_TESTING.md
- Neue Session erstellen
- `session_id` von Event übernehmen
- [ ] `session_heartbeat` Handler:
- Session finden (via `session_id`)
- `total_working_time_s += interval_working_s`
- `billing_units` neu berechnen
- [ ] `session_ended` Handler:
- Session schließen (`state = 'stopped'`)
**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)
**Test:** Mock-Events via curl → Session erscheint in Odoo UI → Billing korrekt
**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
---

View File

@ -28,6 +28,7 @@
'license': 'LGPL-3',
'depends': [
'base',
'web', # Needed for HTTP controllers
],
'external_dependencies': {
'python': [
@ -36,13 +37,9 @@
},
'data': [
'security/ir.model.access.csv',
# Actions first (so other views can reference them)
'views/mqtt_device_views.xml',
'views/mqtt_session_views.xml',
'views/mqtt_message_views.xml',
# Then views that reference actions
'views/mqtt_connection_views.xml',
# Menus last (need all actions to exist)
'views/iot_event_views.xml',
'views/mqtt_menus.xml',
],
'demo': [],

View File

@ -0,0 +1,34 @@
#!/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

@ -5,7 +5,7 @@ Provides endpoints for IoT Bridge to fetch config and send events
"""
from odoo import http, _
from odoo.http import request, Response
from odoo.http import request, Response, route
import json
import logging
from datetime import datetime
@ -17,7 +17,7 @@ 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)
@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
@ -46,6 +46,17 @@ class IotApiController(http.Controller):
}
"""
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)
@ -100,12 +111,13 @@ class IotApiController(http.Controller):
)
# ========== POST /ows/iot/event ==========
@http.route('/ows/iot/event', type='json', auth='public', methods=['POST'], csrf=False)
def receive_iot_event(self, **kw):
@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 Body:
Expected JSON-RPC params:
{
"event_uid": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "session_started",
@ -126,41 +138,50 @@ class IotApiController(http.Controller):
- 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:
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_fields)}",
'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', '=', data['event_uid'])
('event_uid', '=', event_uid)
], limit=1)
if existing_event:
_logger.info(f"Duplicate event UID: {data['event_uid']} - returning 409")
_logger.info(f"Duplicate event UID: {event_uid} - returning 409")
return {
'status': 'duplicate',
'message': 'Event UID already exists',
'event_uid': data['event_uid'],
'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': 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_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)
@ -225,22 +246,40 @@ class IotApiController(http.Controller):
else:
_logger.warning(f"Device not found for event: {event.device_id}")
elif event.event_type == 'session_updated' and session:
elif event.event_type in ['session_updated', 'session_heartbeat'] 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
# 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'])
if 'state' in payload:
update_vals['current_state'] = payload['state']
# 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}")

View File

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

View File

@ -35,6 +35,7 @@ class IotEvent(models.Model):
('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,

View File

@ -1,320 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
import logging
_logger = logging.getLogger(__name__)
class MqttConnection(models.Model):
_name = 'mqtt.connection'
_description = 'MQTT Broker Connection'
_rec_name = 'name'
_order = 'name'
# ========== Basic Fields ==========
name = fields.Char(
string='Name',
required=True,
default='MQTT Broker',
help='Connection name for identification'
)
active = fields.Boolean(
default=True,
help='Deactivate to disable this connection'
)
# ========== Connection Configuration ==========
host = fields.Char(
string='Broker Host',
required=True,
default='localhost',
help='MQTT Broker hostname or IP address (without protocol prefix like mqtt://)'
)
port = fields.Char(
string='Broker Port',
required=True,
default='8883',
help='MQTT Broker port (1883 for non-TLS, 8883 for TLS)'
)
username = fields.Char(
string='Username',
help='MQTT authentication username'
)
password = fields.Char(
string='Password',
help='MQTT authentication password'
)
use_tls = fields.Boolean(
string='Use TLS/SSL',
default=True,
help='Enable TLS/SSL encryption (recommended)'
)
verify_cert = fields.Boolean(
string='Verify Certificate',
default=False,
help='Verify SSL certificate (disable for self-signed certificates)'
)
ca_cert_path = fields.Char(
string='CA Certificate Path',
help='Optional: Path to custom CA certificate file'
)
client_id = fields.Char(
string='Client ID',
default='odoo_mqtt',
required=True,
help='Unique MQTT client identifier'
)
# ========== Auto-Reconnect Configuration ==========
auto_reconnect = fields.Boolean(
string='Auto Reconnect',
default=True,
help='Automatically reconnect on connection loss'
)
reconnect_delay_min = fields.Integer(
string='Min Reconnect Delay (s)',
default=1,
help='Initial reconnect delay in seconds'
)
reconnect_delay_max = fields.Integer(
string='Max Reconnect Delay (s)',
default=60,
help='Maximum reconnect delay (exponential backoff)'
)
# ========== Connection Status (Runtime) ==========
state = fields.Selection([
('stopped', 'Stopped'),
('connecting', 'Connecting...'),
('connected', 'Connected'),
('error', 'Error')
], string='Status', default='stopped', readonly=True,
help='Current connection status')
last_connected = fields.Datetime(
string='Last Connected',
readonly=True,
help='Timestamp of last successful connection'
)
last_error = fields.Text(
string='Last Error',
readonly=True,
help='Last connection error message'
)
# ========== Relations ==========
device_ids = fields.One2many(
'mqtt.device',
'connection_id',
string='Devices',
help='MQTT devices using this connection'
)
# ========== Session Cleanup Configuration ==========
session_cleanup_hours = fields.Integer(
string='Session Cleanup (h)',
default=24,
required=True,
help='Hours after last message before a running session is marked as timeout after Odoo restart'
)
# ========== Computed Fields ==========
device_count = fields.Integer(
string='Device Count',
compute='_compute_counts',
store=True
)
active_device_count = fields.Integer(
string='Active Devices',
compute='_compute_counts',
store=True
)
# ========== Compute Methods ==========
@api.depends('device_ids', 'device_ids.active')
def _compute_counts(self):
for connection in self:
connection.device_count = len(connection.device_ids)
connection.active_device_count = len(connection.device_ids.filtered('active'))
# ========== Constraints ==========
@api.constrains('port')
def _check_port(self):
for connection in self:
port = int(connection.port) if connection.port else 0
if port < 1 or port > 65535:
raise ValidationError(_('Port must be between 1 and 65535'))
@api.constrains('reconnect_delay_min', 'reconnect_delay_max')
def _check_reconnect_delays(self):
for connection in self:
if connection.reconnect_delay_min < 1:
raise ValidationError(_('Minimum reconnect delay must be at least 1 second'))
if connection.reconnect_delay_max < connection.reconnect_delay_min:
raise ValidationError(_('Maximum delay must be greater than minimum delay'))
# ========== Action Methods ==========
def action_start(self):
"""Start MQTT connection - DEPRECATED: Use standalone IoT Bridge container"""
self.ensure_one()
raise UserError(_(
'The integrated MQTT service has been removed.\n\n'
'Please use the standalone IoT Bridge container instead.\n'
'See docker-compose.dev.yaml for setup instructions.'
))
def action_stop(self):
"""Stop MQTT connection - DEPRECATED: Use standalone IoT Bridge container"""
self.ensure_one()
raise UserError(_(
'The integrated MQTT service has been removed.\n\n'
'Please use the standalone IoT Bridge container instead.\n'
'See docker-compose.dev.yaml for setup instructions.'
))
def action_test_connection(self):
"""Test MQTT connection"""
self.ensure_one()
try:
import paho.mqtt.client as mqtt
import ssl
import time
_logger.info(f"Testing MQTT connection: {self.name} ({self.host}:{self.port})")
# Connection test result
test_result = {'connected': False, 'error': None}
def on_connect(client, userdata, flags, rc, properties=None):
"""Callback when connection is established"""
if rc == 0:
test_result['connected'] = True
_logger.info(f"Test connection successful: {self.name}")
else:
error_messages = {
1: 'Connection refused - incorrect protocol version',
2: 'Connection refused - invalid client identifier',
3: 'Connection refused - server unavailable',
4: 'Connection refused - bad username or password',
5: 'Connection refused - not authorized',
}
test_result['error'] = error_messages.get(rc, f'Connection refused - code {rc}')
_logger.error(f"Test connection failed: {test_result['error']}")
# Create test client
client = mqtt.Client(
client_id=f"{self.client_id}_test_{int(time.time())}",
protocol=mqtt.MQTTv5
)
# Set callbacks
client.on_connect = on_connect
# Configure authentication
if self.username:
client.username_pw_set(self.username, self.password or '')
# Configure TLS/SSL
if self.use_tls:
tls_context = ssl.create_default_context()
# Handle certificate verification
if not self.verify_cert:
tls_context.check_hostname = False
tls_context.verify_mode = ssl.CERT_NONE
_logger.warning(f"SSL certificate verification disabled for {self.name}")
# Load custom CA certificate if provided
if self.ca_cert_path:
try:
tls_context.load_verify_locations(cafile=self.ca_cert_path)
_logger.info(f"Loaded CA certificate from {self.ca_cert_path}")
except Exception as ca_error:
raise UserError(_(f'Failed to load CA certificate: {ca_error}'))
client.tls_set_context(tls_context)
# Attempt connection
try:
port_int = int(self.port)
except ValueError:
raise UserError(_(f'Invalid port number: {self.port}'))
client.connect(self.host, port_int, keepalive=60)
# Run loop for up to 5 seconds to allow connection
client.loop_start()
# Wait for connection result (max 5 seconds)
timeout = 5
start_time = time.time()
while not test_result['connected'] and not test_result['error']:
if time.time() - start_time > timeout:
test_result['error'] = 'Connection timeout - broker not responding'
break
time.sleep(0.1)
# IMPORTANT: Stop loop and disconnect properly
try:
client.loop_stop()
client.disconnect()
# Give it time to cleanup
time.sleep(0.2)
except Exception as cleanup_error:
_logger.warning(f"Error during test cleanup: {cleanup_error}")
# Return result
if test_result['connected']:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Successful'),
'message': _('Successfully connected to MQTT broker %s:%s') % (self.host, self.port),
'type': 'success',
'sticky': False,
}
}
else:
error_msg = test_result.get('error', 'Unknown connection error')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': error_msg,
'type': 'danger',
'sticky': True,
}
}
except Exception as e:
_logger.error(f"Connection test failed: {e}", exc_info=True)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Connection Test Failed'),
'message': str(e),
'type': 'danger',
'sticky': True,
}
}
# ========== Auto-Start on Odoo Restart ==========
@api.model
def _register_hook(self):
"""Auto-start disabled - using standalone IoT Bridge container"""
res = super()._register_hook()
_logger.info("MQTT auto-start disabled - use standalone IoT Bridge container")
return res
# ========== CRUD Overrides ==========
@api.ondelete(at_uninstall=False)
def _unlink_if_not_connected(self):
"""Prevent deletion of connected connections"""
if any(connection.state == 'connected' for connection in self):
raise UserError(_('Cannot delete a running connection. Please stop it first.'))

View File

@ -30,13 +30,6 @@ class MqttDevice(models.Model):
default=True,
help='Deactivate to stop monitoring this device'
)
connection_id = fields.Many2one(
'mqtt.connection',
string='MQTT Connection',
required=True,
ondelete='cascade',
help='MQTT broker connection to use'
)
# ========== Status ==========
state = fields.Selection([
@ -120,12 +113,6 @@ class MqttDevice(models.Model):
string='Sessions',
help='Runtime sessions for this device'
)
message_ids = fields.One2many(
'mqtt.message',
'device_id',
string='Messages',
help='Message log (for debugging)'
)
# ========== Computed Statistics ==========
session_count = fields.Integer(
@ -152,22 +139,23 @@ class MqttDevice(models.Model):
)
# ========== Compute Methods ==========
@api.depends('message_ids', 'message_ids.create_date')
@api.depends('session_ids', 'session_ids.last_message_time')
def _compute_state(self):
"""Compute device online/offline state based on last message"""
"""Compute device online/offline state based on last session activity"""
from datetime import datetime, timedelta
for device in self:
# Find last message
last_msg = self.env['mqtt.message'].search([
('device_id', '=', device.id)
], limit=1, order='create_date desc')
# 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_msg:
device.last_seen = last_msg.create_date
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_msg.create_date >= threshold else 'offline'
device.state = 'online' if last_session.last_message_time >= threshold else 'offline'
else:
device.last_seen = False
device.state = 'offline'
@ -220,19 +208,6 @@ class MqttDevice(models.Model):
except json.JSONDecodeError as e:
raise ValidationError(_('Invalid JSON format: %s') % str(e))
@api.constrains('device_id')
def _check_device_id_unique(self):
"""Ensure device_id is unique per connection"""
for device in self:
if self.search_count([
('id', '!=', device.id),
('connection_id', '=', device.connection_id.id),
('device_id', '=', device.device_id)
]) > 0:
raise ValidationError(
_('Device ID "%s" already exists for this connection') % device.device_id
)
# ========== CRUD Hooks ==========
def write(self, vals):
"""
@ -291,18 +266,6 @@ class MqttDevice(models.Model):
'context': {'default_device_id': self.id},
}
def action_view_messages(self):
"""Open message log for this device"""
self.ensure_one()
return {
'name': _('Messages - %s') % self.name,
'type': 'ir.actions.act_window',
'res_model': 'mqtt.message',
'view_mode': 'tree,form',
'domain': [('device_id', '=', self.id)],
'context': {'default_device_id': self.id},
}
def action_start_manual_session(self):
"""Manually start a session"""
self.ensure_one()

View File

@ -1,166 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class MqttMessage(models.Model):
_name = 'mqtt.message'
_description = 'MQTT Message Log'
_rec_name = 'topic'
_order = 'create_date desc'
# Note: _log_access = True by default, provides create_date/write_date
# ========== Basic Fields ==========
connection_id = fields.Many2one(
'mqtt.connection',
string='Connection',
index=True,
ondelete='cascade',
help='MQTT connection that received this message'
)
device_id = fields.Many2one(
'mqtt.device',
string='Device',
ondelete='cascade',
index=True,
help='MQTT device that received this message'
)
device_name = fields.Char(
string='Device Name',
related='device_id.name',
store=True,
readonly=True
)
# ========== Message Data ==========
topic = fields.Char(
string='Topic',
required=True,
index=True,
help='MQTT topic'
)
payload = fields.Text(
string='Payload',
help='Raw message payload (usually JSON)'
)
parsed_data = fields.Text(
string='Parsed Data',
help='Parsed and normalized data'
)
qos = fields.Integer(
string='QoS',
default=0,
help='MQTT Quality of Service (0, 1, or 2)'
)
retain = fields.Boolean(
string='Retain',
default=False,
help='MQTT retain flag'
)
direction = fields.Selection([
('inbound', 'Inbound'),
('outbound', 'Outbound'),
], string='Direction', default='inbound', required=True,
help='Message direction (inbound from broker, outbound to broker)')
# ========== Timestamps ==========
create_date = fields.Datetime(
string='Received At',
readonly=True,
index=True,
help='Timestamp when message was received'
)
# ========== Computed Fields ==========
payload_preview = fields.Char(
string='Payload Preview',
compute='_compute_payload_preview',
help='First 100 characters of payload'
)
# ========== Compute Methods ==========
@api.depends('payload')
def _compute_payload_preview(self):
"""Create preview of payload"""
for message in self:
if message.payload:
message.payload_preview = (message.payload[:100] + '...') if len(message.payload) > 100 else message.payload
else:
message.payload_preview = '-'
# ========== CRUD Methods ==========
@api.model
def create(self, vals):
"""Override create to enforce message limit per device"""
message = super().create(vals)
# Keep only last N messages per device (configurable, default 1000)
max_messages = self.env['ir.config_parameter'].sudo().get_param(
'mqtt.max_messages_per_device', default='1000'
)
try:
max_messages = int(max_messages)
except ValueError:
max_messages = 1000
# Delete old messages if limit exceeded
device_messages = self.search([
('device_id', '=', message.device_id.id)
], order='create_date desc', offset=max_messages)
if device_messages:
device_messages.unlink()
return message
@api.model
def log_message(self, device_id, topic, payload, parsed_data=None):
"""
Convenience method to log MQTT message
Args:
device_id: int (Odoo ID)
topic: str
payload: str or dict
parsed_data: dict (optional)
"""
import json
# Convert payload to string if dict
if isinstance(payload, dict):
payload = json.dumps(payload, indent=2)
# Convert parsed_data to string if dict
if isinstance(parsed_data, dict):
parsed_data = json.dumps(parsed_data, indent=2)
return self.create({
'device_id': device_id,
'topic': topic,
'payload': payload,
'parsed_data': parsed_data,
})
@api.model
def cleanup_old_messages(self, days=7):
"""
Cleanup old messages (called by cron)
Args:
days: int - Delete messages older than this many days
"""
from datetime import datetime, timedelta
cutoff_date = datetime.now() - timedelta(days=days)
old_messages = self.search([
('create_date', '<', cutoff_date)
])
count = len(old_messages)
old_messages.unlink()
_logger.info(f"Cleaned up {count} old MQTT messages (older than {days} days)")
return count

View File

@ -88,6 +88,8 @@ python main.py --config custom_config.yaml
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

View File

@ -1,11 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mqtt_connection_user,mqtt.connection.user,model_mqtt_connection,base.group_user,1,1,1,1
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
access_mqtt_connection_user mqtt.connection.user model_mqtt_connection base.group_user 1 1 1 1
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
access_mqtt_message_user mqtt.message.user model_mqtt_message base.group_user 1 0 0 1
4 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
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
access_mqtt_message_system mqtt.message.system model_mqtt_message 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

@ -0,0 +1,113 @@
<?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,176 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ========== List View ========== -->
<record id="view_mqtt_connection_list" model="ir.ui.view">
<field name="name">mqtt.connection.list</field>
<field name="model">mqtt.connection</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="host"/>
<field name="port"/>
<field name="state"
decoration-success="state == 'connected'"
decoration-warning="state == 'connecting'"
decoration-danger="state == 'error'"
decoration-muted="state == 'stopped'"
widget="badge"/>
<field name="device_count"/>
<field name="active_device_count"/>
<field name="last_connected"/>
</list>
</field>
</record>
<!-- ========== Form View ========== -->
<record id="view_mqtt_connection_form" model="ir.ui.view">
<field name="name">mqtt.connection.form</field>
<field name="model">mqtt.connection</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_start"
string="Start"
type="object"
class="btn-primary"
invisible="state == 'connected'"/>
<button name="action_stop"
string="Stop"
type="object"
class="btn-secondary"
invisible="state != 'connected'"/>
<button name="action_test_connection"
string="Test Connection"
type="object"
class="btn-secondary"/>
<field name="state" widget="statusbar"
statusbar_visible="stopped,connecting,connected"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="%(action_mqtt_device)d"
type="action"
class="oe_stat_button"
icon="fa-microchip"
context="{'default_connection_id': id}">
<field name="device_count"
widget="statinfo"
string="Devices"/>
</button>
<button name="%(action_mqtt_device)d"
type="action"
class="oe_stat_button"
icon="fa-power-off"
context="{'default_connection_id': id, 'search_default_active': 1}">
<field name="active_device_count"
widget="statinfo"
string="Active"/>
</button>
</div>
<widget name="web_ribbon"
title="Connected"
bg_color="bg-success"
invisible="state != 'connected'"/>
<widget name="web_ribbon"
title="Error"
bg_color="bg-danger"
invisible="state != 'error'"/>
<group>
<group string="Connection">
<field name="name"/>
<field name="active"/>
<field name="host"/>
<field name="port"/>
<field name="use_tls"/>
</group>
<group string="Authentication">
<field name="username"/>
<field name="password" password="True"/>
<field name="client_id"/>
</group>
</group>
<group string="Auto-Reconnect">
<group>
<field name="auto_reconnect"/>
</group>
<group invisible="not auto_reconnect">
<field name="reconnect_delay_min"/>
<field name="reconnect_delay_max"/>
</group>
</group>
<group string="Status" invisible="state == 'stopped'">
<group>
<field name="last_connected"/>
</group>
<group invisible="not last_error">
<field name="last_error"/>
</group>
</group>
<notebook>
<page string="Devices" name="devices">
<field name="device_ids">
<list>
<field name="name"/>
<field name="parser_type"/>
<field name="session_strategy"/>
<field name="state" widget="badge"/>
<field name="last_message_time"/>
<field name="session_count"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ========== Search View ========== -->
<record id="view_mqtt_connection_search" model="ir.ui.view">
<field name="name">mqtt.connection.search</field>
<field name="model">mqtt.connection</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="host"/>
<separator/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/>
<separator/>
<filter name="connected" string="Connected" domain="[('state', '=', 'connected')]"/>
<filter name="stopped" string="Stopped" domain="[('state', '=', 'stopped')]"/>
<filter name="error" string="Error" domain="[('state', '=', 'error')]"/>
<separator/>
<group expand="0" string="Group By">
<filter name="group_state" string="Status" context="{'group_by': 'state'}"/>
<filter name="group_tls" string="TLS" context="{'group_by': 'use_tls'}"/>
</group>
</search>
</field>
</record>
<!-- ========== Action ========== -->
<record id="action_mqtt_connection" model="ir.actions.act_window">
<field name="name">MQTT Connections</field>
<field name="res_model">mqtt.connection</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first MQTT Connection
</p>
<p>
Connect to your MQTT broker (e.g., Mosquitto) to start receiving device messages.
</p>
</field>
</record>
</odoo>

View File

@ -8,7 +8,7 @@
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="connection_id"/>
<field name="device_id"/>
<field name="parser_type"/>
<field name="state"
decoration-success="state == 'online'"
@ -59,15 +59,6 @@
widget="statinfo"
string="Runtime (h)"/>
</button>
<button name="action_view_messages"
type="object"
class="oe_stat_button"
icon="fa-envelope-o"
groups="base.group_no_one">
<div class="o_field_widget o_stat_info">
<span class="o_stat_text">Messages</span>
</div>
</button>
</div>
<widget name="web_ribbon"
@ -80,26 +71,28 @@
invisible="state != 'offline'"/>
<group>
<group string="Device">
<field name="id" readonly="1" string="Device ID"/>
<group string="Device Information">
<field name="name"/>
<field name="device_id" string="External Device ID"/>
<field name="active"/>
<field name="connection_id"/>
</group>
<group string="MQTT Configuration">
<field name="topic_pattern"/>
<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">
<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="ace"
options="{'mode': 'json'}"
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>
@ -214,7 +207,7 @@
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="connection_id"/>
<field name="device_id"/>
<separator/>
<filter name="active" string="Active" domain="[('active', '=', True)]"/>
<filter name="inactive" string="Inactive" domain="[('active', '=', False)]"/>
@ -230,7 +223,6 @@
<filter name="running_sessions" string="Running Sessions" domain="[('running_session_count', '>', 0)]"/>
<separator/>
<group expand="0" string="Group By">
<filter name="group_connection" string="Connection" context="{'group_by': 'connection_id'}"/>
<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'}"/>

View File

@ -8,12 +8,6 @@
web_icon="mqtt,static/description/icon.png"/>
<!-- ========== Main Menu Items ========== -->
<menuitem id="menu_mqtt_connections"
name="Connections"
parent="menu_mqtt_root"
action="action_mqtt_connection"
sequence="10"/>
<menuitem id="menu_mqtt_devices"
name="Devices"
parent="menu_mqtt_root"
@ -26,11 +20,11 @@
action="action_mqtt_session"
sequence="30"/>
<menuitem id="menu_mqtt_messages"
name="Messages"
<menuitem id="menu_mqtt_events"
name="IoT Events"
parent="menu_mqtt_root"
action="action_mqtt_message"
sequence="40"/>
action="action_ows_iot_event"
sequence="35"/>
<!-- ========== Configuration Submenu ========== -->
<menuitem id="menu_mqtt_config"
@ -38,12 +32,6 @@
parent="menu_mqtt_root"
sequence="100"/>
<menuitem id="menu_mqtt_config_connections"
name="Connections"
parent="menu_mqtt_config"
action="action_mqtt_connection"
sequence="10"/>
<menuitem id="menu_mqtt_config_devices"
name="Devices"
parent="menu_mqtt_config"

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ========== List View ========== -->
<record id="view_mqtt_message_list" model="ir.ui.view">
<field name="name">mqtt.message.list</field>
<field name="model">mqtt.message</field>
<field name="arch" type="xml">
<list create="false" edit="false" delete="true">
<field name="create_date"/>
<field name="device_name"/>
<field name="topic"/>
<field name="payload_preview"/>
</list>
</field>
</record>
<!-- ========== Form View ========== -->
<record id="view_mqtt_message_form" model="ir.ui.view">
<field name="name">mqtt.message.form</field>
<field name="model">mqtt.message</field>
<field name="arch" type="xml">
<form create="false" edit="false">
<sheet>
<group>
<group string="Message Info">
<field name="device_id"/>
<field name="topic"/>
<field name="create_date"/>
</group>
</group>
<group string="Raw Payload">
<field name="payload" widget="ace" options="{'mode': 'json'}" nolabel="1"/>
</group>
<group string="Parsed Data" invisible="not parsed_data">
<field name="parsed_data" widget="ace" options="{'mode': 'json'}" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- ========== Search View ========== -->
<record id="view_mqtt_message_search" model="ir.ui.view">
<field name="name">mqtt.message.search</field>
<field name="model">mqtt.message</field>
<field name="arch" type="xml">
<search>
<field name="device_id"/>
<field name="device_name"/>
<field name="topic"/>
<field name="payload"/>
<separator/>
<filter name="today" string="Today"
domain="[('create_date', '&gt;=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
<filter name="last_hour" string="Last Hour"
domain="[('create_date', '&gt;=', (datetime.datetime.now() - datetime.timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<filter name="last_10min" string="Last 10 Minutes"
domain="[('create_date', '&gt;=', (datetime.datetime.now() - datetime.timedelta(minutes=10)).strftime('%Y-%m-%d %H:%M:%S'))]"/>
<separator/>
<group expand="0" string="Group By">
<filter name="group_device" string="Device" context="{'group_by': 'device_id'}"/>
<filter name="group_topic" string="Topic" context="{'group_by': 'topic'}"/>
<filter name="group_hour" string="Hour" context="{'group_by': 'create_date:hour'}"/>
</group>
</search>
</field>
</record>
<!-- ========== Action ========== -->
<record id="action_mqtt_message" model="ir.actions.act_window">
<field name="name">MQTT Messages (Debug)</field>
<field name="res_model">mqtt.message</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_today': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No messages logged yet
</p>
<p>
MQTT messages will be logged here for debugging purposes.
Note: Only the most recent messages are kept.
</p>
</field>
</record>
</odoo>