From 9b9b8d025e8f7bdd46dbbf4915c3a3504c0d6e2c Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sat, 7 Feb 2026 19:45:48 +0100 Subject: [PATCH] =?UTF-8?q?IOT=20Bridge=20noch=20nicht=20so=20richtig=20fu?= =?UTF-8?q?nktionsf=C3=A4hig.=20Odoo=20MQTT=20muss=20noch=20aufger=C3=A4um?= =?UTF-8?q?t=20werden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Checkliste.md | 46 +++ open_workshop.code-workspace | 11 - open_workshop_mqtt/IMPLEMENTATION_PLAN.md | 75 +++- open_workshop_mqtt/__manifest__.py | 7 +- open_workshop_mqtt/check_routes.py | 34 ++ open_workshop_mqtt/controllers/iot_api.py | 91 +++-- open_workshop_mqtt/models/__init__.py | 2 - open_workshop_mqtt/models/iot_event.py | 1 + open_workshop_mqtt/models/mqtt_connection.py | 320 ------------------ open_workshop_mqtt/models/mqtt_device.py | 57 +--- open_workshop_mqtt/models/mqtt_message.py | 166 --------- open_workshop_mqtt/python_prototype/README.md | 2 + .../security/ir.model.access.csv | 4 - open_workshop_mqtt/views/iot_event_views.xml | 113 +++++++ .../views/mqtt_connection_views.xml | 176 ---------- .../views/mqtt_device_views.xml | 32 +- open_workshop_mqtt/views/mqtt_menus.xml | 20 +- .../views/mqtt_message_views.xml | 89 ----- 18 files changed, 347 insertions(+), 899 deletions(-) create mode 100644 Checkliste.md delete mode 100644 open_workshop.code-workspace create mode 100644 open_workshop_mqtt/check_routes.py delete mode 100644 open_workshop_mqtt/models/mqtt_connection.py delete mode 100644 open_workshop_mqtt/models/mqtt_message.py create mode 100644 open_workshop_mqtt/views/iot_event_views.xml delete mode 100644 open_workshop_mqtt/views/mqtt_connection_views.xml delete mode 100644 open_workshop_mqtt/views/mqtt_message_views.xml diff --git a/Checkliste.md b/Checkliste.md new file mode 100644 index 0000000..7cd62e6 --- /dev/null +++ b/Checkliste.md @@ -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. \ No newline at end of file diff --git a/open_workshop.code-workspace b/open_workshop.code-workspace deleted file mode 100644 index e5e670b..0000000 --- a/open_workshop.code-workspace +++ /dev/null @@ -1,11 +0,0 @@ -{ - "folders": [ - { - "path": "." - }, - { - "path": "../../../usr/lib/python3/dist-packages/odoo" - } - ], - "settings": {} -} \ No newline at end of file diff --git a/open_workshop_mqtt/IMPLEMENTATION_PLAN.md b/open_workshop_mqtt/IMPLEMENTATION_PLAN.md index f5b4042..84c4041 100644 --- a/open_workshop_mqtt/IMPLEMENTATION_PLAN.md +++ b/open_workshop_mqtt/IMPLEMENTATION_PLAN.md @@ -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 --- diff --git a/open_workshop_mqtt/__manifest__.py b/open_workshop_mqtt/__manifest__.py index 8ea1085..65864a1 100644 --- a/open_workshop_mqtt/__manifest__.py +++ b/open_workshop_mqtt/__manifest__.py @@ -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': [], diff --git a/open_workshop_mqtt/check_routes.py b/open_workshop_mqtt/check_routes.py new file mode 100644 index 0000000..815abc9 --- /dev/null +++ b/open_workshop_mqtt/check_routes.py @@ -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}") diff --git a/open_workshop_mqtt/controllers/iot_api.py b/open_workshop_mqtt/controllers/iot_api.py index 6ff1339..485fc0c 100644 --- a/open_workshop_mqtt/controllers/iot_api.py +++ b/open_workshop_mqtt/controllers/iot_api.py @@ -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}") diff --git a/open_workshop_mqtt/models/__init__.py b/open_workshop_mqtt/models/__init__.py index a321051..00f843b 100644 --- a/open_workshop_mqtt/models/__init__.py +++ b/open_workshop_mqtt/models/__init__.py @@ -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 diff --git a/open_workshop_mqtt/models/iot_event.py b/open_workshop_mqtt/models/iot_event.py index 8691ee9..5181819 100644 --- a/open_workshop_mqtt/models/iot_event.py +++ b/open_workshop_mqtt/models/iot_event.py @@ -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, diff --git a/open_workshop_mqtt/models/mqtt_connection.py b/open_workshop_mqtt/models/mqtt_connection.py deleted file mode 100644 index ca2c04c..0000000 --- a/open_workshop_mqtt/models/mqtt_connection.py +++ /dev/null @@ -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.')) diff --git a/open_workshop_mqtt/models/mqtt_device.py b/open_workshop_mqtt/models/mqtt_device.py index cf81c9c..422cf35 100644 --- a/open_workshop_mqtt/models/mqtt_device.py +++ b/open_workshop_mqtt/models/mqtt_device.py @@ -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() diff --git a/open_workshop_mqtt/models/mqtt_message.py b/open_workshop_mqtt/models/mqtt_message.py deleted file mode 100644 index 6bc665a..0000000 --- a/open_workshop_mqtt/models/mqtt_message.py +++ /dev/null @@ -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 diff --git a/open_workshop_mqtt/python_prototype/README.md b/open_workshop_mqtt/python_prototype/README.md index f5c7296..9a0e82f 100644 --- a/open_workshop_mqtt/python_prototype/README.md +++ b/open_workshop_mqtt/python_prototype/README.md @@ -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 diff --git a/open_workshop_mqtt/security/ir.model.access.csv b/open_workshop_mqtt/security/ir.model.access.csv index 83b6b6d..020ac1c 100644 --- a/open_workshop_mqtt/security/ir.model.access.csv +++ b/open_workshop_mqtt/security/ir.model.access.csv @@ -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 diff --git a/open_workshop_mqtt/views/iot_event_views.xml b/open_workshop_mqtt/views/iot_event_views.xml new file mode 100644 index 0000000..6ffbd4b --- /dev/null +++ b/open_workshop_mqtt/views/iot_event_views.xml @@ -0,0 +1,113 @@ + + + + + + ows.iot.event.list + ows.iot.event + + + + + + + + + + + + + + + ows.iot.event.form + ows.iot.event + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + ows.iot.event.search + ows.iot.event + + + + + + + + + + + + + + + + + + + + + + + + + + + + + IoT Events + ows.iot.event + list,form + {'search_default_filter_unprocessed': 1} + +

