From 59539e0201ec6fd5ad9bb734989259c3128bb772 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sun, 25 Jan 2026 10:15:52 +0100 Subject: [PATCH] WIP: MQTT Tests - Mocked approach created but needs better testing strategy - Created test_mqtt_mocked.py with unittest.mock (following OCA patterns) - Old tests with real MQTT broker hang in TransactionCase tearDown - Created run-tests.sh following OCA/oca-ci best practices - TODO: Find proper way to test MQTT with background threads in Odoo - TODO: Either fully mock or use different test approach (not TransactionCase) --- open_workshop_mqtt/models/mqtt_connection.py | 18 +- open_workshop_mqtt/models/mqtt_device.py | 26 ++ open_workshop_mqtt/models/mqtt_session.py | 3 +- .../tests/tools/shelly_simulator.py | 14 +- open_workshop_mqtt/run-tests.sh | 68 ++++ .../services/iot_bridge_service.py | 328 +++++++++++++----- open_workshop_mqtt/services/mqtt_client.py | 10 +- open_workshop_mqtt/tests/__init__.py | 5 + open_workshop_mqtt/tests/common.py | 163 +++++++++ .../tests/test_device_status.py | 82 +++++ .../tests/test_mqtt_connection.py | 65 ++++ open_workshop_mqtt/tests/test_mqtt_mocked.py | 115 ++++++ .../tests/test_session_detection.py | 113 ++++++ .../views/mqtt_device_views.xml | 1 + 14 files changed, 921 insertions(+), 90 deletions(-) create mode 100755 open_workshop_mqtt/run-tests.sh create mode 100644 open_workshop_mqtt/tests/__init__.py create mode 100644 open_workshop_mqtt/tests/common.py create mode 100644 open_workshop_mqtt/tests/test_device_status.py create mode 100644 open_workshop_mqtt/tests/test_mqtt_connection.py create mode 100644 open_workshop_mqtt/tests/test_mqtt_mocked.py create mode 100644 open_workshop_mqtt/tests/test_session_detection.py diff --git a/open_workshop_mqtt/models/mqtt_connection.py b/open_workshop_mqtt/models/mqtt_connection.py index a3377fb..80ab75d 100644 --- a/open_workshop_mqtt/models/mqtt_connection.py +++ b/open_workshop_mqtt/models/mqtt_connection.py @@ -135,7 +135,8 @@ class MqttConnection(models.Model): @api.constrains('port') def _check_port(self): for connection in self: - if connection.port < 1 or connection.port > 65535: + 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') @@ -350,17 +351,22 @@ class MqttConnection(models.Model): @api.model def auto_start_all_connections(self): """ - Auto-start all connections that were connected before Odoo restart - Similar to doanmandev/odoo-iot-mqtt auto_start_all_listeners() + Auto-start all connections that were running before Odoo restart + Searches for connections with last_connected timestamp (not state='connected'!) + because state gets reset during restart """ try: - connections = self.search([('state', '=', 'connected')]) + # Find connections that were running before restart (have last_connected timestamp) + connections = self.search([ + ('last_connected', '!=', False), + ('state', 'in', ['stopped', 'connecting', 'error']) # Any non-connected state + ]) if not connections: - _logger.info("No connections to auto-start") + _logger.info("No connections to auto-start (no previously connected connections found)") return - _logger.info(f"Auto-starting {len(connections)} MQTT connections...") + _logger.info(f"Auto-starting {len(connections)} MQTT connections that were running before restart...") for connection in connections: try: diff --git a/open_workshop_mqtt/models/mqtt_device.py b/open_workshop_mqtt/models/mqtt_device.py index d99995b..7dbf285 100644 --- a/open_workshop_mqtt/models/mqtt_device.py +++ b/open_workshop_mqtt/models/mqtt_device.py @@ -227,6 +227,32 @@ class MqttDevice(models.Model): _('Device ID "%s" already exists for this connection') % device.device_id ) + # ========== CRUD Hooks ========== + def write(self, vals): + """ + Auto-subscribe when device is added to running connection + or when topic_pattern changes + """ + # Track changes that require re-subscription + needs_resubscribe = 'connection_id' in vals or 'topic_pattern' in vals or 'active' in vals + + result = super().write(vals) + + if needs_resubscribe: + # Import here to avoid circular dependency + from ..services.iot_bridge_service import IoTBridgeService + + for device in self: + if device.active and device.connection_id.state == 'connected': + # Device is active and connection is running → subscribe + _logger.info(f"Auto-subscribing device {device.id} ({device.name}) to running connection") + IoTBridgeService.get_instance(self.env.registry, device.connection_id.database_name).subscribe_device( + device.connection_id.id, + device.id + ) + + return result + # ========== Default Values ========== @api.onchange('session_strategy') def _onchange_session_strategy(self): diff --git a/open_workshop_mqtt/models/mqtt_session.py b/open_workshop_mqtt/models/mqtt_session.py index 74fa987..72c7897 100644 --- a/open_workshop_mqtt/models/mqtt_session.py +++ b/open_workshop_mqtt/models/mqtt_session.py @@ -4,6 +4,7 @@ from odoo import models, fields, api, _ from odoo.exceptions import ValidationError import json import logging +import uuid _logger = logging.getLogger(__name__) @@ -20,7 +21,7 @@ class MqttSession(models.Model): required=True, readonly=True, index=True, - default=lambda self: self.env['ir.sequence'].next_by_code('mqtt.session') or 'NEW', + default=lambda self: str(uuid.uuid4()), help='Unique session identifier' ) device_id = fields.Many2one( diff --git a/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py b/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py index d5ec1b2..50fdf15 100755 --- a/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py +++ b/open_workshop_mqtt/python_prototype/tests/tools/shelly_simulator.py @@ -259,7 +259,7 @@ def main(): parser = argparse.ArgumentParser(description="Shelly PM Mini G3 MQTT Simulator") parser.add_argument( "--scenario", - choices=["standby", "working", "session_end", "full_session", "timeout"], + choices=["standby", "working", "session_end", "full_session", "timeout", "loop"], required=True, help="Test-Szenario" ) @@ -286,14 +286,15 @@ def main(): # If not all available, try config.yaml if not all(mqtt_config.values()): config_path = Path(__file__).parent.parent.parent / "config.yaml" + print(f"Lade MQTT Credentials aus {config_path}...") if config_path.exists(): try: with open(config_path, 'r') as f: config = yaml.safe_load(f) mqtt_section = config.get('mqtt', {}) mqtt_config = { - 'host': mqtt_section.get('broker_host'), - 'port': mqtt_section.get('broker_port'), + 'host': mqtt_section.get('host'), + 'port': mqtt_section.get('port'), 'username': mqtt_section.get('username'), 'password': mqtt_section.get('password') } @@ -350,6 +351,13 @@ def main(): simulator.scenario_full_session() elif args.scenario == "timeout": simulator.scenario_timeout() + elif args.scenario == "loop": + print("Starte Endlosschleife (STRG+C zum Beenden)...") + while True: + simulator.scenario_full_session() + else: + print(f"❌ Unbekanntes Szenario: {args.scenario}") + exit(1) print("\n" + "=" * 60) print("Test abgeschlossen. Prüfe sessions.json für Ergebnisse.") diff --git a/open_workshop_mqtt/run-tests.sh b/open_workshop_mqtt/run-tests.sh new file mode 100755 index 0000000..340e5d6 --- /dev/null +++ b/open_workshop_mqtt/run-tests.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Odoo Module Tests - Following OCA best practices +# Based on https://github.com/OCA/oca-ci +set -e + +MODULE="open_workshop_mqtt" +TIMEOUT=120 +ODOO_DIR="/home/lotzm/gitea.hobbyhimmel/odoo/odoo" +LOG_FILE="/tmp/odoo_test_${MODULE}_$(date +%Y%m%d_%H%M%S).log" + +cd "$ODOO_DIR" + +echo "=== Starting test containers ===" +docker compose -f docker-compose.dev.yaml up -d db +docker compose -f docker-compose.dev.yaml up -d odoo-dev + +echo "=== Waiting for database ===" +sleep 5 +until docker compose -f docker-compose.dev.yaml exec -T db pg_isready -U odoo > /dev/null 2>&1; do + echo "Waiting for PostgreSQL..." + sleep 2 +done + +echo "=== Running tests (timeout: ${TIMEOUT}s) ===" +timeout "$TIMEOUT" docker compose -f docker-compose.dev.yaml exec -T odoo-dev \ + /usr/bin/python3 /usr/bin/odoo \ + -c /etc/odoo/odoo.conf \ + --test-enable \ + --stop-after-init \ + -u "$MODULE" \ + --log-level=test \ + > "$LOG_FILE" 2>&1 + +EXIT_CODE=$? + +# Handle timeout +if [ $EXIT_CODE -eq 124 ]; then + echo "✗ TIMEOUT after ${TIMEOUT}s" + docker compose -f docker-compose.dev.yaml kill odoo-dev + EXIT_CODE=1 +fi + +# Show results +echo "" +echo "=== Test Results ===" +if grep -q "Modules loaded" "$LOG_FILE"; then + # Show test summary + grep -A 20 "running tests" "$LOG_FILE" | tail -20 || echo "No test output found" +else + echo "✗ Module failed to load" + tail -50 "$LOG_FILE" +fi + +echo "" +echo "Full log: $LOG_FILE" + +# Cleanup +echo "=== Stopping containers ===" +docker compose -f docker-compose.dev.yaml stop odoo-dev + +# Result +if [ $EXIT_CODE -eq 0 ]; then + echo "✓ PASSED" +else + echo "✗ FAILED (exit code: $EXIT_CODE)" +fi + +exit $EXIT_CODE diff --git a/open_workshop_mqtt/services/iot_bridge_service.py b/open_workshop_mqtt/services/iot_bridge_service.py index 6341ef2..64d4274 100644 --- a/open_workshop_mqtt/services/iot_bridge_service.py +++ b/open_workshop_mqtt/services/iot_bridge_service.py @@ -39,7 +39,26 @@ class IotBridgeService: self._running_lock = threading.Lock() self._parser = ShellyParser() # For now only Shelly - _logger.info("IoT Bridge Service initialized") + _logger.info(f"IoT Bridge Service initialized for database '{self.db_name}'") + + def cleanup(self): + """ + Cleanup all MQTT connections before registry reload + Called before instance is replaced + """ + _logger.info(f"Cleaning up IoT Bridge Service for '{self.db_name}' ({len(self._clients)} active connections)") + + with self._running_lock: + for connection_id in list(self._clients.keys()): + try: + client = self._clients[connection_id] + _logger.info(f"Stopping connection {connection_id} before reload") + client.disconnect() + del self._clients[connection_id] + except Exception as e: + _logger.error(f"Error stopping connection {connection_id} during cleanup: {e}") + + _logger.info(f"IoT Bridge Service cleanup completed for '{self.db_name}'") @classmethod def get_instance(cls, env) -> 'IotBridgeService': @@ -55,11 +74,62 @@ class IotBridgeService: db_name = env.cr.dbname with cls._lock: + # Check if registry has changed (reload happened) + registry_changed = False + if db_name in cls._instances: + old_instance = cls._instances[db_name] + if old_instance.registry != env.registry: + # Registry changed! Cleanup old instance + _logger.warning(f"Registry reload detected for '{db_name}' - cleaning up old instance") + old_instance.cleanup() + del cls._instances[db_name] + registry_changed = True + if db_name not in cls._instances: cls._instances[db_name] = cls(env) + + # If registry changed, restart connections + if registry_changed: + _logger.info(f"Restarting MQTT connections after registry reload") + cls._instances[db_name]._restart_connections_after_reload() return cls._instances[db_name] + def _restart_connections_after_reload(self): + """ + Restart all connections that were running before registry reload + Called automatically after registry reload is detected + """ + try: + # Use fresh cursor + with self.registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + + # Find connections that should be running + Connection = env['mqtt.connection'] + connections = Connection.search([ + ('last_connected', '!=', False), + ]) + + if not connections: + _logger.info("No connections to restart after reload") + return + + _logger.info(f"Restarting {len(connections)} MQTT connections after reload...") + + for connection in connections: + try: + _logger.info(f"Restarting connection: {connection.name}") + self.start_connection(connection.id) + except Exception as e: + _logger.error(f"Error restarting connection {connection.name}: {e}") + + cr.commit() + _logger.info(f"Successfully restarted {len(connections)} connections after reload") + + except Exception as e: + _logger.error(f"Error in _restart_connections_after_reload: {e}", exc_info=True) + def start_connection(self, connection_id: int) -> bool: """ Start MQTT connection for given connection_id @@ -81,60 +151,102 @@ class IotBridgeService: conn_data = None with self.registry.cursor() as new_cr: env = api.Environment(new_cr, SUPERUSER_ID, {}) - - # Load connection record - connection = env['mqtt.connection'].browse(connection_id) - if not connection.exists(): - _logger.error(f"Connection {connection_id} not found") - return False - - _logger.info(f"Starting MQTT connection: {connection.name}") - - # Store connection data (don't keep record object outside cursor) - conn_data = { - 'id': connection.id, - 'host': connection.host, - 'port': int(connection.port), - 'client_id': connection.client_id, - 'username': connection.username or None, - 'password': connection.password or None, - 'use_tls': connection.use_tls, - 'verify_cert': connection.verify_cert, - 'ca_cert_path': connection.ca_cert_path or None, - 'auto_reconnect': connection.auto_reconnect, - 'reconnect_delay_min': connection.reconnect_delay_min, - 'reconnect_delay_max': connection.reconnect_delay_max, - } - new_cr.commit() - # Cursor is now closed - safe to start MQTT thread - - # Create MQTT client with copied data - client = MqttClient( - connection_id=conn_data['id'], - host=conn_data['host'], - port=conn_data['port'], - client_id=conn_data['client_id'], - username=conn_data['username'], - password=conn_data['password'], - use_tls=conn_data['use_tls'], - verify_cert=conn_data['verify_cert'], - ca_cert_path=conn_data['ca_cert_path'], - auto_reconnect=conn_data['auto_reconnect'], - reconnect_delay_min=conn_data['reconnect_delay_min'], - reconnect_delay_max=conn_data['reconnect_delay_max'], - on_message_callback=self._on_message, - on_connect_callback=self._on_connect, - on_disconnect_callback=self._on_disconnect, - ) - - # Connect (will start background thread) - if client.connect(): - self._clients[connection_id] = client - - # Subscribe to device topics (needs fresh cursor) - self._subscribe_device_topics(connection_id) - - # Update connection state (needs fresh cursor) + return self._start_connection_internal(connection_id, env, use_new_cursor=True) + except Exception as e: + _logger.error(f"Error starting connection {connection_id}: {e}", exc_info=True) + return False + + def start_connection_with_env(self, connection_id: int, env) -> bool: + """ + Start MQTT connection using existing environment (for tests) + + Args: + connection_id: ID of mqtt.connection record + env: Odoo environment to use + + Returns: + bool: True if connection started successfully + """ + with self._running_lock: + # Check if already running + if connection_id in self._clients: + _logger.warning(f"Connection {connection_id} is already running") + return False + + try: + return self._start_connection_internal(connection_id, env, use_new_cursor=False) + except Exception as e: + _logger.error(f"Error starting connection {connection_id}: {e}", exc_info=True) + return False + + def _start_connection_internal(self, connection_id: int, env, use_new_cursor: bool) -> bool: + """ + Internal method to start MQTT connection + + Args: + connection_id: ID of mqtt.connection record + env: Environment to use + use_new_cursor: Whether to use new cursors for updates + + Returns: + bool: True if connection started successfully + """ + # Load connection record + connection = env['mqtt.connection'].browse(connection_id) + if not connection.exists(): + _logger.error(f"Connection {connection_id} not found") + return False + + _logger.info(f"Starting MQTT connection: {connection.name}") + + # Store connection data + conn_data = { + 'id': connection.id, + 'host': connection.host, + 'port': int(connection.port), + 'client_id': connection.client_id, + 'username': connection.username or None, + 'password': connection.password or None, + 'use_tls': connection.use_tls, + 'verify_cert': connection.verify_cert, + 'ca_cert_path': connection.ca_cert_path or None, + 'auto_reconnect': connection.auto_reconnect, + 'reconnect_delay_min': connection.reconnect_delay_min, + 'reconnect_delay_max': connection.reconnect_delay_max, + } + + # Create MQTT client + client = MqttClient( + connection_id=conn_data['id'], + host=conn_data['host'], + port=conn_data['port'], + client_id=conn_data['client_id'], + username=conn_data['username'], + password=conn_data['password'], + use_tls=conn_data['use_tls'], + verify_cert=conn_data['verify_cert'], + ca_cert_path=conn_data['ca_cert_path'], + auto_reconnect=conn_data['auto_reconnect'], + reconnect_delay_min=conn_data['reconnect_delay_min'], + reconnect_delay_max=conn_data['reconnect_delay_max'], + on_message_callback=self._on_message, + on_connect_callback=self._on_connect, + on_disconnect_callback=self._on_disconnect, + ) + + # Connect + if client.connect(): + self._clients[connection_id] = client + + # Subscribe to device topics + if use_new_cursor: + self._subscribe_device_topics(connection_id) + else: + self._subscribe_device_topics_with_env(connection_id, env) + + # Update connection state + def update_state(): + if use_new_cursor: with self.registry.cursor() as new_cr: env = api.Environment(new_cr, SUPERUSER_ID, {}) conn = env['mqtt.connection'].browse(connection_id) @@ -143,10 +255,19 @@ class IotBridgeService: 'last_error': False, }) new_cr.commit() - - return True else: - _logger.error(f"Failed to connect client for connection {connection_id}") + connection.write({ + 'state': 'connecting', + 'last_error': False, + }) + + update_state() + return True + else: + _logger.error(f"Failed to connect client for connection {connection_id}") + + def update_error(): + if use_new_cursor: with self.registry.cursor() as new_cr: env = api.Environment(new_cr, SUPERUSER_ID, {}) conn = env['mqtt.connection'].browse(connection_id) @@ -155,25 +276,33 @@ class IotBridgeService: 'last_error': 'Failed to initiate connection', }) new_cr.commit() - return False - + else: + connection.write({ + 'state': 'error', + 'last_error': 'Failed to initiate connection', + }) + + update_error() + return False + + def _subscribe_device_topics_with_env(self, connection_id, env): + """Subscribe to device topics using existing environment""" + client = self._clients.get(connection_id) + if not client: + return + + # Load devices + devices = env['mqtt.device'].search([ + ('connection_id', '=', connection_id), + ('active', '=', True) + ]) + + for device in devices: + try: + client.subscribe(device.topic_pattern) + _logger.info(f"Subscribed to topic: {device.topic_pattern}") except Exception as e: - _logger.error(f"Error starting connection {connection_id}: {e}", exc_info=True) - - # Update error state - try: - with self.registry.cursor() as new_cr: - env = api.Environment(new_cr, SUPERUSER_ID, {}) - connection = env['mqtt.connection'].browse(connection_id) - connection.write({ - 'state': 'error', - 'last_error': str(e), - }) - new_cr.commit() - except Exception: - pass - - return False + _logger.error(f"Failed to subscribe to {device.topic_pattern}: {e}") def stop_connection(self, connection_id: int) -> bool: """ @@ -240,6 +369,36 @@ class IotBridgeService: new_cr.commit() + def subscribe_device(self, connection_id, device_id): + """ + Subscribe to a single device's topic (used when device is added to running connection) + + Args: + connection_id: ID of mqtt.connection + device_id: ID of mqtt.device to subscribe + """ + client = self._clients.get(connection_id) + if not client: + _logger.warning(f"Cannot subscribe device {device_id}: Connection {connection_id} not running") + return + + # Use fresh cursor to load device + with self.registry.cursor() as new_cr: + env = api.Environment(new_cr, SUPERUSER_ID, {}) + device = env['mqtt.device'].browse(device_id) + + if not device.exists() or not device.active: + _logger.warning(f"Device {device_id} does not exist or is inactive") + return + + try: + client.subscribe(device.topic_pattern, qos=0) + _logger.info(f"Auto-subscribed device {device_id} ({device.name}) to topic: {device.topic_pattern}") + except Exception as e: + _logger.error(f"Failed to auto-subscribe device {device_id} to {device.topic_pattern}: {e}") + + new_cr.commit() + # ========== MQTT Callbacks ========== def _on_connect(self, connection_id: int): @@ -393,6 +552,12 @@ class IotBridgeService: if power is None: return + # Update device status + device.write({ + 'last_message_time': datetime.now(), + 'last_power_w': power, + }) + # Find running session running_session = env['mqtt.session'].search([ ('device_id', '=', device.id), @@ -413,12 +578,17 @@ class IotBridgeService: else: # Device is off if running_session: - # End session + # End session - calculate duration + end_time = datetime.now() + duration_s = int((end_time - running_session.start_time).total_seconds()) + running_session.write({ 'status': 'completed', - 'end_time': datetime.now(), + 'end_time': end_time, 'end_power_w': power, + 'total_duration_s': duration_s, + 'end_reason': 'power_drop', }) - _logger.info(f"🔴 Session ended for {device.name}") + _logger.info(f"🔴 Session ended for {device.name} (duration: {duration_s}s)") except Exception as e: _logger.debug(f"Session processing error: {e}") diff --git a/open_workshop_mqtt/services/mqtt_client.py b/open_workshop_mqtt/services/mqtt_client.py index 32cdc8e..424f761 100644 --- a/open_workshop_mqtt/services/mqtt_client.py +++ b/open_workshop_mqtt/services/mqtt_client.py @@ -150,17 +150,25 @@ class MqttClient: _logger.info(f"Disconnecting MQTT client {self.connection_id}") self._running = False + self._connected = False if self._client: try: + # Stop loop first (non-blocking) self._client.loop_stop() + + # Disconnect with short timeout to avoid hanging self._client.disconnect() + + # Give it a moment but don't wait forever + import time + time.sleep(0.1) + except Exception as e: _logger.error(f"Error during disconnect: {e}") finally: self._client = None - self._connected = False _logger.info(f"MQTT client {self.connection_id} disconnected") def subscribe(self, topic: str, qos: int = 0) -> bool: diff --git a/open_workshop_mqtt/tests/__init__.py b/open_workshop_mqtt/tests/__init__.py new file mode 100644 index 0000000..dd05483 --- /dev/null +++ b/open_workshop_mqtt/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +from . import test_mqtt_connection +from . import test_session_detection +from . import test_device_status diff --git a/open_workshop_mqtt/tests/common.py b/open_workshop_mqtt/tests/common.py new file mode 100644 index 0000000..b5baa92 --- /dev/null +++ b/open_workshop_mqtt/tests/common.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +""" +Common test utilities and base classes +Uses REAL MQTT Broker (like python_prototype tests) +""" + +from odoo.tests import TransactionCase +import logging +import yaml +from pathlib import Path + +_logger = logging.getLogger(__name__) + + +class MQTTTestCase(TransactionCase): + """ + Base test case for MQTT module + Uses REAL MQTT connection to test.mosquitto.org or configured broker + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Load MQTT config from python_prototype/config.yaml + config_path = Path(__file__).parent.parent / 'python_prototype' / 'config.yaml' + if config_path.exists(): + with open(config_path) as f: + config = yaml.safe_load(f) + mqtt_conf = config.get('mqtt', {}) + else: + # Fallback: public test broker + mqtt_conf = { + 'host': 'test.mosquitto.org', + 'port': 1883, + 'username': None, + 'password': None, + } + + # Create test connection with REAL broker + cls.connection = cls.env['mqtt.connection'].create({ + 'name': 'Test MQTT Broker (Real)', + 'host': mqtt_conf.get('host', 'test.mosquitto.org'), + 'port': mqtt_conf.get('port', 1883), + 'client_id': 'odoo_test_client', + 'username': mqtt_conf.get('username', False), + 'password': mqtt_conf.get('password', False), + 'use_tls': mqtt_conf.get('port') == 8883, + }) + + # Create test device with unique topic + import time + test_topic = f'odootest/{int(time.time())}' + + cls.device = cls.env['mqtt.device'].create({ + 'name': 'Test Device (Real)', + 'connection_id': cls.connection.id, + 'topic_pattern': f'{test_topic}/#', + 'parser_type': 'shelly_pm', + 'session_strategy': 'power_threshold', + 'strategy_config': '{"standby_threshold_w": 10, "working_threshold_w": 30}', + }) + + cls.test_topic = test_topic + + _logger.info(f"Test setup complete. Using broker: {mqtt_conf.get('host')}:{mqtt_conf.get('port')}") + _logger.info(f"Test topic: {test_topic}") + + def tearDown(self): + """Cleanup after each test - ensure all connections are stopped""" + super().tearDown() + + # Force stop any running connections + from odoo.addons.open_workshop_mqtt.services.iot_bridge_service import IotBridgeService + try: + service = IotBridgeService.get_instance(self.env) + if self.connection.id in service._clients: + service.stop_connection(self.connection.id) + _logger.info(f"Cleaned up connection {self.connection.id} in tearDown") + except Exception as e: + _logger.warning(f"Error in tearDown cleanup: {e}") + + def start_connection(self): + """Helper to start MQTT connection""" + # Bypass the ORM's action to directly start via service + from odoo.addons.open_workshop_mqtt.services.iot_bridge_service import IotBridgeService + service = IotBridgeService.get_instance(self.env) + + # Start with existing env (not new cursor) + success = service.start_connection_with_env(self.connection.id, self.env) + self.assertTrue(success, "Failed to start connection") + + # Wait for MQTT client to actually connect (check client state, not DB) + import time + client = service._clients.get(self.connection.id) + self.assertIsNotNone(client, "Client not found in service") + + for i in range(10): + if client.is_connected: + break + time.sleep(0.5) + + self.assertTrue(client.is_connected, "Client failed to connect within timeout") + + def stop_connection(self): + """Helper to stop MQTT connection""" + from odoo.addons.open_workshop_mqtt.services.iot_bridge_service import IotBridgeService + service = IotBridgeService.get_instance(self.env) + + # Stop connection + success = service.stop_connection(self.connection.id) + self.assertTrue(success, "Failed to stop connection") + + # Verify client is removed + import time + time.sleep(0.5) + client = service._clients.get(self.connection.id) + self.assertIsNone(client, "Client still in service after stop") + + def publish_test_message(self, subtopic, payload): + """ + Publish message to test topic using paho-mqtt + + Args: + subtopic: Subtopic (e.g., 'status/pm1:0') + payload: Message payload (dict or string) + """ + import paho.mqtt.publish as publish + import json + + topic = f'{self.test_topic}/{subtopic}' + payload_str = json.dumps(payload) if isinstance(payload, dict) else payload + + # Get connection config + auth = None + if self.connection.username: + auth = { + 'username': self.connection.username, + 'password': self.connection.password or '', + } + + # TLS config + tls = None + if self.connection.use_tls: + import ssl + tls = { + 'cert_reqs': ssl.CERT_REQUIRED if self.connection.verify_cert else ssl.CERT_NONE + } + + publish.single( + topic, + payload=payload_str, + hostname=self.connection.host, + port=int(self.connection.port), + auth=auth, + tls=tls, + ) + + _logger.info(f"Published test message to {topic}") + + def simulate_mqtt_message(self, subtopic, payload): + """Alias for publish_test_message for compatibility""" + self.publish_test_message(subtopic, payload) diff --git a/open_workshop_mqtt/tests/test_device_status.py b/open_workshop_mqtt/tests/test_device_status.py new file mode 100644 index 0000000..ff4ad6f --- /dev/null +++ b/open_workshop_mqtt/tests/test_device_status.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +""" +Test Device Status Updates +""" + +from odoo.tests import tagged +from .common import MQTTTestCase +from datetime import datetime +import json + + +@tagged('post_install', '-at_install', 'mqtt') +class TestDeviceStatus(MQTTTestCase): + """Test device status tracking (online/offline)""" + + def test_01_device_updates_last_message_time(self): + """Test device last_message_time is updated on message""" + self.assertFalse(self.device.last_message_time) + + # Simulate message + payload = json.dumps({"id": 0, "apower": 25.0}) + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + self.device.invalidate_recordset() + + # Verify updated + self.assertTrue(self.device.last_message_time) + self.assertEqual(self.device.last_power_w, 25.0) + + def test_02_device_state_computed_from_last_message(self): + """Test device state is computed based on last message time""" + from datetime import timedelta + + # Recent message → online + recent_time = datetime.now() - timedelta(seconds=30) + self.device.write({'last_message_time': recent_time}) + self.device.invalidate_recordset(['state']) + + # State should be computed as online + # Note: depends on _compute_state() implementation + + # Old message → offline + old_time = datetime.now() - timedelta(minutes=5) + self.device.write({'last_message_time': old_time}) + self.device.invalidate_recordset(['state']) + + # State should be computed as offline + + def test_03_device_power_tracking(self): + """Test device tracks current power consumption""" + # Send different power values + for power in [10.0, 25.0, 50.0, 100.0, 0.0]: + payload = json.dumps({"id": 0, "apower": power}) + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + self.device.invalidate_recordset() + self.assertEqual(self.device.last_power_w, power) + + def test_04_device_session_count(self): + """Test device session count is computed""" + # Create sessions + for i in range(3): + self.env['mqtt.session'].create({ + 'device_id': self.device.id, + 'status': 'completed', + 'start_time': datetime.now(), + 'end_time': datetime.now(), + 'total_duration_s': 3600, + }) + + # Running session + self.env['mqtt.session'].create({ + 'device_id': self.device.id, + 'status': 'running', + 'start_time': datetime.now(), + }) + + self.device.invalidate_recordset() + + self.assertEqual(self.device.session_count, 4) + self.assertEqual(self.device.running_session_count, 1) + self.assertEqual(self.device.total_runtime_hours, 3.0) diff --git a/open_workshop_mqtt/tests/test_mqtt_connection.py b/open_workshop_mqtt/tests/test_mqtt_connection.py new file mode 100644 index 0000000..2df5752 --- /dev/null +++ b/open_workshop_mqtt/tests/test_mqtt_connection.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +""" +Test MQTT Connection Lifecycle with REAL broker +""" + +from odoo.tests import tagged +from .common import MQTTTestCase +import time + + +@tagged('post_install', '-at_install', 'mqtt') +class TestMQTTConnection(MQTTTestCase): + """Test MQTT connection start/stop/restart with REAL broker""" + + def test_01_connection_start_real_broker(self): + """Test starting connection to REAL MQTT broker""" + # Start connection (internally checks client.is_connected) + self.start_connection() + + # Connection is established - tearDown will clean up + + def test_02_connection_stop_real_broker(self): + """Test stopping active MQTT connection""" + # Start first + self.start_connection() + + # Explicitly stop (test the stop function) + self.stop_connection() + + # Verify stopped + from odoo.addons.open_workshop_mqtt.services.iot_bridge_service import IotBridgeService + service = IotBridgeService.get_instance(self.env) + client = service._clients.get(self.connection.id) + self.assertIsNone(client, "Client should be removed after stop") + + def test_03_publish_and_receive_message(self): + """Test publishing message and receiving it in Odoo""" + # Start connection + self.start_connection() + + # Wait for subscription + time.sleep(2) + + # Publish test message + test_payload = { + "id": 0, + "voltage": 230.0, + "current": 0.5, + "apower": 50.0, + "freq": 50.0, + } + self.publish_test_message('status/pm1:0', test_payload) + + # Wait for message to arrive + time.sleep(3) + + # Check if message was received + messages = self.env['mqtt.message'].search([ + ('device_id', '=', self.device.id), + ('topic', '=', f'{self.test_topic}/status/pm1:0'), + ]) + + self.assertGreater(len(messages), 0, "No messages received from broker!") + + # tearDown will clean up connection diff --git a/open_workshop_mqtt/tests/test_mqtt_mocked.py b/open_workshop_mqtt/tests/test_mqtt_mocked.py new file mode 100644 index 0000000..e158711 --- /dev/null +++ b/open_workshop_mqtt/tests/test_mqtt_mocked.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +Test suite mit gemocktem MQTT Client (Unit Tests) +Folgt Odoo Best Practices - siehe microsoft_outlook, payment_mercado_pago +""" + +from unittest.mock import Mock, patch, call +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestMQTTConnectionMocked(TransactionCase): + """Unit Tests mit gemocktem MQTT Client - kein echter Broker nötig""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create test device + cls.device = cls.env['iot_bridge.device'].create({ + 'name': 'Test Device Mocked', + 'device_id': 'test-device-mocked-001', + 'status': 'offline', + }) + + # Setup mock MQTT client + cls.mqtt_patcher = patch('odoo.addons.open_workshop.open_workshop_mqtt.services.mqtt_client.mqtt.Client') + cls.MockClient = cls.mqtt_patcher.start() + + # Create mock instance + cls.mqtt_client_mock = Mock() + cls.MockClient.return_value = cls.mqtt_client_mock + + # Setup successful responses + cls.mqtt_client_mock.connect.return_value = 0 # MQTT_ERR_SUCCESS + cls.mqtt_client_mock.loop_start.return_value = None + cls.mqtt_client_mock.subscribe.return_value = (0, 1) + cls.mqtt_client_mock.publish.return_value = Mock(rc=0, mid=1) + cls.mqtt_client_mock.is_connected.return_value = True + cls.mqtt_client_mock.disconnect.return_value = 0 + cls.mqtt_client_mock.loop_stop.return_value = None + + # Get service + cls.service = cls.env['iot_bridge.service'] + + @classmethod + def tearDownClass(cls): + cls.mqtt_patcher.stop() + super().tearDownClass() + + def setUp(self): + super().setUp() + self.mqtt_client_mock.reset_mock() + + def test_01_start_connection_calls_mqtt_methods(self): + """Test dass start_connection die richtigen MQTT Methoden aufruft""" + # Start connection + result = self.service.start_connection_with_env(self.device.env) + + self.assertTrue(result, "Connection should start") + + # Verify calls + self.mqtt_client_mock.connect.assert_called_once() + self.mqtt_client_mock.loop_start.assert_called_once() + + # Check connect args + connect_call = self.mqtt_client_mock.connect.call_args + host, port = connect_call[0][0], connect_call[0][1] + self.assertEqual(host, 'mqtt.majufilo.eu') + self.assertEqual(port, 8883) + + def test_02_stop_connection_calls_disconnect(self): + """Test dass stop_connection disconnect/loop_stop aufruft""" + # Start + self.service.start_connection_with_env(self.device.env) + self.mqtt_client_mock.reset_mock() + + # Stop + self.service.stop_connection() + + # Verify + self.mqtt_client_mock.loop_stop.assert_called_once() + self.mqtt_client_mock.disconnect.assert_called_once() + + def test_03_reconnect_after_disconnect(self): + """Test Reconnect nach Disconnect""" + # Connect -> Disconnect -> Connect + self.service.start_connection_with_env(self.device.env) + self.service.stop_connection() + + self.mqtt_client_mock.reset_mock() + result = self.service.start_connection_with_env(self.device.env) + + self.assertTrue(result) + self.mqtt_client_mock.connect.assert_called_once() + + def test_04_on_connect_subscribes_topics(self): + """Test dass on_connect callback Topics subscribed""" + # Start + self.service.start_connection_with_env(self.device.env) + + # Trigger on_connect + self.service._mqtt_client.on_connect(None, None, None, 0) + + # Check subscribes + self.assertTrue(self.mqtt_client_mock.subscribe.called) + + # Get all subscribed topics + subscribe_calls = self.mqtt_client_mock.subscribe.call_args_list + topics = [c[0][0] for c in subscribe_calls] + + # Should subscribe to device topic + device_topic = f"iot/devices/{self.device.device_id}/status" + self.assertIn(device_topic, topics) diff --git a/open_workshop_mqtt/tests/test_session_detection.py b/open_workshop_mqtt/tests/test_session_detection.py new file mode 100644 index 0000000..7cdcb87 --- /dev/null +++ b/open_workshop_mqtt/tests/test_session_detection.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +""" +Test Session Detection Logic +""" + +from odoo.tests import tagged +from .common import MQTTTestCase +from datetime import datetime +import json + + +@tagged('post_install', '-at_install', 'mqtt') +class TestSessionDetection(MQTTTestCase): + """Test session start/stop detection""" + + def test_01_session_starts_on_power_above_threshold(self): + """Test session starts when power > 0""" + # Setup: connection running + self.connection.write({'state': 'connected'}) + + # Simulate MQTT message with power > 0 + payload = json.dumps({ + "id": 0, + "voltage": 230.0, + "current": 0.5, + "apower": 50.0, # Above threshold + "freq": 50.0, + }) + + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + # Verify session created + session = self.env['mqtt.session'].search([ + ('device_id', '=', self.device.id), + ('status', '=', 'running'), + ]) + + self.assertEqual(len(session), 1) + self.assertEqual(session.start_power_w, 50.0) + self.assertTrue(session.session_id) # UUID generated + + def test_02_session_ends_on_power_zero(self): + """Test session ends when power drops to 0""" + # Setup: create running session + session = self.env['mqtt.session'].create({ + 'device_id': self.device.id, + 'status': 'running', + 'start_time': datetime.now(), + 'start_power_w': 50.0, + }) + + # Simulate power drop + payload = json.dumps({ + "id": 0, + "voltage": 230.0, + "current": 0.0, + "apower": 0.0, # Power off + "freq": 50.0, + }) + + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + # Verify session completed + session.invalidate_recordset() + self.assertEqual(session.status, 'completed') + self.assertTrue(session.end_time) + self.assertEqual(session.end_reason, 'power_drop') + self.assertGreater(session.total_duration_s, 0) + + def test_03_no_duplicate_sessions(self): + """Test no duplicate running sessions are created""" + # Create first session + payload = json.dumps({"id": 0, "apower": 50.0}) + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + # Send another message with power > 0 + payload = json.dumps({"id": 0, "apower": 60.0}) + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + # Should still be only 1 running session + sessions = self.env['mqtt.session'].search([ + ('device_id', '=', self.device.id), + ('status', '=', 'running'), + ]) + + self.assertEqual(len(sessions), 1) + + def test_04_session_duration_calculated(self): + """Test session duration is calculated correctly""" + from datetime import timedelta + + start_time = datetime.now() - timedelta(hours=2, minutes=30) + + session = self.env['mqtt.session'].create({ + 'device_id': self.device.id, + 'status': 'running', + 'start_time': start_time, + 'start_power_w': 50.0, + }) + + # End session + payload = json.dumps({"id": 0, "apower": 0.0}) + self.simulate_mqtt_message('testdevice/status/pm1:0', payload) + + session.invalidate_recordset() + + # Duration should be ~2.5 hours = 9000 seconds + self.assertGreater(session.total_duration_s, 9000) + self.assertLess(session.total_duration_s, 9100) # Allow 100s tolerance + + # Check computed fields + self.assertAlmostEqual(session.duration_hours, 2.5, places=1) + self.assertIn('h', session.duration_formatted) diff --git a/open_workshop_mqtt/views/mqtt_device_views.xml b/open_workshop_mqtt/views/mqtt_device_views.xml index c4ec610..fc82448 100644 --- a/open_workshop_mqtt/views/mqtt_device_views.xml +++ b/open_workshop_mqtt/views/mqtt_device_views.xml @@ -81,6 +81,7 @@ +