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

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))
+152 -120
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:
logger.error(f"Invalid temperature value: {msg.payload}")
except Exception as e:
logger.error(f"Error processing message: {e}")
# Convert payload to float any_opened_start = 0
temperature = float(msg.payload.decode('utf-8')) any_opened = False
# Store in global dictionary
temperature_data[sensor_name] = temperature
logger.debug(f"Updated {sensor_name}: {temperature}°C")
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
for c in contours:
contour_t = c.get_value()
if contour_t is None:
continue
sum_t = sum_t + contour_t
count_t = count_t + 1
if count_t == 0:
logger.error("No temperatures available, switch to full-on mode")
for c in contours:
# valves are normally-opened, so 0 means "open"
mqtt_client.publish(c.out_topic, "0", retain=True)
# turn on boiler
mqtt_client.publish(boiler_control, "1", retain=True)
return
if target_temp is None: for c in contours:
logger.error("Target temperature not configured") contour_t = c.get_value()
return # use average value from all other sensors if contour's
# sensor is offline
if contour_t is None:
contour_t = sum_t / count_t
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)
any_below_target = False 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()
# Check all sensors if any_opened and time.time() - any_opened_start > BOILER_TURN_ON_AFTER:
for sensor, temp in temperature_data.items(): mqtt_client.publish(boiler_control, "1", retain=True)
if temp < target_temp: else:
logger.info(f"Sensor {sensor} below target: {temp} < {target_temp}") mqtt_client.publish(boiler_control, "0", retain=True)
any_below_target = True
break # No need to check further if one is below
# Control GPIO
if any_below_target:
# Send "1" to turn on/activate
mqtt_client.publish(control_topic, "1", retain=True)
logger.info(f"Activated: {control_topic}")
else:
# Send "0" to turn off/deactivate
mqtt_client.publish(control_topic, "0", retain=True)
logger.debug(f"Deactivated: {control_topic}")
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 # Start main cycle in a separate thread
load_config() # This allows MQTT to run in background
cycle_thread = threading.Thread(target=main_cycle, daemon=True)
cycle_thread.start()
# Setup MQTT # Keep main thread alive
setup_mqtt() try:
while True:
# Start main cycle in a separate thread time.sleep(1)
# This allows MQTT to run in background except KeyboardInterrupt:
cycle_thread = threading.Thread(target=main_cycle, daemon=True) logger.info("Shutting down...")
cycle_thread.start() mqtt_client.loop_stop()
mqtt_client.disconnect()
# Keep main thread alive
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down...")
mqtt_client.loop_stop()
mqtt_client.disconnect()
if __name__ == "__main__": if __name__ == "__main__":
main() main()