thermostat
This commit is contained in:
205
tools/sim_thermo.py
Executable file
205
tools/sim_thermo.py
Executable file
@@ -0,0 +1,205 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user