#!/usr/bin/env python3 """ MQTT Simulator für test_thermo_1 Thermostat. Funktionalität: - Subscribt auf vendor/test_thermo_1/set - Hält internen Zustand (mode, target, current, battery, window_open) - Reagiert auf SET-Commands (mode/target) - Simuliert Temperatur-Drift zu target (alle 5s, max ±0.2°C) - Publiziert vendor/test_thermo_1/state (retained, QoS 1) Usage: poetry run python tools/sim_thermo.py Environment Variables: MQTT_BROKER: MQTT broker hostname (default: 172.16.2.16) MQTT_PORT: MQTT broker port (default: 1883) Test Commands: # Start simulator poetry run python tools/sim_thermo.py & # Test SET command curl -X POST http://localhost:8001/devices/test_thermo_1/set \ -H "Content-Type: application/json" \ -d '{"type":"thermostat","payload":{"mode":"heat","target":22.5}}' # Monitor state mosquitto_sub -h 172.16.2.16 -t 'vendor/test_thermo_1/state' -v """ import asyncio import json import os import sys from datetime import datetime from aiomqtt import Client, MqttError # Configuration BROKER_HOST = os.getenv("MQTT_BROKER", "172.16.2.16") BROKER_PORT = int(os.getenv("MQTT_PORT", "1883")) DEVICE_ID = "test_thermo_1" SET_TOPIC = f"vendor/{DEVICE_ID}/set" STATE_TOPIC = f"vendor/{DEVICE_ID}/state" DRIFT_INTERVAL = 5 # seconds class ThermostatSimulator: """Simulates a thermostat device with temperature regulation.""" def __init__(self): self.state = { "mode": "auto", "target": 21.0, "current": 20.5, "battery": 90, "window_open": False } self.client = None self.running = True def log(self, msg: str): """Log with timestamp.""" timestamp = datetime.now().strftime("%H:%M:%S") print(f"[{timestamp}] {msg}", flush=True) async def publish_state(self): """Publish current state to MQTT (retained, QoS 1).""" if not self.client: return payload = json.dumps(self.state) await self.client.publish( STATE_TOPIC, payload=payload, qos=1, retain=True ) self.log(f"📤 Published state: {payload}") def apply_temperature_drift(self): """ Simulate temperature drift towards target. Max change: ±0.2°C per interval. """ if self.state["mode"] == "off": # In OFF mode, drift slowly towards ambient (assume 18°C) ambient = 18.0 diff = ambient - self.state["current"] else: # In HEAT/AUTO mode, drift towards target diff = self.state["target"] - self.state["current"] # Apply max ±0.2°C drift if abs(diff) < 0.1: # Close enough, small adjustment self.state["current"] = round(self.state["current"] + diff, 1) elif diff > 0: self.state["current"] = round(self.state["current"] + 0.2, 1) else: self.state["current"] = round(self.state["current"] - 0.2, 1) self.log(f"🌡️ Temperature drift: current={self.state['current']}°C (target={self.state['target']}°C)") async def handle_set_command(self, payload: dict): """ Handle SET command from MQTT. Payload can contain: mode, target """ self.log(f"📥 Received SET: {payload}") changed = False if "mode" in payload: new_mode = payload["mode"] if new_mode in ["off", "heat", "auto"]: self.state["mode"] = new_mode changed = True self.log(f" Mode changed to: {new_mode}") else: self.log(f" ⚠️ Invalid mode: {new_mode}") if "target" in payload: try: new_target = float(payload["target"]) if 5.0 <= new_target <= 30.0: self.state["target"] = new_target changed = True self.log(f" Target changed to: {new_target}°C") else: self.log(f" ⚠️ Target out of range: {new_target}") except (ValueError, TypeError): self.log(f" ⚠️ Invalid target value: {payload['target']}") if changed: await self.publish_state() async def drift_loop(self): """Background loop for temperature drift simulation.""" while self.running: await asyncio.sleep(DRIFT_INTERVAL) self.apply_temperature_drift() await self.publish_state() async def mqtt_loop(self): """Main MQTT connection and message handling loop.""" try: async with Client( hostname=BROKER_HOST, port=BROKER_PORT, identifier=f"sim_{DEVICE_ID}" ) as client: self.client = client self.log(f"✅ Connected to MQTT broker {BROKER_HOST}:{BROKER_PORT}") # Publish initial state await self.publish_state() self.log(f"📡 Thermo sim started for {DEVICE_ID}") # Subscribe to SET topic await client.subscribe(SET_TOPIC, qos=1) self.log(f"👂 Subscribed to {SET_TOPIC}") # Start drift loop in background drift_task = asyncio.create_task(self.drift_loop()) # Listen for messages async for message in client.messages: try: payload = json.loads(message.payload.decode()) await self.handle_set_command(payload) except json.JSONDecodeError as e: self.log(f"❌ Invalid JSON: {e}") except Exception as e: self.log(f"❌ Error handling message: {e}") # Cancel drift loop on disconnect drift_task.cancel() except MqttError as e: self.log(f"❌ MQTT Error: {e}") except KeyboardInterrupt: self.log("⚠️ Interrupted by user") finally: self.running = False self.log("👋 Simulator stopped") async def run(self): """Run the simulator.""" await self.mqtt_loop() async def main(): """Entry point.""" simulator = ThermostatSimulator() await simulator.run() if __name__ == "__main__": try: asyncio.run(main()) except KeyboardInterrupt: print("\n👋 Simulator terminated") sys.exit(0)