From 4a84636d0e475c09c1b47cadf80b64d960e1af2e Mon Sep 17 00:00:00 2001 From: Denis Denisov Date: Sun, 7 Dec 2025 14:56:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9B=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F-=D0=B2=D1=8B?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D1=82=D0=BB=D0=B0.=20=D0=9A=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=BE=D0=B2,=20=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D1=80=D0=B5=D0=B7=D0=B8=D1=81=20=D0=B8?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=87=D0=B5=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + config.json.sample | 34 +++++- contour.py | 32 +++++ heat_control.py | 292 +++++++++++++++++++++++++-------------------- 4 files changed, 226 insertions(+), 134 deletions(-) create mode 100644 .gitignore create mode 100644 contour.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a3d4b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +config.json diff --git a/config.json.sample b/config.json.sample index 5e54f3c..d8d5731 100644 --- a/config.json.sample +++ b/config.json.sample @@ -1,9 +1,35 @@ { - "mqtt_host": "localhost", + "mqtt_host": "192.168.0.45", "mqtt_port": 1883, "mqtt_username": "", "mqtt_password": "", - "temperature_sensors": ["sensor1", "sensor2", "wb-ms-12v"], - "target_temperature": 20.0, - "control_topic": "devices/wb-gpio/controls/EXT1_R3A1" + "contours": [ + { + "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" } \ No newline at end of file diff --git a/contour.py b/contour.py new file mode 100644 index 0000000..86b617a --- /dev/null +++ b/contour.py @@ -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)) + \ No newline at end of file diff --git a/heat_control.py b/heat_control.py index 450f16f..2974ba9 100644 --- a/heat_control.py +++ b/heat_control.py @@ -10,160 +10,192 @@ import logging from typing import Dict, Any import paho.mqtt.client as mqtt import threading +from optparse import OptionParser +from contour import Contour -# Configuration -CONFIG_FILE = "/etc/wb-temperature-monitor/config.json" +parser = OptionParser() +parser.add_option("--debug", action="store_true") +(options, args) = parser.parse_args() -# Global dictionary for temperature values -temperature_data: Dict[str, float] = {} +CONFIG_FILE = "config.json" +# 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] = {} # MQTT client mqtt_client = None # Setup logging +logging_level = logging.INFO +if (options.debug): + logging_level = logging.DEBUG logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s' + level = logging_level, + format = '%(asctime)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) def load_config(): - """Load configuration from file""" - global config - try: - with open(CONFIG_FILE, 'r') as f: - config = json.load(f) - logger.info(f"Configuration loaded: {config}") - 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 + """Load configuration from file""" + global config, contours, target_temp, boiler_control, hysteresis + try: + with open(CONFIG_FILE, 'r') as f: + config = json.load(f) + for contour_data in config['contours']: + contours.append(Contour(contour_data, logger)) + target_temp = float(config.get('target_temperature')) + hysteresis = float(config.get('hysteresis')) + boiler_control = config.get('boiler_control') + if (boiler_control is None): + logger.error("Boiler control not configured") + exit(1) + logger.info(f"Configuration loaded: {config}") + 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): - """MQTT connection callback""" - if rc == 0: - logger.info("Connected to MQTT broker") - # Subscribe to all temperature sensor topics - for sensor in config.get('temperature_sensors', []): - topic = f"devices/{sensor}/controls/t" - client.subscribe(topic) - logger.info(f"Subscribed to: {topic}") - else: - logger.error(f"Connection failed with code: {rc}") + """MQTT connection callback""" + global contours + if rc == 0: + logger.info("Connected to MQTT broker") + # Subscribe to all temperature sensor topics + for k in contours: + client.subscribe(k.sensor_topic) + logger.info(f"Subscribed to: {k.sensor_topic}") + else: + logger.error(f"Connection failed with code: {rc}") def on_message(client, userdata, msg): - """MQTT message callback - store temperature values""" - try: - # Extract sensor name from topic - # Topic format: devices/%NAME%/controls/t - parts = msg.topic.split('/') - if len(parts) >= 2: - sensor_name = parts[1] # Get the %NAME% part - - # Convert payload to float - temperature = float(msg.payload.decode('utf-8')) - - # 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}") + """MQTT message callback - store temperature values""" + try: + for k in contours: + if (k.sensor_topic == msg.topic): + temperature = float(msg.payload.decode('utf-8')) + k.update(temperature) + 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}") + +any_opened_start = 0 +any_opened = False def check_temperatures(): - """Check temperatures against target and control GPIO""" - target_temp = config.get('target_temperature') - control_topic = config.get('control_topic', 'devices/wb-gpio/controls/EXT1_R3A1') - - if target_temp is None: - logger.error("Target temperature not configured") - return - - any_below_target = False - - # Check all sensors - for sensor, temp in temperature_data.items(): - if temp < target_temp: - logger.info(f"Sensor {sensor} below target: {temp} < {target_temp}") - 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}") + """Check temperatures against target and control GPIO""" + global any_opened_start, any_opened + 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 + + for c in contours: + contour_t = c.get_value() + # 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) + + 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(): - """Main 5-second cycle""" - while True: - try: - check_temperatures() - except Exception as e: - logger.error(f"Error in main cycle: {e}") - - # Wait 5 seconds - time.sleep(5) + """Main 5-second cycle""" + while True: + try: + check_temperatures() + except Exception as e: + logger.error(f"Error in main cycle: {e}") + # Wait 5 seconds + time.sleep(5) def setup_mqtt(): - """Setup and connect MQTT client""" - global mqtt_client - - mqtt_client = mqtt.Client(client_id="wb_temperature_monitor") - mqtt_client.on_connect = on_connect - mqtt_client.on_message = on_message - - # Configure from config - mqtt_host = config.get('mqtt_host', 'localhost') - mqtt_port = config.get('mqtt_port', 1883) - mqtt_username = config.get('mqtt_username', '') - mqtt_password = config.get('mqtt_password', '') - - if mqtt_username: - mqtt_client.username_pw_set(mqtt_username, mqtt_password) - - try: - mqtt_client.connect(mqtt_host, mqtt_port, 60) - mqtt_client.loop_start() - except Exception as e: - logger.error(f"Failed to connect to MQTT broker: {e}") - raise + """Setup and connect MQTT client""" + global mqtt_client + + mqtt_client = mqtt.Client(client_id="wb_temperature_monitor") + mqtt_client.on_connect = on_connect + mqtt_client.on_message = on_message + + # Configure from config + mqtt_host = config.get('mqtt_host', 'localhost') + mqtt_port = config.get('mqtt_port', 1883) + mqtt_username = config.get('mqtt_username', '') + mqtt_password = config.get('mqtt_password', '') + + if mqtt_username: + mqtt_client.username_pw_set(mqtt_username, mqtt_password) + + try: + mqtt_client.connect(mqtt_host, mqtt_port, 60) + mqtt_client.loop_start() + except Exception as e: + logger.error(f"Failed to connect to MQTT broker: {e}") + raise def main(): - """Main function""" - logger.info("Starting Wirenboard Temperature Monitor") - - # Load configuration - load_config() - - # Setup MQTT - setup_mqtt() - - # Start main cycle in a separate thread - # This allows MQTT to run in background - cycle_thread = threading.Thread(target=main_cycle, daemon=True) - cycle_thread.start() - - # Keep main thread alive - try: - while True: - time.sleep(1) - except KeyboardInterrupt: - logger.info("Shutting down...") - mqtt_client.loop_stop() - mqtt_client.disconnect() + logger.info("Starting Wirenboard Temperature Monitor") + load_config() + setup_mqtt() + + # Start main cycle in a separate thread + # This allows MQTT to run in background + cycle_thread = threading.Thread(target=main_cycle, daemon=True) + cycle_thread.start() + + # 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__": - main() \ No newline at end of file + main() \ No newline at end of file