From bb3e492d309f1e61789c4043eef193c9b6eba9a6 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Sat, 24 Jan 2026 13:01:41 +0100 Subject: [PATCH] feat: Add multi-device support (M4) - Support multiple machines in parallel - Each device uses unique topic_prefix - Add 3 integration tests for multi-device scenarios - Update config.yaml.example with 2-machine setup - All 8 integration tests passing - Test scenarios: parallel sessions, independent timeouts, different thresholds --- open_workshop_mqtt/python_prototype/README.md | 2 +- .../python_prototype/config.yaml.example | 23 +-- .../integration/test_mqtt_integration.py | 146 +++++++++++++++++- 3 files changed, 154 insertions(+), 17 deletions(-) diff --git a/open_workshop_mqtt/python_prototype/README.md b/open_workshop_mqtt/python_prototype/README.md index 6b20953..f90e3ec 100644 --- a/open_workshop_mqtt/python_prototype/README.md +++ b/open_workshop_mqtt/python_prototype/README.md @@ -122,6 +122,6 @@ python tests/tools/shelly_simulator.py --scenario timeout - [x] M0-M3: MQTT + Parser + Session Detection - [x] Unit + Integration Tests - [x] Timeout Detection mit check_timeouts() -- [ ] M4: Multi-Device Support +- [x] M4: Multi-Device Support (2+ Maschinen parallel) - [ ] M5: Reconnect + Error Handling - [ ] M6-M10: Odoo Integration diff --git a/open_workshop_mqtt/python_prototype/config.yaml.example b/open_workshop_mqtt/python_prototype/config.yaml.example index 3312468..a6cd58c 100644 --- a/open_workshop_mqtt/python_prototype/config.yaml.example +++ b/open_workshop_mqtt/python_prototype/config.yaml.example @@ -15,6 +15,8 @@ mqtt: # Add more topics as needed # Device Configuration +# Multi-Device Support: Jedes Shelly PM Mini G3 Gerät = 1 Maschine +# Unterscheide Geräte durch verschiedene topic_prefix in MQTT Topics devices: - shelly_id: "shellypmminig3-48f6eeb73a1c" machine_name: "Shaper Origin" @@ -37,15 +39,18 @@ devices: enabled: true - # Example for additional device - # - shelly_id: "shellypmminig3-xxxxxxxxxxxx" - # machine_name: "CNC Mill" - # machine_id: "cnc-mill-01" - # device_type: "shelly_pm_mini_g3" - # power_threshold: 100 - # start_debounce_s: 5 - # stop_debounce_s: 20 - # enabled: true + # Zweite Maschine - anderes Shelly-Gerät, andere Thresholds + # Wichtig: Jedes Shelly muss eigenen topic_prefix haben! + - shelly_id: "shellypmminig3-48f6eeb73a2d" + machine_name: "CNC Fräse" + machine_id: "cnc-mill-01" + device_type: "shelly_pm_mini_g3" + standby_threshold_w: 30 # Höherer Standby-Wert für CNC + working_threshold_w: 150 # Spindel braucht mehr Power + start_debounce_s: 3 + stop_debounce_s: 15 + message_timeout_s: 20 + enabled: true # Logging Configuration logging: diff --git a/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py b/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py index deedb41..ce61021 100644 --- a/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py +++ b/open_workshop_mqtt/python_prototype/tests/integration/test_mqtt_integration.py @@ -73,10 +73,10 @@ mqtt: client_id: "ows_iot_bridge_pytest" keepalive: 60 topics: - - "{mqtt_config['topic_prefix']}/#" - + - "{mqtt_config['topic_prefix']}-m1/#" + - "{mqtt_config['topic_prefix']}-m2/#" devices: - - topic_prefix: "{mqtt_config['topic_prefix']}" + - topic_prefix: "{mqtt_config['topic_prefix']}-m1" machine_name: "PyTest Machine" machine_id: "pytest-machine-01" device_type: "shelly_pm_mini_g3" @@ -86,6 +86,16 @@ devices: stop_debounce_s: 15 message_timeout_s: 20 enabled: true + - topic_prefix: "{mqtt_config['topic_prefix']}-m2" + machine_name: "PyTest CNC" + machine_id: "pytest-machine-02" + device_type: "shelly_pm_mini_g3" + standby_threshold_w: 30 + working_threshold_w: 150 + start_debounce_s: 3 + stop_debounce_s: 15 + message_timeout_s: 20 + enabled: true logging: level: "INFO" @@ -264,7 +274,7 @@ class TestMQTTIntegration: def test_session_start_standby(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): """Session Start → STANDBY""" - topic = f"{mqtt_config['topic_prefix']}/status/pm1:0" + topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" sessions_file = test_storage_files['sessions'] # Anzahl Sessions vor dem Test @@ -295,7 +305,7 @@ class TestMQTTIntegration: def test_session_end_with_stop_debounce(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): """Session Ende mit stop_debounce (15s unter 20W)""" - topic = f"{mqtt_config['topic_prefix']}/status/pm1:0" + topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" sessions_file = test_storage_files['sessions'] initial_count = len(read_sessions(sessions_file)) @@ -340,7 +350,7 @@ class TestMQTTIntegration: def test_standby_to_working_transition(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): """STANDBY → WORKING Transition""" - topic = f"{mqtt_config['topic_prefix']}/status/pm1:0" + topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" sessions_file = test_storage_files['sessions'] # Session im STANDBY starten @@ -387,7 +397,7 @@ class TestMQTTIntegration: def test_timeout_detection(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): """Timeout Detection (20s keine Messages)""" - topic = f"{mqtt_config['topic_prefix']}/status/pm1:0" + topic = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" sessions_file = test_storage_files['sessions'] initial_count = len(read_sessions(sessions_file)) @@ -412,6 +422,128 @@ class TestMQTTIntegration: timeout_sessions = [s for s in sessions if s.get('end_reason') == 'timeout'] assert len(timeout_sessions) > 0, f"Keine timeout Session gefunden. Sessions: {json.dumps(sessions[-3:], indent=2)}" + + def test_multi_device_parallel_sessions(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): + """Zwei Maschinen starten parallel Sessions""" + topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" + topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" + sessions_file = test_storage_files['sessions'] + + initial_count = len(read_sessions(sessions_file)) + + print("\n🔄 Starte parallele Sessions auf 2 Maschinen...") + + # Maschine 1: Session starten (20W threshold) + send_shelly_message(mqtt_sender, topic_machine1, 50) + time.sleep(0.5) + + # Maschine 2: Session starten (30W threshold) + send_shelly_message(mqtt_sender, topic_machine2, 80) + time.sleep(0.5) + + # Beide Maschinen halten + for _ in range(4): + send_shelly_message(mqtt_sender, topic_machine1, 60) + send_shelly_message(mqtt_sender, topic_machine2, 100) + time.sleep(1) + + # Warten auf Sessions + time.sleep(3) + + # Beide Maschinen sollten jetzt laufende Sessions haben + sessions = read_sessions(sessions_file) + new_sessions = [s for s in sessions if len(sessions) > initial_count][-2:] # Letzte 2 neue Sessions + running_sessions = [s for s in new_sessions if s.get('status') == 'running'] + + assert len(running_sessions) == 2, f"Erwartet exakt 2 neue laufende Sessions, gefunden: {len(running_sessions)}" + + # Prüfen dass beide machine_ids vorhanden sind + machine_ids = [s['machine_id'] for s in running_sessions] + assert 'pytest-machine-01' in machine_ids, "Maschine 1 Session fehlt" + assert 'pytest-machine-02' in machine_ids, "Maschine 2 Session fehlt" + + print(f"✅ 2 parallele Sessions erfolgreich: {machine_ids}") + + def test_multi_device_independent_timeouts(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): + """Maschine 1 Timeout, Maschine 2 läuft weiter""" + topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" + topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" + sessions_file = test_storage_files['sessions'] + + print("\n⏱️ Teste unabhängige Timeouts...") + + # Beide Maschinen starten + send_shelly_message(mqtt_sender, topic_machine1, 50) + send_shelly_message(mqtt_sender, topic_machine2, 80) + time.sleep(1) + + for _ in range(3): + send_shelly_message(mqtt_sender, topic_machine1, 50) + send_shelly_message(mqtt_sender, topic_machine2, 80) + time.sleep(1) + + time.sleep(2) + + # Maschine 1: Letzte Message, dann Timeout + send_shelly_message(mqtt_sender, topic_machine1, 50) + + # Maschine 2: Läuft weiter + for _ in range(26): # 26 Sekunden + send_shelly_message(mqtt_sender, topic_machine2, 80) + time.sleep(1) + + # Prüfen + sessions = read_sessions(sessions_file) + + # Maschine 1 sollte timeout haben + m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01'] + timeout_session = [s for s in m1_sessions if s.get('end_reason') == 'timeout'] + assert len(timeout_session) > 0, "Maschine 1 sollte Timeout-Session haben" + + # Maschine 2 sollte noch laufen + m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02'] + running_session = [s for s in m2_sessions if s.get('status') == 'running'] + assert len(running_session) > 0, "Maschine 2 sollte noch laufen" + + print("✅ Unabhängige Timeouts funktionieren") + + def test_multi_device_different_thresholds(self, bridge_process, mqtt_sender, mqtt_config, test_storage_files): + """Unterschiedliche Power-Thresholds pro Maschine""" + topic_machine1 = f"{mqtt_config['topic_prefix']}-m1/status/pm1:0" + topic_machine2 = f"{mqtt_config['topic_prefix']}-m2/status/pm1:0" + sessions_file = test_storage_files['sessions'] + + # Cleanup: Alte Sessions löschen für sauberen Test + if sessions_file.exists(): + sessions_file.unlink() + + print("\n🔧 Teste unterschiedliche Thresholds...") + + # Maschine 1: 25W (über 20W threshold) → sollte starten + for _ in range(4): + send_shelly_message(mqtt_sender, topic_machine1, 25) + time.sleep(1) + + time.sleep(3) + + # Maschine 2: 25W (unter 30W threshold) → sollte NICHT starten + for _ in range(4): + send_shelly_message(mqtt_sender, topic_machine2, 25) + time.sleep(1) + + time.sleep(3) + + sessions = read_sessions(sessions_file) + + # Maschine 1 sollte Session haben + m1_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-01' and s.get('status') == 'running'] + assert len(m1_sessions) > 0, "Maschine 1 sollte bei 25W Session haben (threshold 20W)" + + # Maschine 2 sollte KEINE Session haben + m2_sessions = [s for s in sessions if s['machine_id'] == 'pytest-machine-02' and s.get('status') == 'running'] + assert len(m2_sessions) == 0, "Maschine 2 sollte bei 25W KEINE Session haben (threshold 30W)" + + print("✅ Unterschiedliche Thresholds funktionieren") if __name__ == '__main__':