docker-thermostat/thermostat.py

394 lines
14 KiB
Python
Raw Normal View History

2024-06-01 17:40:56 +00:00
from flask import Flask, request
from flask_restx import Api, Resource, fields
from functools import wraps
import json
import logging
import yaml
import os
import sys
2024-06-02 06:39:31 +00:00
import datetime
2024-06-02 10:28:35 +00:00
from datetime import timezone
from datetime import datetime
2024-06-02 07:38:43 +00:00
import time
2024-06-02 09:37:28 +00:00
import requests
2024-06-02 10:40:23 +00:00
import subprocess
2024-06-02 12:12:46 +00:00
from threading import Thread
2024-06-02 22:32:44 +00:00
from threading import Lock
2024-06-06 21:12:34 +00:00
from threading import Event
import signal
2024-06-06 10:22:13 +00:00
import sqlite3
2024-06-06 10:10:18 +00:00
2024-06-02 09:25:47 +00:00
# This code has been written for
2024-06-02 10:18:22 +00:00
# python3 3.11.2-1+b1
2024-06-02 09:25:47 +00:00
# python3-flask 2.2.2-3 all micro web framework based on Werkzeug and Jinja2 - Python 3.x
2024-06-02 21:42:55 +00:00
# flask-restx 1.3.0
2024-06-02 09:25:47 +00:00
2024-06-06 12:01:31 +00:00
# Flask-RESTX documentation: https://flask-restx.readthedocs.io/en/latest/
2024-06-02 09:25:47 +00:00
2024-06-02 22:32:44 +00:00
xprint_lock = Lock()
2024-06-01 17:40:56 +00:00
logging.basicConfig(level=logging.WARNING)
2024-06-02 22:32:44 +00:00
def xprint(*args, **kwargs):
"""Thread safe print function"""
with xprint_lock:
print(*args, **kwargs)
sys.stdout.flush()
2024-06-01 17:40:56 +00:00
authorizations = {
'apikey': {
'type': 'apiKey',
'in': 'header',
'name': 'X-API-KEY'
}
}
with open('./conf.yml') as conf:
yaml_conf = yaml.safe_load(conf)
flask_settings = yaml_conf.get("flask_settings")
api_key = yaml_conf.get("api_key")
if os.environ['FLASK_ENV'] == 'development':
flask_settings_env = yaml_conf.get("flask_settings_dev")
logging.getLogger().setLevel(logging.DEBUG)
elif os.environ['FLASK_ENV'] == 'production':
flask_settings_env = yaml_conf.get("flask_settings_prod")
2024-06-06 21:33:15 +00:00
logging.getLogger().setLevel(logging.INFO)
2024-06-01 17:40:56 +00:00
else:
logging.error("FLASK_ENV must be set to development or production.")
sys.exit(1)
targets = yaml_conf.get("targets")
modes = yaml_conf.get("modes")
http_port = yaml_conf.get("http_port")
shedding_order = yaml_conf.get("shedding_order")
rooms_settings = yaml_conf.get("rooms_settings")
default_target = yaml_conf.get("default_target")
relays_load = yaml_conf.get("relays_load")
awake_hour = yaml_conf.get("awake_hour")
sleep_hour = yaml_conf.get("sleep_hour")
forced_mode_duration = yaml_conf.get("forced_mode_duration")
load_shedder_interval = yaml_conf.get("load_shedder_interval")
relay_control_interval = yaml_conf.get("relay_control_interval")
hysteresis = yaml_conf.get("hysteresis")
max_load = yaml_conf.get("max_load")
load_margin = yaml_conf.get("load_margin")
def auth_required(func):
func = api.doc(security='apikey')(func)
@wraps(func)
def check_auth(*args, **kwargs):
if 'X-API-KEY' not in request.headers:
api.abort(401, 'API key required')
key = request.headers['X-API-KEY']
# Check key validity
if key != api_key:
api.abort(401, 'Wrong API key')
return func(*args, **kwargs)
return check_auth
2024-06-02 06:39:31 +00:00
##
def now():
2024-06-02 10:34:49 +00:00
return "["+datetime.now().strftime("%c")+"]"
2024-06-02 06:39:31 +00:00
def enabled_rooms():
rooms_list=[]
for room in rooms_settings:
if rooms_settings[room]["enabled"]:
rooms_list.append(room)
return rooms_list
def status_as_text():
return 'target: '+target_name+'\n'\
+'forced_mode: '+str(forced_mode)+'\n'\
+'\n'.join(['Target temperature for '+room+': '+str(rooms_settings[room][target_name])+'\n'+'Current temperature for '+room+': '+str(get_metric(rooms_settings[room]["metric"], current_time, relay_control_interval)) for room in enabled_rooms()])
2024-06-02 13:25:22 +00:00
def status_as_dict():
return {"target": target_name,\
"forced_mode": str(forced_mode),\
"enabled_rooms": {room: {'Target temperature': str(rooms_settings[room][target_name]), 'Current temperature': str(get_metric(rooms_settings[room]["metric"], current_time, relay_control_interval))} for room in enabled_rooms()}}
2024-06-02 07:47:06 +00:00
def relay_state(relay):
try:
returned_output = subprocess.check_output(["./relay.py", relay, "status"])
return returned_output.strip().decode("utf-8")
except Exception as e:
logging.error(e)
logging.error("relay "+relay+" status: Failed to command relays board.")
sys.stdout.flush()
return "Failed"
def set_relay(relay, state):
try:
returned_output = subprocess.check_output(["./relay.py", relay, state])
logging.info("set relay "+relay+" to "+state+", new global status: "+returned_output.decode("utf-8").split('\n')[1])
sys.stdout.flush()
return "OK"
except Exception as e:
logging.error(e)
logging.error("set relay "+relay+" to "+state+": Failed to command relays board.")
sys.stdout.flush()
return "KO"
def get_metric(metric, current_time, interval):
url = "http://localhost:3000/"+metric
try:
r = requests.get(url)
data = json.loads(r.text)
2024-06-02 10:28:35 +00:00
timestamp = datetime.fromisoformat(data['timestamp']).replace(tzinfo=timezone.utc).timestamp()
2024-06-02 07:47:06 +00:00
if current_time - timestamp < interval * 2:
return data['value']
else:
logging.warning("WARNING: No recent load data available.")
except Exception as e:
logging.error(e)
sys.stdout.flush()
return None
2024-06-06 10:10:18 +00:00
def get_forced_mode(cursor):
cursor.execute("SELECT value, timestamp FROM set_mode WHERE name='mode'")
row = cursor.fetchone()
2024-06-02 09:33:27 +00:00
if row is None:
return None
2024-06-02 07:47:06 +00:00
data = dict(zip(['value', 'timestamp'], row))
2024-06-06 11:12:33 +00:00
timestamp = datetime.fromisoformat(data['timestamp']).replace(tzinfo=timezone.utc).timestamp()
2024-06-02 07:47:06 +00:00
# We ignore old targets but never ignore absence modes
if data['value'] in targets and time.time() - timestamp > forced_mode_duration:
logging.debug("Ignoring old set mode.")
return None
else:
return data['value']
2024-06-02 06:39:31 +00:00
##
2024-06-06 21:12:34 +00:00
stop = Event()
def handler(signum, frame):
global stop
2024-06-07 00:46:18 +00:00
global t1
global dbconn
2024-06-06 21:12:34 +00:00
logging.info("Got interrupt: "+str(signum))
stop.set()
logging.info("Shutdown")
2024-06-07 00:46:18 +00:00
t1.join()
dbconn.close()
exit(0)
2024-06-06 21:12:34 +00:00
signal.signal(signal.SIGTERM,handler)
signal.signal(signal.SIGINT,handler)
2024-06-02 06:39:31 +00:00
2024-06-01 17:40:56 +00:00
app = Flask(__name__)
app.config.from_mapping(flask_settings)
app.config.from_mapping(flask_settings_env)
2024-06-06 12:01:31 +00:00
api = Api(app, version='1.0', title='Thermostat and load shedder',
2024-06-01 17:40:56 +00:00
description='API to read and set thermostat.', authorizations=authorizations)
ns_thermostat = api.namespace('thermostat/', description='Thermostat API')
2024-06-06 10:10:18 +00:00
# if the database file does not exist it will be created automatically
dbconn = sqlite3.connect("./instance/thermostat.db")
cursor = dbconn.cursor()
2024-06-01 17:40:56 +00:00
# we will only use name="mode" for set_mode table
# only modes that are set manually will be recorded in the database
2024-06-06 10:10:18 +00:00
cursor.execute("CREATE TABLE IF NOT EXISTS set_mode (name TEXT PRIMARY KEY DEFAULT 'mode' NOT NULL, \
value TEXT NOT NULL, \
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL)")
2024-06-06 11:41:24 +00:00
Set_mode_parser = api.parser()
Set_mode_parser.add_argument(
"value", type=str, choices=targets+modes, required=True, help="Thermostat mode", location="json"
2024-06-06 11:41:24 +00:00
)
2024-06-01 17:40:56 +00:00
Set_mode_resource_fields = {
2024-06-07 19:03:42 +00:00
'value': fields.String(required=True, description='Thermostat mode', enum=targets+modes),
}
Set_mode_model = api.model('Set_mode_Model', Set_mode_resource_fields)
2024-06-07 17:05:36 +00:00
set_mode_description = 'Temporary forced modes (will stay in effect for '+str(int(forced_mode_duration))+' seconds):\n'\
2024-06-07 17:09:33 +00:00
+'\n'.join(['- '+target+'\n' for target in targets])\
2024-06-07 17:05:36 +00:00
+'\n'\
+'Permanent forced modes:\n'\
2024-06-07 17:09:33 +00:00
+'\n'.join(['- '+mode+'\n' for mode in modes])
2024-06-06 21:12:34 +00:00
2024-06-01 17:40:56 +00:00
@ns_thermostat.route('/set_mode')
class Set_mode_thermostat(Resource):
2024-06-06 19:52:58 +00:00
@auth_required
@api.expect(Set_mode_model, validate=True)
2024-06-07 17:05:36 +00:00
@api.doc(description=set_mode_description)
2024-06-06 19:52:58 +00:00
def post(self):
global new_forced_mode
try:
args = Set_mode_parser.parse_args()
new_forced_mode = args["value"]
except Exception as e:
logging.error(e)
return "K0", 400
return "OK", 201
2024-06-02 20:52:39 +00:00
2024-06-01 17:40:56 +00:00
@ns_thermostat.route('/status')
class Status_thermostat(Resource):
2024-06-06 19:52:58 +00:00
@auth_required
def get(self):
result = status_as_dict()
return result
2024-06-01 17:40:56 +00:00
2024-06-06 12:01:31 +00:00
Set_verbosity_parser = api.parser()
Set_verbosity_parser.add_argument(
"value", type=str, choices=["DEBUG", "INFO", "WARNING"], required=True, help="Verbosity", location="json"
)
2024-06-02 21:29:16 +00:00
Set_verbosity_resource_fields = {
'value': fields.String(required=True, description='Verbosity', enum=["DEBUG", "INFO", "WARNING"])
}
Set_verbosity_model = api.model('Set_verbosity_Model', Set_verbosity_resource_fields)
2024-06-02 21:29:16 +00:00
@ns_thermostat.route('/set_verbosity')
class Set_verbosity_thermostat(Resource):
2024-06-06 19:52:58 +00:00
@auth_required
@api.expect(Set_verbosity_model, validate=True)
2024-06-06 19:52:58 +00:00
def put(self):
try:
args = Set_verbosity_parser.parse_args()
if args["value"] == 'DEBUG':
logging.getLogger().setLevel(logging.DEBUG)
elif args["value"] == 'INFO':
logging.getLogger().setLevel(logging.INFO)
elif args["value"] == 'WARNING':
logging.getLogger().setLevel(logging.WARNING)
else:
return "Bad request", 400
return "OK", 201
except Exception as e:
logging.error(e)
return "K0", 400
2024-06-02 21:29:16 +00:00
2024-06-01 17:40:56 +00:00
api.add_namespace(ns_thermostat)
2024-06-02 07:34:33 +00:00
# TODO: Get Linky overload warning
#cursor.execute("SELECT * FROM set_mode")
#rows = cursor.fetchall()
#for row in rows:
2024-06-02 22:32:44 +00:00
# xprint(row)
2024-06-02 07:34:33 +00:00
#sys.stdout.flush()
2024-06-01 17:40:56 +00:00
2024-06-02 12:58:11 +00:00
target_name = default_target
2024-06-02 13:03:21 +00:00
forced_mode = None
2024-06-02 13:08:17 +00:00
current_time = time.time()
2024-06-02 20:52:39 +00:00
new_forced_mode = None
2024-06-02 12:58:11 +00:00
2024-06-02 12:12:46 +00:00
def thermostat_loop():
2024-06-02 12:50:09 +00:00
start_time = time.time()
last_control_time = None
first_loop = True
2024-06-02 12:58:11 +00:00
global target_name
2024-06-02 13:03:21 +00:00
global forced_mode
2024-06-02 20:52:39 +00:00
global new_forced_mode
2024-06-02 13:08:17 +00:00
global current_time
2024-06-06 11:07:44 +00:00
dbconn = sqlite3.connect("./instance/thermostat.db")
cursor = dbconn.cursor()
2024-06-06 21:12:34 +00:00
logging.info("====== Starting ======")
2024-06-02 12:50:09 +00:00
2024-06-02 12:12:46 +00:00
while True:
2024-06-06 21:12:34 +00:00
if stop.is_set():
dbconn.close()
break
2024-06-02 12:45:26 +00:00
if new_forced_mode is not None:
2024-06-06 10:10:18 +00:00
cursor.execute("INSERT OR REPLACE INTO set_mode (value) VALUES ('"+new_forced_mode+"')")
dbconn.commit()
2024-06-02 12:45:26 +00:00
logging.info("Switch to "+new_forced_mode)
target_name = new_forced_mode
new_forced_mode = None
# Force immediate action:
last_control_time = None
current_time = time.time()
current_date = datetime.now()
today_awake_time = current_date.replace(hour=int(awake_hour.split(':')[0]), minute=int(awake_hour.split(':')[1]), second=0, microsecond=0)
today_sleep_time = current_date.replace(hour=int(sleep_hour.split(':')[0]), minute=int(sleep_hour.split(':')[1]), second=0, microsecond=0)
2024-06-06 10:10:18 +00:00
forced_mode = get_forced_mode(cursor)
2024-06-02 13:40:23 +00:00
if forced_mode is not None and forced_mode in targets:
if target_name != forced_mode:
target_name = forced_mode
logging.info("Switch to "+forced_mode)
else:
if forced_mode == "long_absence":
if target_name != "target_frost_protection" or first_loop:
target_name = "target_frost_protection"
logging.info("Switch to "+target_name)
elif forced_mode == "short_absence" or first_loop:
if target_name != "target_sleep_temperature":
target_name = "target_sleep_temperature"
logging.info("Switch to "+target_name)
elif current_date > today_awake_time and current_date < today_sleep_time:
if target_name != "target_unconfirmed_awake_temperature" and target_name != "target_awake_temperature":
target_name = "target_unconfirmed_awake_temperature"
logging.info("Switch to unconfirmed awake mode.")
elif current_date < today_awake_time or current_date > today_sleep_time:
if target_name != "target_unconfirmed_sleep_temperature" and target_name != "target_sleep_temperature":
target_name = "target_unconfirmed_sleep_temperature"
logging.info("Switch to unconfirmed sleep mode.")
first_loop = False
# Load shedder
current_load = get_metric("Modane_elec_main_power", current_time, load_shedder_interval)
if current_load is None:
time.sleep(load_shedder_interval)
continue
elif max_load - current_load < load_margin:
logging.warning("Load too high: "+str(current_load)+"VA")
total_shedded = 0
for room in shedding_order:
current_state = relay_state(rooms_settings[room]["relays"])
if current_state != "Failed":
logging.debug("Got relay_state: '"+current_state+"'")
if current_state == "1":
result = set_relay(rooms_settings[room]["relays"], "off")
if result == "OK":
total_shedded += relays_load[rooms_settings[room]["relays"]]
if max_load - current_load - total_shedded < load_margin:
logging.info("Load should be back to normal.")
break
# Thermostat
if last_control_time is None or current_time - last_control_time > relay_control_interval:
last_control_time = current_time
for room in rooms_settings:
if not rooms_settings[room]["enabled"]:
continue
target = rooms_settings[room][target_name]
logging.debug("Target: "+str(target))
temperature = get_metric(rooms_settings[room]["metric"], current_time, relay_control_interval)
if temperature is None:
continue
logging.debug(room+": "+str(temperature))
current_state = relay_state(rooms_settings[room]["relays"])
if current_state != "Failed":
logging.debug("Got relay_state: '"+current_state+"'")
if temperature < target - hysteresis:
if current_state == "0":
logging.info(room+": Target temperature is "+str(target))
logging.info(room+": Current temperature is "+str(temperature))
if current_load + relays_load[rooms_settings[room]["relays"]] + load_margin > max_load:
logging.warning(room+": Load too high cannot start heaters.")
else:
logging.info(room+": Starting heaters.")
2024-06-02 12:12:46 +00:00
# set_relay(rooms_settings[room]["relays"], "on")
2024-06-02 13:40:23 +00:00
sys.stdout.flush()
else:
logging.debug("Relay already on.")
elif temperature > target + hysteresis:
if current_state == "1":
logging.info(room+": Target temperature is "+str(target))
logging.info(room+": Current temperature is "+str(temperature))
logging.info(room+": Stopping heaters.")
sys.stdout.flush()
2024-06-02 12:12:46 +00:00
# set_relay(rooms_settings[room]["relays"], "off")
2024-06-02 13:40:23 +00:00
else:
logging.debug("Relay already off.")
2024-06-02 12:12:46 +00:00
time.sleep(load_shedder_interval)
2024-06-06 21:49:07 +00:00
logging.info("====== Ended successfully ======")
2024-06-02 07:47:06 +00:00
2024-06-06 23:26:04 +00:00
t1 = Thread(target=thermostat_loop)
t1.start()
2024-06-06 23:46:51 +00:00
gunicorn_app = app