+ No IoT Events yet +

+

+ Events are automatically created by the IoT Bridge when it detects
+ session state changes (started, updated, stopped, heartbeat). +

+
+
+ +
diff --git a/open_workshop_mqtt/views/mqtt_connection_views.xml b/open_workshop_mqtt/views/mqtt_connection_views.xml deleted file mode 100644 index 518bb59..0000000 --- a/open_workshop_mqtt/views/mqtt_connection_views.xml +++ /dev/null @@ -1,176 +0,0 @@ - - - - - - mqtt.connection.list - mqtt.connection - - - - - - - - - - - - - - - - mqtt.connection.form - mqtt.connection - -
-
-
- - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-
- - - - mqtt.connection.search - mqtt.connection - - - - - - - - - - - - - - - - - - - - - - - MQTT Connections - mqtt.connection - list,form - -

- Create your first MQTT Connection -

-

- Connect to your MQTT broker (e.g., Mosquitto) to start receiving device messages. -

-
-
- -
diff --git a/open_workshop_mqtt/views/mqtt_device_views.xml b/open_workshop_mqtt/views/mqtt_device_views.xml index fc82448..d854d43 100644 --- a/open_workshop_mqtt/views/mqtt_device_views.xml +++ b/open_workshop_mqtt/views/mqtt_device_views.xml @@ -8,7 +8,7 @@ - + - - - + + - - - + + - + +
+ Configuration is sent to IoT Bridge via REST API +
@@ -214,7 +207,7 @@ - + @@ -230,7 +223,6 @@ - diff --git a/open_workshop_mqtt/views/mqtt_menus.xml b/open_workshop_mqtt/views/mqtt_menus.xml index 6a8085c..ddc41d9 100644 --- a/open_workshop_mqtt/views/mqtt_menus.xml +++ b/open_workshop_mqtt/views/mqtt_menus.xml @@ -8,12 +8,6 @@ web_icon="mqtt,static/description/icon.png"/> - - - + action="action_ows_iot_event" + sequence="35"/> - - - - - - - mqtt.message.list - mqtt.message - - - - - - - - - - - - - mqtt.message.form - mqtt.message - -
- - - - - - - - - - - - - - - - - -
-
-
- - - - mqtt.message.search - mqtt.message - - - - - - - - - - - - - - - - - - - - - - - MQTT Messages (Debug) - mqtt.message - list,form - {'search_default_today': 1} - -

- No messages logged yet -

-

- MQTT messages will be logged here for debugging purposes. - Note: Only the most recent messages are kept. -

-
-
- -