Finish Line, Web Interface of My Sunrise Alarm
Published on:
The script that joins all modules. Schedules the alarm, provides the web interface.
The source code of the script can be found at GitHub - sunrise-pialarm/main.py. This is part of a bigger project, which is fully open source. Check out GitHub - 11Firefox11/sunrise-pialarm.
Finish Line
This will be what connects all previously made modules. The main runner.
Schedule Based System
To save up compute power a scheduling system is required.
The flow is the following. Web server turns on, so the alarm can be set. The server turns off and schedules the alarm and the web server that can turn off the alarm. Web server turns on before a specified time of the alarm, so the sunrise can be turned off. The alarm starts. If alarm is stopped the next web server turn on must be scheduled, so the alarm can be set next time too.
For scheduling threading
and apscheduler
will be used. A helper environment variable named PIALARM_RUNNING
will be used too.
There are python related settings which are stored in environment_variables.py
, these will be used throughout the script.
There are few time related settings that are needed for this scheduling to work correctly.
Settings
alarm_at
: When the alarm will go off, the web interface will change this. It is in HH:MM format (string).server_state
:turn_on_at
: When the web server should turn on at, so the alarm time can be set. It is inHH:MM
format.turn_off_at
: When the web server should turn off at, so the alarm can be scheduled. It is inHH:MM
format.turn_on_before_alarm
: How many seconds before the sunrise start the web server should turn on, so it can be turned off before the alarm goes off. It is a number.
SettingsManager
To handle these settings I made a SettingsManager class
. I don’t want to go much into the details, but here are the key things to know about it:
- loads in the settings and puts them into variables
HH:MM
formats are stored in a list (to convert them there are functions:extract_secs_from_time_list
,time_list_to_secs
,time_list_to_str
)alarm_at
has a setter function which will automatically calculate when the web server should start (based onturn_on_before_alarm
andwhen_yellow
)
Initialize Modules
if __name__ == "__main__"
will be used, so the code will only run if it is run as main script. AlarmRunner
, SettingsManager
will be the two components.
from os import path
from environment_variables import PASSCODES_JSON_PATH, ALARM_JSON_PATH, SETTINGS_JSON_PATH, WEB_SERVER_IP, WEB_SERVER_PORT
from settings_manager import SettingsManager
from alarm_runner import AlarmRunner
# ...
if __name__ == "__main__":
this_dir = path.dirname(path.realpath(__file__))
generate_full_path = lambda p: path.join(this_dir, p)
alarm = AlarmRunner(generate_full_path(ALARM_JSON_PATH))
Create Scheduler
As detailed above, lots of schedulers will be created. Pass in a function, parameters (that will be passed to function), a time when to run it. Automatically it should extend the time with the date because add_job
requires a full datetime
object.
from datetime import datetime, timedelta
from apscheduler.schedulers.background import BackgroundScheduler
# ...
def create_scheduler(func: callable, timeList: list, args: list = []):
global settings
sch = BackgroundScheduler()
sch.configure()
now = datetime.now()
if len(timeList) == 2: # add seconds to list if needed
timeList.append(0)
to_run_at = datetime(now.year, now.month, now.day, *timeList)
if settings.time_list_to_secs([now.hour, now.minute, now.second]) > settings.time_list_to_secs([timeList[0], timeList[1], timeList[2]]): # if the time passed today, add one day
to_run_at = to_run_at + timedelta(days=1)
sch.add_job(func, "date", args=args, run_date=to_run_at) # add the schedule
sch.start()
return sch
# ...
Web Interface
The web server will be implemented with Flask
. 2 routes are required: one that serves the HTML file, one that handles the action of the user.
There are two very basic HTML files running.html
and waiting.html
. Both have a form and both submit data to /
with a POST
method.
At the GET
route if the PIALARM_RUNNING
is "True"
then running.html
will be served else waiting.html
. At waiting the current alarm time is passed into the HTML.
At the POST
route either alarm_at
or code
should be in the JSON that has been posted. If neither is in it, False
status
will be sent back. If the alarm is running and the given code is good then the alarm will be stopped. If new alarm_at
provided, it will be saved.
This will be in a function because the web server will be turned on by a schedule.
from os import path, environ
from flask import render_template, request, jsonify
# ...
def start_server():
app = Flask(__name__)
@app.route("/", methods=["GET"])
def main_get():
global alarm
return render_template(("waiting" if not environ["PIALARM_RUNNING"] == "True" else "running") + ".html", alarm_at=settings.time_list_to_str(settings.alarm_at))
@app.route("/", methods=["POST"])
def main_post():
global settings, alarm
data = dict(request.json)
if not data or (environ["PIALARM_RUNNING"] == "False" and "alarm_at" not in data) or (environ["PIALARM_RUNNING"] == "True" and "code" not in data):
return jsonify({"status": False})
if environ["PIALARM_RUNNING"] == "True":
if not settings.stop_alarm(data["code"]):
return jsonify({"status": False})
environ["PIALARM_RUNNING"] = "False"
else:
settings.set_alarm(data["alarm_at"])
return jsonify({"status": True})
# ...
2 more things are needed to complete the flask server: to actually start it, to stop it.
Threading
A thread is needed for the web server. The recommended solution is an extended Thread
class. Overwrite run
and shutdown
and that is it.
from threading import Thread
from werkzeug.serving import make_server
# ...
class ServerThread(Thread):
def __init__(self, app):
Thread.__init__(self)
self.server = make_server(WEB_SERVER_IP, WEB_SERVER_PORT, app) # create the actual WSGI server
self.ctx = app.app_context()
self.ctx.push()
def run(self):
self.server.serve_forever()
def shutdown(self):
self.server.shutdown()
# ...
def start_server():
global server
# ...
server = ServerThread(app)
server.start()
def stop_server():
global server
server.shutdown()
More Schedule
Actually develop the schedule cycle. Everything will be built around the flask server.
class flaskManager:
def __init__(self) -> None:
self.alarm_schedule = None
environ["PIALARM_RUNNING"] = "False"
def start(self, night=False):
global manager, settings, app
if night:
create_scheduler(manager.stop, settings.turn_off_at[0], [True]) # schedule the shutdown of web server
else:
environ["PIALARM_RUNNING"] = "True" # not night means the alarm is running
start_server()
def stop(self, night=False):
global settings, alarm
stop_server()
if night: # if it stops at night that means that it has to prepare for next morning
settings.load_config() # reload config
self.alarm_schedule = create_scheduler(alarm.start, settings.realalarm_at) # start the alarm
create_scheduler(self.start, settings.turn_on_at[1]) # start the server
else:
create_scheduler(self.start, settings.turn_on_at[0], [True]) # prepare for night
# ...
def start_server():
# ...
@app.route("/", methods=["POST"])
def main_post():
global settings, manager, alarm
# ...
if environ["PIALARM_RUNNING"] == "True":
if not settings.stop_alarm(data["code"]):
return jsonify({"status": False})
environ["PIALARM_RUNNING"] = "False"
manager.alarm_schedule.shutdown(wait=False) # stop actually the schedule
to_stop_at = datetime.now() + timedelta(seconds=1)
create_scheduler(manager.stop, [to_stop_at.hour, to_stop_at.minute, to_stop_at.second]) # stop the flask (one second after because we still need to respond to request)
# ...
if __name__ == "__main__":
# ...
manager = flaskManager()
Last step is to handle the schedules when the script starts. Plus we need to run an infinite sleep, so the script keeps running.
if __name__ == "__main__":
# ...
now = datetime.now()
now = settings.time_list_to_secs([now.hour, now.minute])
if ((settings.time_list_to_secs(settings.turn_on_at[0]) < now and now < settings.time_list_to_secs(settings.turn_off_at[0]))): # check if the web server should run now
manager.start(True)
else:
create_scheduler(manager.start, settings.turn_on_at[0], [True]) # else schedule the next web server start
while True:
sleep(5)
The project is fully done! Thank you for reading.
Previous Part