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
This commit is contained in:
Matthias Lotz 2026-01-24 13:01:41 +01:00
parent aeb8e5660b
commit bb3e492d30
3 changed files with 154 additions and 17 deletions

View File

@ -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

View File

@ -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:

View File

@ -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__':