IOT Bridge noch nicht so richtig funktionsfähig. Odoo MQTT muss noch aufgeräumt werden
This commit is contained in:
parent
d0b7a5737d
commit
9b9b8d025e
46
Checkliste.md
Normal file
46
Checkliste.md
Normal 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.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../../usr/lib/python3/dist-packages/odoo"
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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': [],
|
||||
|
|
|
|||
34
open_workshop_mqtt/check_routes.py
Normal file
34
open_workshop_mqtt/check_routes.py
Normal 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}")
|
||||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.'))
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
113
open_workshop_mqtt/views/iot_event_views.xml
Normal file
113
open_workshop_mqtt/views/iot_event_views.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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'}"/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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', '>=', context_today().strftime('%Y-%m-%d 00:00:00'))]"/>
|
||||
<filter name="last_hour" string="Last Hour"
|
||||
domain="[('create_date', '>=', (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', '>=', (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>
|
||||
Loading…
Reference in New Issue
Block a user