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 (
2024-06-06 20:45:58 +00:00
" 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
2024-06-06 20:45:58 +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 ) ,
2024-06-06 20:45:58 +00:00
}
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
2024-06-06 20:45:58 +00:00
@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
2024-06-06 20:45:58 +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
2024-06-06 20:45:58 +00:00
@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