Логика включения-выключения контуров и котла. Конфиг контуров, гистерезис и прочее

This commit is contained in:
ftc
2025-12-07 14:56:25 +03:00
parent b4e77a02d6
commit 4a84636d0e
4 changed files with 226 additions and 134 deletions
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
config.json
+30 -4
View File
@@ -1,9 +1,35 @@
{ {
"mqtt_host": "localhost", "mqtt_host": "192.168.0.45",
"mqtt_port": 1883, "mqtt_port": 1883,
"mqtt_username": "", "mqtt_username": "",
"mqtt_password": "", "mqtt_password": "",
"temperature_sensors": ["sensor1", "sensor2", "wb-ms-12v"], "contours": [
"target_temperature": 20.0, {
"control_topic": "devices/wb-gpio/controls/EXT1_R3A1" "sensor": "/devices/workroom_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A1"
},
{
"sensor": "/devices/bathroom_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A2"
},
{
"sensor": "/devices/entrance_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A3"
},
{
"sensor": "/devices/corridor_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A4"
},
{
"sensor": "/devices/techroom_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A5"
},
{
"sensor": "/devices/kitchen_weather/controls/t",
"control": "/devices/wb-gpio/controls/EXT1_R3A6"
}
],
"target_temperature": 21.0,
"hysteresis": 0.5,
"boiler_control": "/devices/wb-gpio/controls/EXT1_R3A8"
} }
+32
View File
@@ -0,0 +1,32 @@
import time
class Contour:
EXPIRE_TIME = 120 # sensor value considered expired in 2 minutes
CHANGE_FREQ = 600 # change of state allowed after 10 minutes
current_value = None
value_time = 0
state = None
state_change_time = 0
def __init__(self, config, logger):
self.sensor_topic = config['sensor']
self.out_topic = config['control']
self.logger = logger
def update(self, value):
self.current_value = value
self.value_time = time.time()
def get_value(self):
if (time.time() - self.value_time <= self.EXPIRE_TIME):
return self.current_value
return None
def check_state(self, state_value):
if self.state == state_value:
return
if (self.state is None) or (time.time() - self.state_change_time > self.CHANGE_FREQ):
self.state = state_value
self.logger.info("Contour " + self.sensor_topic + " changed state to " + str(self.state))
+162 -130
View File
@@ -10,160 +10,192 @@ import logging
from typing import Dict, Any from typing import Dict, Any
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import threading import threading
from optparse import OptionParser
from contour import Contour
# Configuration parser = OptionParser()
CONFIG_FILE = "/etc/wb-temperature-monitor/config.json" parser.add_option("--debug", action="store_true")
(options, args) = parser.parse_args()
# Global dictionary for temperature values CONFIG_FILE = "config.json"
temperature_data: Dict[str, float] = {} # will turn on boiler after this amount of opened contour time
# (this is to make sure valve has time for opening)
BOILER_TURN_ON_AFTER = 90
contours = []
config: Dict[str, Any] = {} config: Dict[str, Any] = {}
# MQTT client # MQTT client
mqtt_client = None mqtt_client = None
# Setup logging # Setup logging
logging_level = logging.INFO
if (options.debug):
logging_level = logging.DEBUG
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level = logging_level,
format='%(asctime)s - %(levelname)s - %(message)s' format = '%(asctime)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def load_config(): def load_config():
"""Load configuration from file""" """Load configuration from file"""
global config global config, contours, target_temp, boiler_control, hysteresis
try: try:
with open(CONFIG_FILE, 'r') as f: with open(CONFIG_FILE, 'r') as f:
config = json.load(f) config = json.load(f)
logger.info(f"Configuration loaded: {config}") for contour_data in config['contours']:
except FileNotFoundError: contours.append(Contour(contour_data, logger))
logger.error(f"Config file not found: {CONFIG_FILE}") target_temp = float(config.get('target_temperature'))
raise hysteresis = float(config.get('hysteresis'))
except json.JSONDecodeError: boiler_control = config.get('boiler_control')
logger.error(f"Invalid JSON in config file: {CONFIG_FILE}") if (boiler_control is None):
raise logger.error("Boiler control not configured")
except Exception as e: exit(1)
logger.error(f"Error loading config: {e}") logger.info(f"Configuration loaded: {config}")
raise except FileNotFoundError:
logger.error(f"Config file not found: {CONFIG_FILE}")
raise
except json.JSONDecodeError:
logger.error(f"Invalid JSON in config file: {CONFIG_FILE}")
raise
except Exception as e:
logger.error(f"Error loading config: {e}")
raise
def on_connect(client, userdata, flags, rc): def on_connect(client, userdata, flags, rc):
"""MQTT connection callback""" """MQTT connection callback"""
if rc == 0: global contours
logger.info("Connected to MQTT broker") if rc == 0:
# Subscribe to all temperature sensor topics logger.info("Connected to MQTT broker")
for sensor in config.get('temperature_sensors', []): # Subscribe to all temperature sensor topics
topic = f"devices/{sensor}/controls/t" for k in contours:
client.subscribe(topic) client.subscribe(k.sensor_topic)
logger.info(f"Subscribed to: {topic}") logger.info(f"Subscribed to: {k.sensor_topic}")
else: else:
logger.error(f"Connection failed with code: {rc}") logger.error(f"Connection failed with code: {rc}")
def on_message(client, userdata, msg): def on_message(client, userdata, msg):
"""MQTT message callback - store temperature values""" """MQTT message callback - store temperature values"""
try: try:
# Extract sensor name from topic for k in contours:
# Topic format: devices/%NAME%/controls/t if (k.sensor_topic == msg.topic):
parts = msg.topic.split('/') temperature = float(msg.payload.decode('utf-8'))
if len(parts) >= 2: k.update(temperature)
sensor_name = parts[1] # Get the %NAME% part logger.debug(f"Updated {k.sensor_topic}: {temperature}°C")
except ValueError:
# Convert payload to float logger.error(f"Invalid temperature value: {msg.payload}")
temperature = float(msg.payload.decode('utf-8')) except Exception as e:
logger.error(f"Error processing message: {e}")
# Store in global dictionary
temperature_data[sensor_name] = temperature any_opened_start = 0
logger.debug(f"Updated {sensor_name}: {temperature}°C") any_opened = False
except ValueError:
logger.error(f"Invalid temperature value: {msg.payload}")
except Exception as e:
logger.error(f"Error processing message: {e}")
def check_temperatures(): def check_temperatures():
"""Check temperatures against target and control GPIO""" """Check temperatures against target and control GPIO"""
target_temp = config.get('target_temperature') global any_opened_start, any_opened
control_topic = config.get('control_topic', 'devices/wb-gpio/controls/EXT1_R3A1') sum_t = 0
count_t = 0
if target_temp is None: for c in contours:
logger.error("Target temperature not configured") contour_t = c.get_value()
return if contour_t is None:
continue
any_below_target = False sum_t = sum_t + contour_t
count_t = count_t + 1
# Check all sensors if count_t == 0:
for sensor, temp in temperature_data.items(): logger.error("No temperatures available, switch to full-on mode")
if temp < target_temp: for c in contours:
logger.info(f"Sensor {sensor} below target: {temp} < {target_temp}") # valves are normally-opened, so 0 means "open"
any_below_target = True mqtt_client.publish(c.out_topic, "0", retain=True)
break # No need to check further if one is below # turn on boiler
mqtt_client.publish(boiler_control, "1", retain=True)
# Control GPIO return
if any_below_target:
# Send "1" to turn on/activate for c in contours:
mqtt_client.publish(control_topic, "1", retain=True) contour_t = c.get_value()
logger.info(f"Activated: {control_topic}") # use average value from all other sensors if contour's
else: # sensor is offline
# Send "0" to turn off/deactivate if contour_t is None:
mqtt_client.publish(control_topic, "0", retain=True) contour_t = sum_t / count_t
logger.debug(f"Deactivated: {control_topic}") if contour_t < target_temp - hysteresis:
# should turn on contour
c.check_state(True)
if contour_t > target_temp + hysteresis:
# should turn off contour
c.check_state(False)
if c.state:
# 0 means "open"
mqtt_client.publish(c.out_topic, "0", retain=True)
else:
mqtt_client.publish(c.out_topic, "1", retain=True)
if any_opened:
for c in contours:
if not c.state:
any_opened = False
else:
for c in contours:
if c.state:
any_opened = True
any_opened_start = time.time()
if any_opened and time.time() - any_opened_start > BOILER_TURN_ON_AFTER:
mqtt_client.publish(boiler_control, "1", retain=True)
else:
mqtt_client.publish(boiler_control, "0", retain=True)
def main_cycle(): def main_cycle():
"""Main 5-second cycle""" """Main 5-second cycle"""
while True: while True:
try: try:
check_temperatures() check_temperatures()
except Exception as e: except Exception as e:
logger.error(f"Error in main cycle: {e}") logger.error(f"Error in main cycle: {e}")
# Wait 5 seconds
# Wait 5 seconds time.sleep(5)
time.sleep(5)
def setup_mqtt(): def setup_mqtt():
"""Setup and connect MQTT client""" """Setup and connect MQTT client"""
global mqtt_client global mqtt_client
mqtt_client = mqtt.Client(client_id="wb_temperature_monitor") mqtt_client = mqtt.Client(client_id="wb_temperature_monitor")
mqtt_client.on_connect = on_connect mqtt_client.on_connect = on_connect
mqtt_client.on_message = on_message mqtt_client.on_message = on_message
# Configure from config # Configure from config
mqtt_host = config.get('mqtt_host', 'localhost') mqtt_host = config.get('mqtt_host', 'localhost')
mqtt_port = config.get('mqtt_port', 1883) mqtt_port = config.get('mqtt_port', 1883)
mqtt_username = config.get('mqtt_username', '') mqtt_username = config.get('mqtt_username', '')
mqtt_password = config.get('mqtt_password', '') mqtt_password = config.get('mqtt_password', '')
if mqtt_username: if mqtt_username:
mqtt_client.username_pw_set(mqtt_username, mqtt_password) mqtt_client.username_pw_set(mqtt_username, mqtt_password)
try: try:
mqtt_client.connect(mqtt_host, mqtt_port, 60) mqtt_client.connect(mqtt_host, mqtt_port, 60)
mqtt_client.loop_start() mqtt_client.loop_start()
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to MQTT broker: {e}") logger.error(f"Failed to connect to MQTT broker: {e}")
raise raise
def main(): def main():
"""Main function""" logger.info("Starting Wirenboard Temperature Monitor")
logger.info("Starting Wirenboard Temperature Monitor") load_config()
setup_mqtt()
# Load configuration
load_config() # Start main cycle in a separate thread
# This allows MQTT to run in background
# Setup MQTT cycle_thread = threading.Thread(target=main_cycle, daemon=True)
setup_mqtt() cycle_thread.start()
# Start main cycle in a separate thread # Keep main thread alive
# This allows MQTT to run in background try:
cycle_thread = threading.Thread(target=main_cycle, daemon=True) while True:
cycle_thread.start() time.sleep(1)
except KeyboardInterrupt:
# Keep main thread alive logger.info("Shutting down...")
try: mqtt_client.loop_stop()
while True: mqtt_client.disconnect()
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down...")
mqtt_client.loop_stop()
mqtt_client.disconnect()
if __name__ == "__main__": if __name__ == "__main__":
main() main()