From bc04332c9213b9f6a90978390e1226d3ec0f8eba Mon Sep 17 00:00:00 2001 From: Thomas Klaehn Date: Mon, 23 May 2022 16:20:14 +0200 Subject: [PATCH] Initial commit --- .vscode/launch.json | 27 ++++ home.service | 10 ++ home/__init__.py | 1 + home/app.py | 206 +++++++++++++++++++++++++++ home/config.json | 47 ++++++ home/gunicorn.conf.py | 4 + home/static/css/style.css | 73 ++++++++++ home/static/scripts/hochbeet.js | 147 +++++++++++++++++++ home/static/scripts/tomatentuppen.js | 147 +++++++++++++++++++ home/templates/hochbeet.html | 76 ++++++++++ home/templates/index.html | 24 ++++ home/templates/tomatentuppen.html | 76 ++++++++++ setup.py | 44 ++++++ 13 files changed, 882 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 home.service create mode 100644 home/__init__.py create mode 100644 home/app.py create mode 100644 home/config.json create mode 100644 home/gunicorn.conf.py create mode 100644 home/static/css/style.css create mode 100644 home/static/scripts/hochbeet.js create mode 100644 home/static/scripts/tomatentuppen.js create mode 100644 home/templates/hochbeet.html create mode 100644 home/templates/index.html create mode 100644 home/templates/tomatentuppen.html create mode 100644 setup.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..c383684 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Flask", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "home/app.py", + "FLASK_ENV": "development", + "FLASK_DEBUG": "0" + }, + "args": [ + "run", + "--no-debugger", + "--no-reload", + "--host=0.0.0.0", + "--port=8000" + ], + "jinja": true + } + ] +} \ No newline at end of file diff --git a/home.service b/home.service new file mode 100644 index 0000000..f700cab --- /dev/null +++ b/home.service @@ -0,0 +1,10 @@ +[Unit] +Description=home service +After=multi-user.target + +[Service] +Type=idle +ExecStart=gunicorn --bind 0.0.0.0:80 greenhouseui:app + +[Install] +WantedBy=multi-user.target diff --git a/home/__init__.py b/home/__init__.py new file mode 100644 index 0000000..5ac2301 --- /dev/null +++ b/home/__init__.py @@ -0,0 +1 @@ +from .app import app \ No newline at end of file diff --git a/home/app.py b/home/app.py new file mode 100644 index 0000000..e326537 --- /dev/null +++ b/home/app.py @@ -0,0 +1,206 @@ +"""Flask app""" +import datetime +import json +import os +import shutil +import threading +import time +import xmlrpc.client + +from flask import Flask +from flask import render_template +from flask import make_response +from flask import request +from flask import jsonify + +CONFIG_FILE = os.path.join(os.path.expanduser('~'), ".config/home/config.json") + +class Control(threading.Thread): + """Control""" + def __init__(self, config_file_name: str): + threading.Thread.__init__(self) + self.run_condition = True + self.config = None + self.config_file = config_file_name + self.water_state = [] + + + def reload_config(self): + """Reload config""" + try: + with open(self.config_file, "r", encoding="UTF-8") as handle: + self.config = json.load(handle) + except FileNotFoundError: + # create default config + os.makedirs(os.path.dirname(self.config_file), exist_ok=True) + shutil.copyfile("config.json", self.config_file) + with open(self.config_file, "r", encoding="UTF-8") as handle: + self.config = json.load(handle) + for _ in range(len(self.config['configs'])): + self.water_state.append(False) + + + def run(self): + while self.run_condition: + configs = self.config['configs'] + water_index = 0 + for config in configs: + water = config["water"] + autostate = water["autostate"] + if autostate: + times = water["times"] + idx = 0 + now = datetime.datetime.now() + if int(now.hour) >= 12: + idx = 1 + on_time_pattern = times[idx]['on_time'] + on_time_pattern = on_time_pattern.split(':') + on_time = now.replace(hour=int(on_time_pattern[0]), + minute=int(on_time_pattern[1]), + second=0, + microsecond=0) + off_time_pattern = times[idx]['off_time'] + off_time_pattern = off_time_pattern.split(':') + off_time = now.replace(hour=int(off_time_pattern[0]), + minute=int(off_time_pattern[1]), + second=0, + microsecond=0) + + + url = "http://" + config["host"] + ":" + str(config["port"]) + if now > on_time and now <= off_time and not self.water_state[water_index]: + client = xmlrpc.client.ServerProxy(url) + client.switch_relay(water["relay"], True) + self.water_state[water_index] = client.get_relay_state(water["relay"]) + elif now > off_time and self.water_state[water_index]: + client = xmlrpc.client.ServerProxy(url) + client.switch_relay(water["relay"], False) + self.water_state[water_index] = client.get_relay_state(water["relay"]) + water_index += 1 + time.sleep(1) + + +control = Control(CONFIG_FILE) + +app = Flask(__name__) + +@app.route('/', methods=['GET']) +def index(): + """Handle GET to index.html""" + return render_template('index.html') + + +@app.route('/hochbeet', methods=['GET']) +def hochbeet(): + """Handle GET to index.html""" + return render_template('hochbeet.html') + + +@app.route('/tomatentuppen', methods=['GET']) +def tomatentuppen(): + """Handle GET to index.html""" + return render_template('tomatentuppen.html') + + +@app.route('/sample/', methods=['GET']) +def get_sample(idx='0'): + """Handle GET to /sample/""" + response = make_response("", 404) + with open(CONFIG_FILE, "r", encoding="UTF-8") as handle: + config = json.load(handle) + for cfg in config["configs"]: + if cfg["id"] == idx: + water = cfg["water"] + relay = int(water["relay"]) + res = {} + url = "http://" + cfg["host"] + ":" + str(cfg["port"]) + client = xmlrpc.client.ServerProxy(url) + water = {} + water['id'] = str(idx) + water['state'] = client.get_relay_state(relay) + res['water'] = water + response = make_response(jsonify(res), 200) + break + return response + + + +@app.route('/sample/', methods=['PATCH']) +def patch_sample(idx='0'): + """Handle PATCH to /sample""" + record = json.loads(request.data) + response = make_response("", 404) + if 'id' in record: + with open(CONFIG_FILE, "r", encoding="UTF-8") as handle: + config = json.load(handle) + for cfg in config["configs"]: + if cfg["id"] == idx: + water = cfg["water"] + relay = int(water["relay"]) + url = "http://" + cfg["host"] + ":" + str(cfg["port"]) + client = xmlrpc.client.ServerProxy(url) + client.switch_relay(relay, record["waterstate"]) + response = make_response("", 204) + break + return response + + +@app.route('/config/', methods=['GET']) +def get_config(idx='0'): + """Hadnle GET to config/""" + response = make_response("", 404) + with open(CONFIG_FILE, "r", encoding="UTF-8") as handle: + config = json.load(handle) + for cfg in config["configs"]: + if cfg["id"] == idx: + water = cfg["water"] + res = {} + res["water"] = water + response = make_response(jsonify(res), 200) + break + return response + + +@app.route('/config/', methods=['PATCH']) +def patch_config(idx="0"): + """Handle PATCH to /config/""" + record = json.loads(request.data) + response = make_response("", 404) + config = None + with open(CONFIG_FILE, "r", encoding="UTF-8") as handle: + config = json.load(handle) + count = -1 + for cfg in config["configs"]: + count += 1 + if cfg["id"] == idx: + water = record['water'] + if 'autostate' in water: + config["configs"][count]["water"]["autostate"] = water["autostate"] + if 'times' in water: + config["configs"][count]["water"]["times"] = water["times"] + break + if config: + with open(CONFIG_FILE, "w", encoding="UTF-8") as handle: + json.dump(config, handle) + # prepare answer + res = {} + water = {} + water['id'] = idx + water['autostate'] = config["configs"][count]["water"]["autostate"] + water['times'] = config["configs"][count]["water"]["times"] + res['water'] = water + response = make_response(jsonify(res), 200) + + control.reload_config() + return response + + +def start_control(): + """Helper to start the control thread""" + control.reload_config() + control.start() + + +if __name__ == 'app': + start_control() + app.run(debug=True, host='0.0.0.0', port=8000) diff --git a/home/config.json b/home/config.json new file mode 100644 index 0000000..f4aa452 --- /dev/null +++ b/home/config.json @@ -0,0 +1,47 @@ +{ + "configs": + [ + { + "id": "hochbeet", + "host": "hochbeet", + "port": 64001, + "water": + { + "relay": 2, + "autostate": false, + "times": + [ + { + "on_time": "7:00", + "off_time": "7:20" + }, + { + "on_time": "19:00", + "off_time": "19:20" + } + ] + } + }, + { + "id": "tomatentuppen", + "host": "hochbeet", + "port": 64001, + "water": + { + "relay": 3, + "autostate": false, + "times": + [ + { + "on_time": "7:00", + "off_time": "7:20" + }, + { + "on_time": "19:00", + "off_time": "19:20" + } + ] + } + } + ] +} diff --git a/home/gunicorn.conf.py b/home/gunicorn.conf.py new file mode 100644 index 0000000..64c8fc0 --- /dev/null +++ b/home/gunicorn.conf.py @@ -0,0 +1,4 @@ + +def post_worker_init(server): + from home.app import start_control + start_control() diff --git a/home/static/css/style.css b/home/static/css/style.css new file mode 100644 index 0000000..08c9ea8 --- /dev/null +++ b/home/static/css/style.css @@ -0,0 +1,73 @@ +html, body { + font-size: 22px !important; + font-family: arial, verdana, helvetica, sans-serif; + color: #b6b6b6; + background: #282929; +} + +h1, h2, h3 {text-align: center;} +p {text-align: center;} +div {text-align: center;} + +.center { + margin-left: auto; + margin-right: auto; + text-align: center; +} + +input { + border: 1px #999999 solid; + background-color: #f6f6f6; + padding: 2px 4px 2px 4px; + margin: 0 0 0.5rem 0; + font-size: 1.0rem; + border-radius: 8px; +} + +input[type="submit" i] { + color: #b6b6b6; + background-color: #282929; + padding: 10px; + margin: 10px 0; + border-color: #b6b6b6; + border-width: 3px; + border-radius: 18px; + font-size: 20px; +} + +input[type="text"] { + border-radius: 8px; +} + +td { + padding: 8px; +} + +.table_left { + text-align: right; +} + +footer { + position: fixed; + /* left: 50%; */ + bottom: 20px; + /* transform: translate(-50%, -50%); */ + margin: 0 auto; +} + +a { + outline: none; + color: #b6b6b6; + text-decoration-line: none; +} + +select { + color: #b6b6b6; + background-color: #282929; + padding: 10px; + margin: 10px 0; + border-color: #b6b6b6; + border-width: 3px; + border-radius: 16px; + font-size: 20px; +} diff --git a/home/static/scripts/hochbeet.js b/home/static/scripts/hochbeet.js new file mode 100644 index 0000000..6f3ead8 --- /dev/null +++ b/home/static/scripts/hochbeet.js @@ -0,0 +1,147 @@ +var on_switch_water = function() { + var state = true; + if(document.getElementById("water_switch").value == "ausschalten") { + state = false; + } + var json_str = JSON.stringify( + { + "id": "hochbeet", + "waterstate": state + } + ); + patch_sample(json_str); +} + +var on_switch_auto_state = function() { + var state = true; + if(document.getElementById("auto_switch").value == "ausschalten") { + state = false; + } + var json_str = JSON.stringify( + { + "id": "hochbeet", + "water": + { + "autostate": state + } + } + ); + patch_config(json_str); +} + +var on_change_config = function() { + var on_time_one = document.getElementById("water_on_one").value; + var off_time_one = document.getElementById("water_off_one").value; + var on_time_two = document.getElementById("water_on_two").value; + var off_time_two = document.getElementById("water_off_two").value; + var json_str = JSON.stringify( + { + "id": "hochbeet", + "water": + { + "times": + [ + { + "on_time": on_time_one, + "off_time": off_time_one + }, + { + "on_time": on_time_two, + "off_time": off_time_two + } + ] + } + } + ); + patch_config(json_str); +} + +var http_patch_config = new XMLHttpRequest(); +http_patch_config.onreadystatechange = function () { + if (http_patch_config.readyState === 4) { + var status = http_patch_config.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_config(http_patch_config.responseText); + } + } else { + // request error + } +} +var patch_config = function(config) { + http_patch_config.abort(); + http_patch_config.open("PATCH", "/config/hochbeet"); + http_patch_config.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + http_patch_config.send(config); +} + +var patch_http = new XMLHttpRequest(); +var patch_sample = function(sample) { + patch_http.abort(); + patch_http.open("PATCH", "/sample/hochbeet"); + patch_http.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + patch_http.send(sample); +} + +var parse_config = function (event) { + var config = JSON.parse(event) + var output = "einschalten" + var visibility = 'hidden' + if(config.water) { + if(config.water.autostate) { + output = "ausschalten" + visibility = "visible" + document.getElementById("water_on_one").value = config.water.times[0].on_time; + document.getElementById("water_off_one").value = config.water.times[0].off_time; + document.getElementById("water_on_two").value = config.water.times[1].on_time; + document.getElementById("water_off_two").value = config.water.times[1].off_time; + } + document.getElementById("auto_switch").value = output; + document.getElementById("water_times").style.visibility = visibility + } +} + +var parse_sample = function (event) { + var sample = JSON.parse(event) + if(sample.water && sample.water.id == 'hochbeet') { + var switch_caption = "einschalten"; + if(sample.water.state) { + switch_caption = "ausschalten"; + } + document.getElementById("water_switch").value = switch_caption; + } +} + +var http = new XMLHttpRequest(); +http.onreadystatechange = function () { + if (http.readyState === 4) { + var status = http.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_sample(http.responseText); + setTimeout(function () { + http.open("GET", 'sample/hochbeet'); + http.send(); + }, 500); + } + } else { + // request error + } +} +http.open("GET", "sample/hochbeet"); +http.send(); + +var http_get_config = new XMLHttpRequest(); +http_get_config.onreadystatechange = function () { + if (http_get_config.readyState === 4) { + var status = http_get_config.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_config(http_get_config.responseText); + } + } else { + // request error + } +} +http_get_config.open("GET", "config/hochbeet"); +http_get_config.send(); diff --git a/home/static/scripts/tomatentuppen.js b/home/static/scripts/tomatentuppen.js new file mode 100644 index 0000000..63988c0 --- /dev/null +++ b/home/static/scripts/tomatentuppen.js @@ -0,0 +1,147 @@ +var on_switch_water = function() { + var state = true; + if(document.getElementById("water_switch").value == "ausschalten") { + state = false; + } + var json_str = JSON.stringify( + { + "id": "tomatentuppen", + "waterstate": state + } + ); + patch_sample(json_str); +} + +var on_switch_auto_state = function() { + var state = true; + if(document.getElementById("auto_switch").value == "ausschalten") { + state = false; + } + var json_str = JSON.stringify( + { + "id": "tomatentuppen", + "water": + { + "autostate": state + } + } + ); + patch_config(json_str); +} + +var on_change_config = function() { + var on_time_one = document.getElementById("water_on_one").value; + var off_time_one = document.getElementById("water_off_one").value; + var on_time_two = document.getElementById("water_on_two").value; + var off_time_two = document.getElementById("water_off_two").value; + var json_str = JSON.stringify( + { + "id": "tomatentuppen", + "water": + { + "times": + [ + { + "on_time": on_time_one, + "off_time": off_time_one + }, + { + "on_time": on_time_two, + "off_time": off_time_two + } + ] + } + } + ); + patch_config(json_str); +} + +var http_patch_config = new XMLHttpRequest(); +http_patch_config.onreadystatechange = function () { + if (http_patch_config.readyState === 4) { + var status = http_patch_config.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_config(http_patch_config.responseText); + } + } else { + // request error + } +} +var patch_config = function(config) { + http_patch_config.abort(); + http_patch_config.open("PATCH", "/config/tomatentuppen"); + http_patch_config.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + http_patch_config.send(config); +} + +var patch_http = new XMLHttpRequest(); +var patch_sample = function(sample) { + patch_http.abort(); + patch_http.open("PATCH", "/sample/tomatentuppen"); + patch_http.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + patch_http.send(sample); +} + +var parse_config = function (event) { + var config = JSON.parse(event) + var output = "einschalten" + var visibility = 'hidden' + if(config.water) { + if(config.water.autostate) { + output = "ausschalten" + visibility = "visible" + document.getElementById("water_on_one").value = config.water.times[0].on_time; + document.getElementById("water_off_one").value = config.water.times[0].off_time; + document.getElementById("water_on_two").value = config.water.times[1].on_time; + document.getElementById("water_off_two").value = config.water.times[1].off_time; + } + document.getElementById("auto_switch").value = output; + document.getElementById("water_times").style.visibility = visibility + } +} + +var parse_sample = function (event) { + var sample = JSON.parse(event) + if(sample.water && sample.water.id == 'tomatentuppen') { + var switch_caption = "einschalten"; + if(sample.water.state) { + switch_caption = "ausschalten"; + } + document.getElementById("water_switch").value = switch_caption; + } +} + +var http = new XMLHttpRequest(); +http.onreadystatechange = function () { + if (http.readyState === 4) { + var status = http.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_sample(http.responseText); + setTimeout(function () { + http.open("GET", 'sample/tomatentuppen'); + http.send(); + }, 500); + } + } else { + // request error + } +} +http.open("GET", "sample/tomatentuppen"); +http.send(); + +var http_get_config = new XMLHttpRequest(); +http_get_config.onreadystatechange = function () { + if (http_get_config.readyState === 4) { + var status = http_get_config.status; + if (status === 0 || (status >= 200 && status < 400)) { + // The request has been completed successfully + parse_config(http_get_config.responseText); + } + } else { + // request error + } +} +http_get_config.open("GET", "config/tomatentuppen"); +http_get_config.send(); diff --git a/home/templates/hochbeet.html b/home/templates/hochbeet.html new file mode 100644 index 0000000..12e1373 --- /dev/null +++ b/home/templates/hochbeet.html @@ -0,0 +1,76 @@ + + + + Hochbeet + + + + + + +

Hochbeet

+ + + + + + + + + +
Bewässerung + +
Zeigesteuerte Bewässerung + +
+ + + + \ No newline at end of file diff --git a/home/templates/index.html b/home/templates/index.html new file mode 100644 index 0000000..26773e6 --- /dev/null +++ b/home/templates/index.html @@ -0,0 +1,24 @@ + + + + Home + + + + + +

Home

+ + + + + diff --git a/home/templates/tomatentuppen.html b/home/templates/tomatentuppen.html new file mode 100644 index 0000000..41d2205 --- /dev/null +++ b/home/templates/tomatentuppen.html @@ -0,0 +1,76 @@ + + + + Tomatentuppen + + + + + + +

Tomatentuppen

+ + + + + + + + + +
Bewässerung + +
Zeigesteuerte Bewässerung + +
+ + + + \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1bbde7b --- /dev/null +++ b/setup.py @@ -0,0 +1,44 @@ + +import os +import shutil +import stat +from setuptools import setup +from setuptools.command.install import install + +NAME = 'home' +VERSION = '1' +AUTHOR = 'Thomas Klaehn' +EMAIL = 'tkl@blackfinn.de' +PACKAGES = ['home'] +REQUIRES = ['Flask', 'gunicorn'] + +CONFIG_FILE = 'config.json' +PACKAGE_DATA = {'home': ['config.json', 'templates/*', 'static/css/*', 'static/scripts/*']} + +SERVICEDIR = "/lib/systemd/system" +DAEMON_START_SCRIPT = os.path.join(SERVICEDIR, 'home.service') + +LOGFILE = "/var/log/home.log" + +class Install(install): + """Installer""" + def run(self): + install.run(self) + os.makedirs(SERVICEDIR, exist_ok=True) + shutil.copyfile('home.service', os.path.join(SERVICEDIR, DAEMON_START_SCRIPT)) + os.chmod(DAEMON_START_SCRIPT, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) + + try: + open(LOGFILE, 'r', encoding="UTF-8") + except FileNotFoundError: + os.makedirs(os.path.dirname(LOGFILE), exist_ok=True) + open(LOGFILE, 'x', encoding="UTF-8") + os.chmod(LOGFILE, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP | stat.S_IROTH | stat.S_IWOTH) + +setup(name=NAME, version=VERSION, long_description=__doc__, author=AUTHOR, author_email=EMAIL, + packages=PACKAGES, include_package_data=True, package_data=PACKAGE_DATA, zip_safe=False, + install_requires=REQUIRES, + cmdclass={ + 'install': Install + } + )