Global IP: –
+Local IP: –
+Valid Connection: –
+Last Seen: –
+diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f2fa764 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "spellright.language": [ + "de", + "en" + ], + "spellright.documentTypes": [ + "markdown", + "latex", + "plaintext" + ] +} \ No newline at end of file diff --git a/db/README.md b/db/README.md new file mode 100644 index 0000000..5f4748d --- /dev/null +++ b/db/README.md @@ -0,0 +1,20 @@ +# COMMANDS DB description + +## one command contains: +- command +- status +- command_id +- tstamp + +### command may contain one out of following strings: +```["toggle_machine","","","","","",""]``` + +### status may contain one out of following strings: +```["pending","failed","served","rejected"]``` + +### command_id is a random generated 4 chars long integer to identify the exact command between Server frontend Database and ESP + +### tstamp is the exact Date at the first ever creation of the db entry and does not say anything about completion or rejection. + +## NOTE: +### A created command is marked as failed after 5 minutes if its status is still pending, to ensure that no processes continue after a communication failure in the chain. \ No newline at end of file diff --git a/db/commands.db b/db/commands.db index f079480..68db1a3 100644 Binary files a/db/commands.db and b/db/commands.db differ diff --git a/db/init_scripts/initCom.py b/db/init_scripts/initCom.py index f563877..9d5b31c 100644 --- a/db/init_scripts/initCom.py +++ b/db/init_scripts/initCom.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +# Dieses Skript erstellt eine SQLite-Datenbank mit einer Tabelle für Befehle. + import os import sqlite3 diff --git a/init.py b/init.py new file mode 100644 index 0000000..7c79b78 --- /dev/null +++ b/init.py @@ -0,0 +1,29 @@ +from modules.persistence import save_dict +from datetime import datetime, UTC + +# Defaults +water = { + "lastFilled": str(datetime.now(UTC)), + "fill": 100, + "coffeesOnFill": 0 +} + +beans = { + "lastFilled": str(datetime.now(UTC)), + "fill": 100, + "coffeesOnFill": 0 +} + +machine = { + "state": "idle", + "connected": False, + "ready": False, + "peding_command": False, + "error": False, + "lastConnectionProof": str(datetime.now(UTC)) +} + +# In JSON-Dateien speichern +save_dict("water", water) +save_dict("beans", beans) +save_dict("machine", machine) diff --git a/modules/persistence.py b/modules/persistence.py new file mode 100644 index 0000000..74d6267 --- /dev/null +++ b/modules/persistence.py @@ -0,0 +1,23 @@ +import os +import json +from datetime import datetime + +BASE_PATH = os.path.join(os.path.dirname(__file__), "..", "persistence") +BASE_PATH = os.path.abspath(BASE_PATH) + + +def save_dict(name, data): + path = os.path.join(BASE_PATH, f"{name}.json") + os.makedirs(BASE_PATH, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, default=str, indent=2) + +def load_dict(name): + path = os.path.join(BASE_PATH, f"{name}.json") + if os.path.exists(path): + with open(path, "r") as f: + return json.load(f) + return {} # fallback falls Datei fehlt + +# no persistence but global variable important for tracking the esp-connection over runtime +esp_conn_infos = {"ip_global": None, "ip_local": None, "last_seen": None, "connection_valid": False} \ No newline at end of file diff --git a/modules/socketio.py b/modules/socketio.py new file mode 100644 index 0000000..dfd25c3 --- /dev/null +++ b/modules/socketio.py @@ -0,0 +1,16 @@ +# extensions.py +from flask_socketio import SocketIO +from modules.persistence import esp_conn_infos,load_dict, save_dict + +socketio = SocketIO(cors_allowed_origins="*", async_mode='threading') + +def resend_static_data(): + water = load_dict("water") + beans = load_dict("beans") + machine = load_dict("machine") + socketio.emit('static_data', { + 'water': water, + 'beans': beans, + 'machine': machine, + 'esp_conn_infos': esp_conn_infos +}) diff --git a/persistence/beans.json b/persistence/beans.json new file mode 100644 index 0000000..d4203b3 --- /dev/null +++ b/persistence/beans.json @@ -0,0 +1,6 @@ +{ + "lastFilled": "2025-05-06 20:56:49.436340+00:00", + "fill": 100, + "coffeesOnFill": 0, + "refilled": 0 +} \ No newline at end of file diff --git a/persistence/machine.json b/persistence/machine.json new file mode 100644 index 0000000..fb881c6 --- /dev/null +++ b/persistence/machine.json @@ -0,0 +1,8 @@ +{ + "state": "idle", + "connected": false, + "ready": false, + "peding_command": false, + "error": false, + "lastConnectionProof": "2025-05-06 20:56:49.436343+00:00" +} \ No newline at end of file diff --git a/persistence/water.json b/persistence/water.json new file mode 100644 index 0000000..76aa60e --- /dev/null +++ b/persistence/water.json @@ -0,0 +1,6 @@ +{ + "lastFilled": "2025-05-06 20:56:49.436328+00:00", + "fill": 21, + "coffeesOnFill": 0, + "refilled": 0 +} \ No newline at end of file diff --git a/routes/esp_routes.py b/routes/esp_routes.py index 9a62fe3..7791a34 100644 --- a/routes/esp_routes.py +++ b/routes/esp_routes.py @@ -1,11 +1,13 @@ from flask import Blueprint, render_template, request, jsonify -import routes.shared as shared from flask import Flask, jsonify, request import paho.mqtt.client as mqtt import json import random import sqlite3 import os +from modules.persistence import esp_conn_infos +import datetime +from modules.socketio import resend_static_data esp = Blueprint('eps', __name__, url_prefix='/unsecure/esp') @@ -15,11 +17,6 @@ MQTT_BROKER = "localhost" # oder IP/Domain MQTT_PORT = 1883 MQTT_TOPIC = "coffee/command" -@esp.route('/') -def fetch_command(): - pCd = shared.pending_command - shared.reset_command() - return jsonify(pCd) @esp.route('/online', methods=['POST']) def esp_online(): @@ -27,6 +24,12 @@ def esp_online(): sender_ip = request.headers.get('X-Forwarded-For', request.remote_addr) esp_ip = data.get("ip", "unknown") + esp_conn_infos["ip_local"] = esp_ip + esp_conn_infos["ip_global"] = sender_ip + esp_conn_infos["last_seen"] = datetime.now() + esp_conn_infos["connection_valid"] = True + resend_static_data() + print(f"ESP ONLINE von IP: {esp_ip}, roher IP: {sender_ip}") return jsonify({"status": "ok"}) diff --git a/routes/shared.py b/routes/shared.py deleted file mode 100644 index 68d4842..0000000 --- a/routes/shared.py +++ /dev/null @@ -1,10 +0,0 @@ -pending_command = {'command': None, 'command-URL': None, 'command-expected': None, 'command-expected-URL': None} - -def reset_command(): - global pending_command - pending_command = { - 'command': None, - 'command-URL': None, - 'command-expected': None, - 'command-expected-topic': None - } \ No newline at end of file diff --git a/routes/unsecure_routes.py b/routes/unsecure_routes.py index 5d445b7..0b408f4 100644 --- a/routes/unsecure_routes.py +++ b/routes/unsecure_routes.py @@ -1,25 +1,30 @@ from flask import Blueprint, render_template, request, jsonify -import routes.shared as shared unsecure = Blueprint('unsecure', __name__, url_prefix='/unsecure') +from modules.persistence import load_dict, save_dict +from modules.persistence import esp_conn_infos +# from flask_socketio import SocketIO +from modules.socketio import resend_static_data - +# def resend_static_data(): +# water = load_dict("water") +# beans = load_dict("beans") +# machine = load_dict("machine") +# socketio.emit('static_data', { +# 'water': water, +# 'beans': beans, +# 'machine': machine, +# 'esp_conn_infos': esp_conn_infos +# }) @unsecure.route('/') def index(): - return render_template('index.html', title='gimmiCoffee') + water = load_dict("water") + beans = load_dict("beans") + machine = load_dict("machine") + print(f"Water: {water}, Beans: {beans}, Machine: {machine}") + return render_template('index.html', title='gimmiCoffee', water=water, beans=beans, machine=machine, esp_conn_infos=esp_conn_infos) -@unsecure.route('/send') -def send_command(): - pCd = shared.pending_command - befehl = request.args.get('befehl') - if befehl: - pCd['command'] = befehl - pCd['command-URL'] = '/unsecure/esp/someURI' - pCd.update({'extra': 'test'}) - print(pCd) - return f"Befehl '{pCd}' gespeichert." - return "Kein Befehl angegeben.", 400 - -# @unsecure.route('/live') -# def test(): -# return render_template('live.html', users=[{'name': 'Max'}, {'name': 'Moritz'}, {'name': 'Hans'}]) +@unsecure.route('/update') +def update(): + resend_static_data() + return jsonify({"status": "ok"}) diff --git a/server.py b/server.py index 9f54945..66eb4e2 100644 --- a/server.py +++ b/server.py @@ -1,19 +1,26 @@ from flask import Flask -from flask_socketio import SocketIO from routes.unsecure_routes import unsecure from routes.esp_routes import esp import threading import time +import os import paho.mqtt.client as mqtt +from datetime import datetime, timedelta +import sqlite3 +from modules.persistence import load_dict, save_dict, esp_conn_infos +from modules.socketio import socketio, resend_static_data MQTT_BROKER = "localhost" MQTT_PORT = 1883 MQTT_TOPIC_SUB = "coffee/status" MQTT_TOPIC_SEND = "coffee/command" + app = Flask(__name__, static_url_path='/unsecure/static') app.config['SECRET_KEY'] = 'super-secret-key' -socketio = SocketIO(app, cors_allowed_origins="*", async_mode='threading') + +socketio.init_app(app) + # Blueprints registrieren app.register_blueprint(unsecure) @@ -33,7 +40,7 @@ def on_message(client, userdata, msg): 'message': msg.payload.decode() }) -# MQTT-Thread starten +# MQTT-Thread def mqtt_thread(): client = mqtt.Client() client.on_connect = on_connect @@ -41,24 +48,69 @@ def mqtt_thread(): client.connect(MQTT_BROKER, MQTT_PORT, 60) client.loop_forever() -# Dummy-Daten-Thread -# def send_data(): -# counter = 0 -# while True: -# data = { -# 'test': 'Live-Daten', -# 'status': 'OK', -# 'counter': counter -# } -# socketio.emit('update_data', data) -# counter += 1 -# time.sleep(2) +# DB-Cleanup-Thread +def cleanup_old_commands(): -# Beide Threads starten -#threading.Thread(target=send_data, daemon=True).start() + db_path = os.path.join(os.path.dirname(__file__), "db", "commands.db") + while True: + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + five_minutes_ago = (datetime.utcnow() - timedelta(minutes=5)).strftime('%Y-%m-%d %H:%M:%S') + + cursor.execute(""" + UPDATE commands + SET status = 'failed' + WHERE status = 'pending' AND tstamp <= ? + """, (five_minutes_ago,)) + + updated_rows = cursor.rowcount + conn.commit() + conn.close() + + if updated_rows > 0: + print(f"[Cleanup] {updated_rows} Einträge als 'failed' markiert.") + except Exception as e: + print(f"[Cleanup-Fehler] {e}") + + time.sleep(60) # jede Minute prüfen ob es pending Einträge gibt, die älter als 5 Minuten sind + +# Clear commands DB +def clear_commands_db(): + import os + import sqlite3 + + db_path = os.path.join(os.path.dirname(__file__), "db", "commands.db") + + if os.path.exists(db_path): + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("DELETE FROM commands") + conn.commit() + conn.close() + print("[DB] commands-Tabelle geleert.") + else: + print("[DB] Keine Datenbank gefunden – nichts geleert.") + +# Motitior ESP-Connection +def monitor_esp_connection(): + while True: + if esp_conn_infos["last_seen"]: + time_diff = datetime.now() - esp_conn_infos["last_seen"] + if time_diff > timedelta(minutes=30): + esp_conn_infos["connection_valid"] = False + resend_static_data() + time.sleep(60) # einmal pro Minute die Verbindung zum ESP prüfen + +### THREADS START ### +threading.Thread(target=cleanup_old_commands, daemon=True).start() +threading.Thread(target=monitor_esp_connection, daemon=True).start() #threading.Thread(target=mqtt_thread, daemon=True).start() if __name__ == '__main__': + #clear_commands_db() socketio.run(app, host='0.0.0.0', port=3060, allow_unsafe_werkzeug=True) + diff --git a/static/script.js b/static/script.js index 5006c53..d907bd4 100644 --- a/static/script.js +++ b/static/script.js @@ -1 +1,55 @@ -//im empty \ No newline at end of file +function gebId(id) { + return document.getElementById(id); +} + + +const water = JSON.parse(document.getElementById("waterData").innerText) +const beans = JSON.parse(document.getElementById("beansData").innerText) +const machine = JSON.parse(document.getElementById("machineData").innerText) +const esp_conn_infos = JSON.parse(document.getElementById("espData").innerText) + +// console.log(water) +// console.log(beans) +// console.log(machine) +// console.log(esp_conn_infos) + +gebId("beans-fill").innerText = beans.fill + "%" +gebId("water-fill").innerText = water.fill + "%" +gebId("beans-filled").innerText = beans.refilled +gebId("water-filled").innerText = water.refilled +gebId("ip_global").innerText = esp_conn_infos.ip_global +gebId("ip_local").innerText = esp_conn_infos.ip_local +gebId("valid_connection").innerText = esp_conn_infos.connection_valid +gebId("last_seen").innerText = esp_conn_infos.last_seen + +if (esp_conn_infos.connection_valid) { + gebId("validButt").classList.add("deniePress"); + gebId("machine-status-butt").classList.remove("deniePress"); +}else { + gebId("infoMain").classList.add("blink-orange"); +} +if (water.fill < 20) { + gebId("water-fill").parentElement.classList.add("blink-orange"); +} +if (beans.fill < 20) { + gebId("beans-fill").parentElement.classList.add("blink-orange"); +} + +function toggleMachine() { + if (gebId("machine-status-butt").classList.contains("deniePress")){ + return; + } + // console.log("toggleMachine"); + const result = confirm("Möchtest du den Vorgang wirklich ausführen?"); + if (!result) { + return; + } + console.log("toggleMachine"); + document.getElementById("machine-status").innerText = "PENDING"; + document.getElementById("machine-status-butt").classList.add("blink-orange"); + fetch('/unsecure/esp/toggle-machine', { method: 'POST' }) + .then(res => res.json()) + .then(data => { + console.log(data); + }); +} \ No newline at end of file diff --git a/static/socketio.js b/static/socketio.js new file mode 100644 index 0000000..010bec1 --- /dev/null +++ b/static/socketio.js @@ -0,0 +1,31 @@ +function gebId(id) { + return document.getElementById(id); +} + +const socket = io(); + +socket.on('static_data', (data) => { + gebId("beans-fill").innerText = data.beans.fill + "%" + gebId("water-fill").innerText = data.water.fill + "%" + gebId("beans-filled").innerText = data.beans.refilled + gebId("water-filled").innerText = data.water.refilled + if (data.water.fill < 20) { + gebId("water-fill").parentElement.classList.add("blink-orange"); + }else { + gebId("water-fill").parentElement.classList.remove("blink-orange"); + } + if (data.beans.fill < 20) { + gebId("beans-fill").parentElement.classList.add("blink-orange"); + }else { + gebId("beans-fill").parentElement.classList.remove("blink-orange"); + } + if (esp_conn_infos.connection_valid) { + gebId("validButt").classList.add("deniePress"); + gebId("infoMain").classList.remove("blink-orange"); + gebId("machine-status-butt").classList.remove("deniePress"); + }else { + gebId("validButt").classList.remove("deniePress"); + gebId("infoMain").classList.add("blink-orange"); + gebId("machine-status-butt").classList.add("deniePress"); + } +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css index 75f47de..1fdb997 100644 --- a/static/style.css +++ b/static/style.css @@ -115,16 +115,15 @@ header { .button-grid { display: flex; flex-wrap: wrap; - gap: 30px; - max-height: 70vh; - overflow-y: auto; + gap: 20px; + max-height: 73vh; } .grid-button { flex: 1 1 calc(50% - 15px); - background: #95a5a6; - color: white; - height: 23vh; + background: #818e8f; + /* color: white; */ + height: 22vh; display: flex; align-items: center; justify-content: center; @@ -139,7 +138,7 @@ header { border: 2px solid #000; padding: 20px; border-radius: 10px; - background: #f0f0f0; + background: #c4c4c4; overflow-y: auto; } @@ -156,13 +155,13 @@ header { position: relative; width: 100%; border-radius: 10px; - background: #2ecc71; + background: #5f5f5f; font-family: Arial, sans-serif; margin-bottom: 16px; display: flex; align-items: center; justify-content: center; - color: rgb(0, 0, 0); + color: rgb(255, 255, 255); flex: none; height: 25vh; transition: background 0.3s; @@ -170,21 +169,24 @@ header { .clickable:hover { - background: #27ae60; + background: #505050; cursor: pointer; } +.not:hover { + background: #5f5f5f; +} .clickable .top-left-text { position: absolute; top: 10px; left: 12px; font-size: 24px; - color: #000000; + color: #ffffff; } .clickable .center-number { font-size: 48px; font-weight: bold; - color: #222; + color: #ffffff; } .grid-button .top-left-text { position: absolute; @@ -202,9 +204,11 @@ header { color: #222; text-align: center; } -.deniePress:hover{ - cursor: not-allowed; +.defaultGray:hover { + background: #6a7274; + } + .blink-orange { background-color: orange; animation: pulse-orange 1.0s infinite; @@ -219,6 +223,34 @@ header { background-color: red; } .initBackRed:Hover{ - background-color: rgb(255, 37, 37); + background-color: rgb(252, 47, 47); } - \ No newline at end of file +.deniePress:hover{ + cursor: not-allowed; +} +.initBackRed:hover.deniePress { + background-color: red !important; + cursor: not-allowed; +} +.validationButtonOuter{ + display: flex; + align-items: center; + justify-content: center; + flex: 1; +} +.validButt{ + background-color: green; + padding: 10px; + border-radius: 5px; + transition: background 0.3s; +} +.validButt:hover{ + cursor: pointer; + background-color: rgb(0, 151, 0); +} +.validButt:hover.deniePress{ + background-color: green !important; +} +.deniePress:hover{ + cursor: not-allowed; +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index c230148..df516ab 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,7 @@
Global IP: –
+Local IP: –
+Valid Connection: –
+Last Seen: –
+