2024-06-01 17:40:56 +00:00
from flask import Flask , request
from flask_restx import Api , Resource , fields
from functools import wraps
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
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-01 17:40:56 +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
# python3-flask-migrate 4.0.4-1 all SQLAlchemy migrations for Flask using Alembic and Python 3
# python3-flask-sqlalchemy 3.0.3-1 all adds SQLAlchemy support to your Python 3 Flask application
# alembic 1.8.1-2 all lightweight database migration tool for SQLAlchemy
# python3-alembic 1.8.1-2 all lightweight database migration tool for SQLAlchemy - Python module
# python3-sqlalchemy 1.4.46+ds1-1 all SQL toolkit and Object Relational Mapper for Python 3
# python3-sqlalchemy-ext:i386 1.4.46+ds1-1+b1 i386 SQL toolkit and Object Relational Mapper for Python3 - C extension
# Flask-SQLAlchemy documentation: https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/quickstart/
# We use SQLAlchemy ORM style 2.x: https://docs.sqlalchemy.org/en/20/tutorial/data_select.html
2024-06-01 17:40:56 +00:00
logging . basicConfig ( level = logging . WARNING )
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 " )
logging . getLogger ( ) . setLevel ( logging . WARNING )
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 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-02 08:39:59 +00:00
def get_forced_mode ( ) :
2024-06-02 08:51:25 +00:00
with app . app_context ( ) :
2024-06-02 09:25:47 +00:00
row = db . session . execute ( db . select ( Set_mode . value , Set_mode . timestamp ) ) . first ( )
2024-06-02 09:33:27 +00:00
if row is None :
return None
2024-06-02 08:39:59 +00:00
#cur.execute("SELECT value, timestamp FROM set_mode WHERE name='mode'")
#row = cur.fetchone()
2024-06-02 07:47:06 +00:00
data = dict ( zip ( [ ' value ' , ' timestamp ' ] , row ) )
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
# 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-01 17:40:56 +00:00
app = Flask ( __name__ )
app . config . from_mapping ( flask_settings )
app . config . from_mapping ( flask_settings_env )
db = SQLAlchemy ( app )
api = Api ( app , version = ' 1.0 ' , title = ' Thermostat ' ,
description = ' API to read and set thermostat. ' , authorizations = authorizations )
ns_thermostat = api . namespace ( ' thermostat/ ' , description = ' Thermostat API ' )
# we will only use name="mode" for set_mode table
# only modes that are set manually will be recorded in the database
class Set_mode ( db . Model ) :
__tablename__ = " set_mode "
name = db . Column ( db . String , primary_key = True , server_default = ' mode ' , nullable = False )
value = db . Column ( db . String , nullable = False )
2024-06-01 21:42:54 +00:00
timestamp = db . Column ( db . DateTime , server_default = db . text ( " CURRENT_TIMESTAMP " ) , nullable = False )
2024-06-01 17:40:56 +00:00
Set_mode_resource_fields = {
' name ' : fields . String ( description = ' mode type ' ) ,
' value ' : fields . String ( required = True , description = ' value ' ) ,
' time ' : fields . DateTime ( dt_format = ' iso8601 ' ) ,
}
Set_mode_model = api . model ( ' Set_mode_Model ' , Set_mode_resource_fields )
@ns_thermostat.route ( ' /set_mode ' )
class Set_mode_thermostat ( Resource ) :
@auth_required
@api.expect ( Set_mode_model , validate = True )
def post ( self ) :
try :
data = Set_mode ( * * request . json )
db . session . add ( data )
db . session . commit ( )
return " OK " , 201
except Exception as e :
logging . error ( e )
return " K0 " , 400
@ns_thermostat.route ( ' /status ' )
class Status_thermostat ( Resource ) :
@auth_required
def get ( self ) :
2024-06-02 06:39:31 +00:00
result = dict ( datas = status_as_text ( ) )
2024-06-01 17:40:56 +00:00
logging . debug ( result )
return result
api . add_namespace ( ns_thermostat )
2024-06-01 22:21:10 +00:00
with app . app_context ( ) :
db . create_all ( )
2024-06-01 17:40:56 +00:00
migrate = Migrate ( app , db , compare_type = True )
2024-06-02 07:34:33 +00:00
start_time = time . time ( )
last_control_time = None
new_forced_mode = None
target_name = default_target
first_loop = True
# TODO: Get Linky overload warning
#cursor.execute("SELECT * FROM set_mode")
#rows = cursor.fetchall()
#for row in rows:
# print(row)
#sys.stdout.flush()
2024-06-01 17:40:56 +00:00
2024-06-02 12:12:46 +00:00
def thermostat_loop ( ) :
while True :
# # if stop.is_set():
# # httpd.shutdown()
# # httpd.server_close()
# # dbconn.close()
# # break
#
# if new_forced_mode is not None:
# with app.app_context():
# data = Set_mode({"value": new_forced_mode})
# db.session.add(data)
# db.session.commit()
# #cursor.execute("INSERT OR REPLACE INTO set_mode (value) VALUES ('"+new_forced_mode+"')")
# #dbconn.commit()
# 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)
# forced_mode = get_forced_mode()
# 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+"'")
2024-06-02 10:45:43 +00:00
# if current_state == "1":
2024-06-02 12:12:46 +00:00
# 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.")
# set_relay(rooms_settings[room]["relays"], "on")
# 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()
# set_relay(rooms_settings[room]["relays"], "off")
# else:
# logging.debug("Relay already off.")
time . sleep ( load_shedder_interval )
2024-06-02 07:47:06 +00:00
2024-06-02 12:22:55 +00:00
t1 = Thread ( target = thermostat_loop )
2024-06-02 12:36:11 +00:00
t1 . daemon = True
2024-06-02 12:27:25 +00:00
t1 . start ( )
2024-06-02 07:47:06 +00:00
logging . info ( " ====== Ended successfully ====== " )
2024-06-01 17:40:56 +00:00
#import argparse
#import signal
#from threading import Event
#from http.server import BaseHTTPRequestHandler
#import threading
#import socketserver
2024-06-02 10:18:22 +00:00
2024-06-01 17:40:56 +00:00
#from threading import Lock
#
#xprint_lock = Lock()
#
#def xprint(*args, **kwargs):
# """Thread safe print function"""
# with xprint_lock:
# print(*args, **kwargs)
# sys.stdout.flush()
#
#p = argparse.ArgumentParser(description='Thermostat and load shedder.')
#p.add_argument("-v", "--verbosity", help="Increase output verbosity",
# type=str, choices=['DEBUG', 'INFO', 'WARNING'], default='INFO')
#args = p.parse_args()
#
#verbosity = args.verbosity
#if verbosity == 'DEBUG':
# logging.basicConfig(level=logging.DEBUG)
#elif verbosity == 'INFO':
# logging.basicConfig(level=logging.INFO)
#elif verbosity == 'WARNING':
# logging.basicConfig(level=logging.WARNING)
#
#
#def api_help():
# return """
#### HTTP API description:
## /status
#
## /help : This message
#
#"""\
#+'Temporary forced modes (will stay in effect for '+str(int(forced_mode_duration))+' seconds):\n'\
#+'\n'.join(['# /'+target+'\n' for target in targets])\
#+'\n'\
#+'Permanent forced modes:\n'\
#+'\n'.join(['# /'+mode+'\n' for mode in modes])
#
2024-06-02 06:39:31 +00:00
2024-06-02 07:47:06 +00:00
2024-06-01 17:40:56 +00:00
#logging.info("====== Starting ======")
#stop = Event()
#last_data = {}
#
#def handler(signum, frame):
# global stop
# logging.info("Got interrupt: "+str(signum))
# stop.set()
# logging.info("Shutdown")
#
#signal.signal(signal.SIGTERM,handler)
#signal.signal(signal.SIGINT,handler)
#
#class MyHandler(BaseHTTPRequestHandler):
# def do_GET(self):
# global new_forced_mode
# request = self.path[1:]
# if request in targets or request in modes:
# self.send_response(200)
# new_forced_mode = request
# elif self.path == '/status':
# self.send_response(200)
# self.send_header('Content-type', 'text/plain')
# self.end_headers()
# self.wfile.write(status_as_text().encode("utf-8"))
# elif self.path == '/help':
# self.send_response(200)
# self.send_header('Content-type', 'text/plain')
# self.end_headers()
# self.wfile.write(api_help().encode("utf-8"))
# else:
# self.send_response(404)
# # This rewrites the BaseHTTP logging function
# def log_message(self, format, *args):
# if verbosity == 'INFO':
# xprint("%s - - [%s] %s" %
# (self.address_string(),
# self.log_date_time_string(),
# format%args))
#
#class WebThread(threading.Thread):
# def run(self):
# httpd.serve_forever()
#
2024-06-02 07:34:33 +00:00
2024-06-01 17:40:56 +00:00
#httpd = socketserver.TCPServer(("", http_port), MyHandler, bind_and_activate=False)
#httpd.allow_reuse_address = True
#httpd.server_bind()
#httpd.server_activate()
#webserver_thread = WebThread()
#webserver_thread.start()
#
2024-06-02 07:47:06 +00:00