commit 6ab9ceed4b10b123ec58fee2d6de9a43c815bcd3 Author: olaf Date: Tue Jun 3 14:01:08 2025 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ce29a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Python-related +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.pdb +*.egg-info/ +*.eggs/ +*.env +.env + +# Byte-compiled / optimized / DLL files +*.pyc +*.pyo +*.pyd + +# Virtual environments +venv/ +env/ +.venv/ +.env/ + +# Logs and databases +*.log +*.sqlite3 +*.db + +# Docker-related +*.pid +*.tar +docker-compose.override.yml +docker-compose.override.*.yml +docker-compose.override.*.yaml + +# Node.js-related +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Dash and Flask cache +*.cache +*.pytest_cache/ +instance/ +*.mypy_cache/ +*.hypothesis/ +*.coverage +.coverage.* + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*.bak +*.tmp + +# OS-generated files +.DS_Store +Thumbs.db +desktop.ini + +# Devcontainer-related +.devcontainer/ + +received_screenshots/ +mosquitto/ +alte/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de61e9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +```dockerfile +# Use a stable Python base image +FROM python:3.11-slim + +# Build arguments for host user mapping +ARG USER_ID=1000 +ARG GROUP_ID=1000 + +# Create non-root user +RUN groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa + +# Ensure user exists +RUN getent passwd infoscreen_taa + +# Install locale dependencies and generate UTF-8 locale +RUN apt-get update && apt-get install -y locales \ + && sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen + +# Set environment variables for locale +ENV LANG=de_DE.UTF-8 \ + LANGUAGE=de_DE:de \ + LC_ALL=de_DE.UTF-8 + +# Enable Dash debug during development +ENV DASH_DEBUG_MODE=True + +# Working directory inside container +WORKDIR /app # entspricht mount in devcontainer.json + +# Copy only requirements first for efficient caching +COPY server/requirements-dev.txt ./ + +# Install dev dependencies under the non-root user +USER infoscreen_taa +RUN pip install --upgrade pip \ + && pip install --user -r requirements-dev.txt + +# Switch back to root to copy source files and fix permissions +USER root + +# Copy the server application code into /app +COPY server/ /app +RUN chown -R infoscreen_taa:infoscreen_taa /app + +# Create config directory under the non-root user's home +RUN mkdir -p /home/infoscreen_taa/.config/Infoscreen-Server \ + && chown -R infoscreen_taa:infoscreen_taa /home/infoscreen_taa/.config/Infoscreen-Server + +# Expose development ports +EXPOSE 8000 8050 + +# Use a long-running process so the container stays alive +CMD ["tail", "-f", "/dev/null"] +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/create_init_files.py b/create_init_files.py new file mode 100644 index 0000000..54383fc --- /dev/null +++ b/create_init_files.py @@ -0,0 +1,17 @@ +import os + +folders = [ + "server", + "dashboard", + "dashboard/callbacks", + "dashboard/utils", +] + +for folder in folders: + path = os.path.join(os.getcwd(), folder, "__init__.py") + if not os.path.exists(path): + with open(path, "w") as f: + pass # Leere Datei anlegen + print(f"Angelegt: {path}") + else: + print(f"Existiert bereits: {path}") \ No newline at end of file diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..f2335b4 --- /dev/null +++ b/dashboard/Dockerfile @@ -0,0 +1,37 @@ +# dashboard/Dockerfile +# Produktions-Dockerfile für die Dash-Applikation + +# --- Basis-Image --- +FROM python:3.13-slim + +# --- Arbeitsverzeichnis im Container --- +WORKDIR /app + +# --- Systemabhängigkeiten installieren (falls benötigt) --- +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# --- Python-Abhängigkeiten kopieren und installieren --- +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# --- Applikationscode kopieren --- +COPY dashboard/ /app + +# --- Non-Root-User anlegen und Rechte setzen --- +ARG USER_ID=1000 +ARG GROUP_ID=1000 +RUN groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} \ + --shell /bin/bash --create-home infoscreen_taa \ + && chown -R infoscreen_taa:infoscreen_taa /app +USER infoscreen_taa + +# --- Port für Dash exposed --- +EXPOSE 8050 + +# --- Startbefehl: Gunicorn mit Dash-Server --- +# "app.py" enthält: app = dash.Dash(...); server = app.server +CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:8050", "app:server"] diff --git a/dashboard/Dockerfile.dev b/dashboard/Dockerfile.dev new file mode 100644 index 0000000..22aa010 --- /dev/null +++ b/dashboard/Dockerfile.dev @@ -0,0 +1,47 @@ +# dashboard/Dockerfile.dev +# Entwicklungs-Dockerfile für das Dash-Dashboard + +FROM python:3.13-slim + +# Build args für UID/GID +ARG USER_ID=1000 +ARG GROUP_ID=1000 + +# Systemabhängigkeiten (falls nötig) +RUN apt-get update \ + && apt-get install -y --no-install-recommends locales curl \ + && sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && rm -rf /var/lib/apt/lists/* + +# Locale setzen +ENV LANG=de_DE.UTF-8 \ + LANGUAGE=de_DE:de \ + LC_ALL=de_DE.UTF-8 + +# Non-root User anlegen +RUN groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa + +# Arbeitsverzeichnis +WORKDIR /app + +# Kopiere nur Requirements für schnellen Rebuild +COPY requirements.txt ./ + +# Installiere Abhängigkeiten +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt + +# Setze Entwicklungs-Modus +ENV DASH_DEBUG_MODE=True +ENV API_URL=http://server:8000/api + +# Ports für Dash und optional Live-Reload +EXPOSE 8050 + +# Wechsle zum non-root User +USER infoscreen_taa + +# Dev-Start: Dash mit Hot-Reload +CMD ["tail", "-f", "/dev/null"] diff --git a/dashboard/__init__.py b/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/app.py b/dashboard/app.py new file mode 100644 index 0000000..10bb02b --- /dev/null +++ b/dashboard/app.py @@ -0,0 +1,32 @@ +# dashboard/app.py + +from dash import Dash, html, dcc, page_container +from flask import Flask +import dash_bootstrap_components as dbc +from components.header import Header +# from components.sidebar import Sidebar +import callbacks.ui_callbacks # wichtig! +import dashboard.callbacks.overview_callbacks # <-- Das registriert die Callbacks +from config import SECRET_KEY, ENV + +server = Flask(__name__) +server.secret_key = SECRET_KEY + +app = Dash( + __name__, + server=server, + use_pages=True, + external_stylesheets=[dbc.themes.BOOTSTRAP], + suppress_callback_exceptions=True +) + +app.layout = html.Div([ + Header(), + dcc.Store(id="sidebar-state", data={"collapsed": False}), + html.Div(id="sidebar"), # Sidebar wird dynamisch gerendert + html.Div(page_container, className="page-content"), +]) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8050, debug=(ENV=="development")) diff --git a/dashboard/assets/TAA_Logo.png b/dashboard/assets/TAA_Logo.png new file mode 100644 index 0000000..698eb92 Binary files /dev/null and b/dashboard/assets/TAA_Logo.png differ diff --git a/dashboard/assets/custom.css b/dashboard/assets/custom.css new file mode 100644 index 0000000..62754cf --- /dev/null +++ b/dashboard/assets/custom.css @@ -0,0 +1,188 @@ +/* ========================== + Allgemeines Layout + ========================== */ +body { + margin: 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + background-color: #f8f9fa; + padding-top: 60px; /* Platz für den fixen Header schaffen */ +} + +/* page-content (rechts neben der Sidebar) */ +.page-content { + margin-left: 220px; + padding: 20px; + transition: margin-left 0.3s ease; + min-height: calc(100vh - 60px); /* Mindesthöhe minus Header-Höhe */ +} + +/* Wenn Sidebar collapsed ist, reduziere margin-left */ +.sidebar.collapsed ~ .page-content { + margin-left: 70px; +} + +/* ========================== + Header + ========================== */ +.app-header { + position: fixed; + top: 0; + left: 0; + height: 60px; + width: 100%; + background-color: #e4d5c1; + color: #7c5617; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + z-index: 1100; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +.logo { + height: 40px; + margin-right: 10px; +} + +.app-title { + font-size: 1.5rem; + font-weight: bold; +} + +.org-name { + font-size: 1rem; + color: #7c5617; +} + +.header-right { + display: flex; + align-items: center; +} + +/* ========================== + Sidebar + ========================== */ +.sidebar { + width: 220px; + transition: width 0.3s ease; + background-color: #e4d5c1; + color: black; + height: calc(100vh - 60px); /* Höhe minus Header */ + position: fixed; + top: 60px; /* Den gleichen Wert wie Header-Höhe verwenden */ + left: 0; + z-index: 1000; + overflow-x: hidden; + overflow-y: auto; +} + +.sidebar.collapsed { + width: 70px; +} + +/* Sidebar Toggle Button (Burger-Icon) */ +.sidebar-toggle { + text-align: right; + padding: 5px 10px; +} + +.toggle-button { + background: none; + border: none; + color: #7c5617; + cursor: pointer; + font-size: 1.2rem; + transition: transform 0.1s ease, background-color 0.2s ease; + padding: 8px; + border-radius: 4px; +} + +.toggle-button:hover { + background-color: #7c5617; + color: #e4d5c1; + transform: scale(1.05); +} + +.toggle-button:active { + transform: scale(0.95); +} + +/* Navigation in der Sidebar */ +.sidebar-nav .nav-link { + color: #7c5617; + padding: 10px 15px; + display: flex; + align-items: center; + border-radius: 4px; + margin: 2px 8px; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.sidebar-nav .nav-link:hover { + background-color: #7c5617; + color: #e4d5c1; +} + +.sidebar-nav .nav-link.active { + background-color: #7c5617; + color: #e4d5c1; +} + +/* Text neben Icons */ +.sidebar-label { + display: inline-block; + margin-left: 10px; + white-space: nowrap; + transition: opacity 0.3s ease, width 0.3s ease; +} + +/* Wenn Sidebar collapsed ist, blendet das Label aus */ +.sidebar.collapsed .sidebar-label { + opacity: 0; + width: 0; + overflow: hidden; +} + +/* Tooltips (Bootstrap-Tooltips) */ +.tooltip { + z-index: 2000; + background-color: #7c5617; + color: #e4d5c1; +} + +/* Optional: Tooltips nur anzeigen, wenn Sidebar collapsed ist */ +/* Da dash-bootstrap-components Tooltips in einen anderen DOM-Layer rendert, + kann man bei Bedarf per Callback steuern, ob sie geöffnet sind oder nicht. + Dieser Block ist nur ein Zusatz – das Haupt-Show/Hiding erfolgt per + is_open-Callback. */ +.sidebar:not(.collapsed) ~ .tooltip { + display: none !important; +} + +/* ========================== + Responsive (bei Bedarf) + ========================== */ +@media (max-width: 768px) { + body { + padding-top: 60px; /* Header-Platz auch auf mobilen Geräten */ + } + + .sidebar { + position: fixed; + height: calc(100vh - 60px); + z-index: 1050; + } + + .page-content { + margin-left: 0; + } + + .sidebar.collapsed { + width: 0; + } + + .sidebar.collapsed ~ .page-content { + margin-left: 0; + } +} \ No newline at end of file diff --git a/dashboard/assets/logo.png b/dashboard/assets/logo.png new file mode 100644 index 0000000..0ffb524 Binary files /dev/null and b/dashboard/assets/logo.png differ diff --git a/dashboard/callbacks/__init__.py b/dashboard/callbacks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/callbacks/appoinments_callbacks.py b/dashboard/callbacks/appoinments_callbacks.py new file mode 100644 index 0000000..fb17367 --- /dev/null +++ b/dashboard/callbacks/appoinments_callbacks.py @@ -0,0 +1,27 @@ +# dashboard/callbacks/appointments_callbacks.py +import dash +from dash import Input, Output, State, dcc +from flask import session +from utils.db import execute_query, execute_non_query + +@dash.callback( + Output("appointments-feedback", "children"), + Input("btn-add-appointment", "n_clicks"), + State("input-client-id", "value"), + State("input-appointment-date", "date"), + State("input-appointment-time", "value"), + State("input-appointment-desc", "value"), + prevent_initial_call=True +) +def add_appointment(n_clicks, client_id, date, time_str, desc): + if "role" not in session: + return dcc.Location(href="/login") + if n_clicks and n_clicks > 0: + datetime_str = f"{date} {time_str}:00" + sql = """ + INSERT INTO appointments (client_id, start_datetime, description) + VALUES (%s, %s, %s) + """ + rc = execute_non_query(sql, (client_id, datetime_str, desc)) + return "Erstellt." if rc else "Fehler beim Anlegen." + return "" diff --git a/dashboard/callbacks/auth_callbacks.py b/dashboard/callbacks/auth_callbacks.py new file mode 100644 index 0000000..ee0a85c --- /dev/null +++ b/dashboard/callbacks/auth_callbacks.py @@ -0,0 +1,31 @@ +# dashboard/callbacks/auth_callbacks.py +import dash +from dash import Input, Output, State, dcc +from flask import session +from utils.auth import check_password, get_user_role +from config import ENV +from utils.db import execute_query + +@dash.callback( + Output("login-feedback", "children"), + Output("header-right", "children"), + Input("btn-login", "n_clicks"), + State("input-user", "value"), + State("input-pass", "value"), + prevent_initial_call=True +) +def login_user(n_clicks, username, password): + if ENV == "development": + # Dev‐Bypass: setze immer Admin‐Session und leite weiter + session["username"] = "dev_admin" + session["role"] = "admin" + return dcc.Location(href="/overview", id="redirect-dev"), None + + # Produktions‐Login: User in DB suchen + user = execute_query("SELECT username, pwd_hash, role FROM users WHERE username=%s", (username,), fetch_one=True) + if user and check_password(password, user["pwd_hash"]): + session["username"] = user["username"] + session["role"] = user["role"] + return dcc.Location(href="/overview", id="redirect-ok"), None + else: + return "Ungültige Zugangsdaten.", None diff --git a/dashboard/callbacks/overview_callbacks.py b/dashboard/callbacks/overview_callbacks.py new file mode 100644 index 0000000..548e467 --- /dev/null +++ b/dashboard/callbacks/overview_callbacks.py @@ -0,0 +1,104 @@ +# dashboard/callbacks/overview_callbacks.py +import sys +print(sys.path) +import threading +import dash +from dash import Input, Output, State, MATCH, html, dcc +from flask import session +from utils.db import get_session # Diese Funktion muss eine SQLAlchemy-Session liefern! +from utils.mqtt_client import publish, start_loop +from config import ENV +import dash_bootstrap_components as dbc +import os +from server.models import Client + +mqtt_thread_started = False +SCREENSHOT_DIR = "/workspace/received-screenshots" + +def ensure_mqtt_running(): + global mqtt_thread_started + if not mqtt_thread_started: + thread = threading.Thread(target=start_loop, daemon=True) + thread.start() + mqtt_thread_started = True + +def get_latest_screenshot(client_uuid): + prefix = f"client_{client_uuid}_" + try: + files = [f for f in os.listdir(SCREENSHOT_DIR) if f.startswith(prefix)] + if not files: + return "/assets/placeholder.png" + latest = max(files, key=lambda x: os.path.getmtime(os.path.join(SCREENSHOT_DIR, x))) + return f"/received-screenshots/{latest}" + except Exception: + return "/assets/placeholder.png" + +@dash.callback( + Output("clients-cards-container", "children"), + Input("interval-update", "n_intervals") +) +def update_clients(n): + # Auto-Login im Development-Modus + if "role" not in session: + if ENV == "development": + session["role"] = "admin" + else: + return dcc.Location(id="redirect-login", href="/login") + + ensure_mqtt_running() + session_db = get_session() + clients = session_db.query(Client).all() + session_db.close() + cards = [] + for client in clients: + uuid = client.uuid + screenshot = get_latest_screenshot(uuid) + card = dbc.Card( + [ + dbc.CardHeader(client.location or client.hardware_hash), + dbc.CardBody([ + html.Img( + src=screenshot, + style={"width": "160px", "height": "90px", "object-fit": "cover"}, + ), + html.P(f"IP: {client.ip_address or '-'}", className="card-text"), + html.P(f"Letzte Aktivität: {client.last_alive or '-'}", className="card-text"), + dbc.ButtonGroup([ + dbc.Button("Reload Page", color="primary", id={"type": "btn-reload", "index": uuid}, n_clicks=0), + dbc.Button("Restart Client", color="danger", id={"type": "btn-restart", "index": uuid}, n_clicks=0), + ], className="mt-2"), + html.Div(id={"type": "restart-feedback", "index": uuid}), + html.Div(id={"type": "reload-feedback", "index": uuid}), + ]), + ], + className="mb-4", + style={"width": "18rem"}, + ) + cards.append(dbc.Col(card, width=4)) + return dbc.Row(cards) + +@dash.callback( + Output({"type": "restart-feedback", "index": MATCH}, "children"), + Input({"type": "btn-restart", "index": MATCH}, "n_clicks"), + State({"type": "btn-restart", "index": MATCH}, "id") +) +def on_restart(n_clicks, btn_id): + if n_clicks and n_clicks > 0: + cid = btn_id["index"] + payload = '{"command": "restart"}' + ok = publish(f"clients/{cid}/control", payload) + return "Befehl gesendet." if ok else "Fehler beim Senden." + return "" + +@dash.callback( + Output({"type": "reload-feedback", "index": MATCH}, "children"), + Input({"type": "btn-reload", "index": MATCH}, "n_clicks"), + State({"type": "btn-reload", "index": MATCH}, "id") +) +def on_reload(n_clicks, btn_id): + if n_clicks and n_clicks > 0: + cid = btn_id["index"] + payload = '{"command": "reload"}' + ok = publish(f"clients/{cid}/control", payload) + return "Befehl gesendet." if ok else "Fehler beim Senden." + return "" diff --git a/dashboard/callbacks/settings_callbacks.py b/dashboard/callbacks/settings_callbacks.py new file mode 100644 index 0000000..f26901b --- /dev/null +++ b/dashboard/callbacks/settings_callbacks.py @@ -0,0 +1,20 @@ +# dashboard/callbacks/settings_callbacks.py +import dash +from dash import Input, Output, State, dcc +from flask import session +from utils.db import execute_query, execute_non_query + +@dash.callback( + Output("settings-feedback", "children"), + Input("btn-save-settings", "n_clicks"), + State("input-default-volume", "value"), + prevent_initial_call=True +) +def save_settings(n_clicks, volume): + if "role" not in session: + return dcc.Location(href="/login") + if n_clicks and n_clicks > 0: + sql = "UPDATE global_settings SET value=%s WHERE key='default_volume'" + rc = execute_non_query(sql, (volume,)) + return "Einstellungen gespeichert." if rc else "Speichern fehlgeschlagen." + return "" diff --git a/dashboard/callbacks/ui_callbacks.py b/dashboard/callbacks/ui_callbacks.py new file mode 100644 index 0000000..ddac799 --- /dev/null +++ b/dashboard/callbacks/ui_callbacks.py @@ -0,0 +1,31 @@ +# dashboard/callbacks/ui_callbacks.py + +from dash import Input, Output, State, callback +from components.sidebar import Sidebar + +# 1) Toggle-Callback: Umschalten von collapsed = False ↔ True +@callback( + Output("sidebar-state", "data"), + Input("btn-toggle-sidebar", "n_clicks"), + State("sidebar-state", "data"), + prevent_initial_call=True +) +def toggle_sidebar(n_clicks, state): + # Wenn der Button geklickt wurde, invertiere den collapsed-Wert + collapsed = state.get("collapsed", False) + return {"collapsed": not collapsed} + + +# 2) Render-Callback: Zeichnet die Sidebar neu und setzt die CSS-Klasse +@callback( + [Output("sidebar", "children"), Output("sidebar", "className")], + Input("sidebar-state", "data") +) +def render_sidebar(state): + collapsed = state.get("collapsed", False) + sidebar_class = "sidebar collapsed" if collapsed else "sidebar" + + # Sidebar() gibt jetzt nur den Inhalt zurück + sidebar_content = Sidebar(collapsed=collapsed) + + return sidebar_content, sidebar_class \ No newline at end of file diff --git a/dashboard/callbacks/users_callbacks.py b/dashboard/callbacks/users_callbacks.py new file mode 100644 index 0000000..03e8261 --- /dev/null +++ b/dashboard/callbacks/users_callbacks.py @@ -0,0 +1,24 @@ +# dashboard/callbacks/users_callbacks.py +import dash +from dash import Input, Output, State, dcc +from flask import session +from utils.db import execute_query, execute_non_query +from utils.auth import hash_password + +@dash.callback( + Output("users-feedback", "children"), + Input("btn-new-user", "n_clicks"), + State("input-new-username", "value"), + State("input-new-password", "value"), + State("input-new-role", "value"), + prevent_initial_call=True +) +def create_user(n_clicks, uname, pwd, role): + if session.get("role") != "admin": + return "Keine Berechtigung." + if n_clicks and n_clicks > 0: + pwd_hash = hash_password(pwd) + sql = "INSERT INTO users (username, pwd_hash, role) VALUES (%s, %s, %s)" + rc = execute_non_query(sql, (uname, pwd_hash, role)) + return "Benutzer erstellt." if rc else "Fehler beim Erstellen." + return "" diff --git a/dashboard/components/header.py b/dashboard/components/header.py new file mode 100644 index 0000000..ca92250 --- /dev/null +++ b/dashboard/components/header.py @@ -0,0 +1,13 @@ +# dashboard/components/header.py +from dash import html + +def Header(): + return html.Div( + className="app-header", + children=[ + html.Img(src="/assets/logo.png", className="logo"), + html.Span("Infoscreen-Manager", className="app-title"), + html.Span(" – Organisationsname", className="org-name"), + html.Div(id="header-right", className="header-right") # Platzhalter für Login/Profil‐Button + ] + ) diff --git a/dashboard/components/sidebar.py b/dashboard/components/sidebar.py new file mode 100644 index 0000000..03c129a --- /dev/null +++ b/dashboard/components/sidebar.py @@ -0,0 +1,70 @@ +# dashboard/components/sidebar.py + +from dash import html +import dash_bootstrap_components as dbc +from dash_iconify import DashIconify +from typing import List, Any + +def Sidebar(collapsed: bool = False) -> List[Any]: + """ + Gibt nur den Inhalt der Sidebar zurück (ohne das äußere div mit id="sidebar"). + Das äußere div wird bereits in app.py definiert. + """ + + nav_items = [ + {"label": "Übersicht", "href": "/overview", "icon": "mdi:view-dashboard"}, + {"label": "Termine", "href": "/appointments","icon": "mdi:calendar"}, + {"label": "Einstellungen","href": "/settings", "icon": "mdi:cog"}, + {"label": "Benutzer", "href": "/users", "icon": "mdi:account"}, + ] + + nav_links = [] + + for item in nav_items: + # Die ID muss in den Callbacks exakt so referenziert werden: + link_id = {"type": "nav-item", "index": item["label"]} + + # ✅ NavLink erstellen + nav_link = dbc.NavLink( + [ + DashIconify(icon=item["icon"], width=24), + html.Span(item["label"], className="ms-2 sidebar-label"), + ], + href=item["href"], + active="exact", + className="sidebar-item", + id=link_id, + ) + + # ✅ Conditional List Construction - keine append() nötig + if collapsed: + tooltip = dbc.Tooltip( + item["label"], + target=link_id, + placement="right", + id=f"tooltip-{item['label']}", + ) + link_components = [nav_link, tooltip] + else: + link_components = [nav_link] + + # ✅ Alle Komponenten in einem div-Container + nav_links.append( + html.Div( + children=link_components, + className="nav-item-container" + ) + ) + + # Gib nur den Inhalt zurück, nicht das äußere div + return [ + html.Div( + className="sidebar-toggle", + children=html.Button( + DashIconify(icon="mdi:menu", width=28), + id="btn-toggle-sidebar", + className="toggle-button", + ) + ), + dbc.Nav(nav_links, vertical=True, pills=True, className="sidebar-nav") + ] \ No newline at end of file diff --git a/dashboard/config.py b/dashboard/config.py new file mode 100644 index 0000000..ed723a9 --- /dev/null +++ b/dashboard/config.py @@ -0,0 +1,28 @@ +# dashboard/config.py +import os +from dotenv import load_dotenv + +# .env aus Root‐Verzeichnis laden +base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +load_dotenv(os.path.join(base_dir, ".env")) + +# DB‐Einstellungen +DB_HOST = os.getenv("DB_HOST") +DB_PORT = int(os.getenv("DB_PORT", "3306")) +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_NAME = os.getenv("DB_NAME") +DB_POOL_NAME = os.getenv("DB_POOL_NAME", "my_pool") +DB_POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5")) + +# MQTT‐Einstellungen +MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST") +MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883")) +MQTT_USERNAME = os.getenv("MQTT_USERNAME") +MQTT_PASSWORD = os.getenv("MQTT_PASSWORD") +MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60")) +MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID") + +# Sonstige Einstellungen +SECRET_KEY = os.getenv("SECRET_KEY", "changeme") +ENV = os.getenv("ENV", "development") diff --git a/dashboard/fullcalendar_test.py b/dashboard/fullcalendar_test.py new file mode 100644 index 0000000..2dfe1b2 --- /dev/null +++ b/dashboard/fullcalendar_test.py @@ -0,0 +1,402 @@ +import dash +import full_calendar_component as fcc +from dash import * +import dash_mantine_components as dmc +from dash.exceptions import PreventUpdate +from datetime import datetime, date, timedelta +import dash_quill + +# dash._dash_renderer._set_react_version('18.2.0') + +app = Dash(__name__, prevent_initial_callbacks=True) + +quill_mods = [ + [{"header": "1"}, {"header": "2"}, {"font": []}], + [{"size": []}], + ["bold", "italic", "underline", "strike", "blockquote"], + [{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}], + ["link", "image"], +] + +# Get today's date +today = datetime.now() + +# Format the date +formatted_date = today.strftime("%Y-%m-%d") + +app.layout = html.Div( + [ + fcc.FullCalendarComponent( + id="calendar", # Unique ID for the component + initialView="listWeek", # dayGridMonth, timeGridWeek, timeGridDay, listWeek, + # dayGridWeek, dayGridYear, multiMonthYear, resourceTimeline, resourceTimeGridDay, resourceTimeLineWeek + headerToolbar={ + "left": "prev,next today", + "center": "", + "right": "listWeek,timeGridDay,timeGridWeek,dayGridMonth", + }, # Calendar header + initialDate=f"{formatted_date}", # Start date for calendar + editable=True, # Allow events to be edited + selectable=True, # Allow dates to be selected + events=[], + nowIndicator=True, # Show current time indicator + navLinks=True, # Allow navigation to other dates + ), + dmc.MantineProvider( + theme={"colorScheme": "dark"}, + children=[ + dmc.Modal( + id="modal", + size="xl", + title="Event Details", + zIndex=10000, + children=[ + html.Div(id="modal_event_display_context"), + dmc.Space(h=20), + dmc.Group( + [ + dmc.Button( + "Close", + color="red", + variant="outline", + id="modal-close-button", + ), + ], + pos="right", + ), + ], + ) + ], + ), + dmc.MantineProvider( + theme={"colorScheme": "dark"}, + children=[ + dmc.Modal( + id="add_modal", + title="New Event", + size="xl", + children=[ + dmc.Grid( + children=[ + dmc.GridCol( + html.Div( + dmc.DatePickerInput( + id="start_date", + label="Start Date", + value=datetime.now().date(), + styles={"width": "100%"}, + disabled=True, + ), + style={"width": "100%"}, + ), + span=6, + ), + dmc.GridCol( + html.Div( + dmc.TimeInput( + label="Start Time", + withSeconds=True, + value=datetime.now(), + # format="12", + id="start_time", + ), + style={"width": "100%"}, + ), + span=6, + ), + ], + gutter="xl", + ), + dmc.Grid( + children=[ + dmc.GridCol( + html.Div( + dmc.DatePickerInput( + id="end_date", + label="End Date", + value=datetime.now().date(), + styles={"width": "100%"}, + ), + style={"width": "100%"}, + ), + span=6, + ), + dmc.GridCol( + html.Div( + dmc.TimeInput( + label="End Time", + withSeconds=True, + value=datetime.now(), + # format="12", + id="end_time", + ), + style={"width": "100%"}, + ), + span=6, + ), + ], + gutter="xl", + ), + dmc.Grid( + children=[ + dmc.GridCol( + span=6, + children=[ + dmc.TextInput( + label="Event Title:", + style={"width": "100%"}, + id="event_name_input", + required=True, + ) + ], + ), + dmc.GridCol( + span=6, + children=[ + dmc.Select( + label="Select event color", + placeholder="Select one", + id="event_color_select", + value="ng", + data=[ + { + "value": "bg-gradient-primary", + "label": "bg-gradient-primary", + }, + { + "value": "bg-gradient-secondary", + "label": "bg-gradient-secondary", + }, + { + "value": "bg-gradient-success", + "label": "bg-gradient-success", + }, + { + "value": "bg-gradient-info", + "label": "bg-gradient-info", + }, + { + "value": "bg-gradient-warning", + "label": "bg-gradient-warning", + }, + { + "value": "bg-gradient-danger", + "label": "bg-gradient-danger", + }, + { + "value": "bg-gradient-light", + "label": "bg-gradient-light", + }, + { + "value": "bg-gradient-dark", + "label": "bg-gradient-dark", + }, + { + "value": "bg-gradient-white", + "label": "bg-gradient-white", + }, + ], + style={"width": "100%", "marginBottom": 10}, + required=True, + ) + ], + ), + ] + ), + dash_quill.Quill( + id="rich_text_input", + modules={ + "toolbar": quill_mods, + "clipboard": { + "matchVisual": False, + }, + }, + ), + dmc.Accordion( + children=[ + dmc.AccordionItem( + [ + dmc.AccordionControl("Raw HTML"), + dmc.AccordionPanel( + html.Div( + id="rich_text_output", + style={ + "height": "300px", + "overflowY": "scroll", + }, + ) + ), + ], + value="raw_html", + ), + ], + ), + dmc.Space(h=20), + dmc.Group( + [ + dmc.Button( + "Submit", + id="modal_submit_new_event_button", + color="green", + ), + dmc.Button( + "Close", + color="red", + variant="outline", + id="modal_close_new_event_button", + ), + ], + pos="right", + ), + ], + ), + ], + ), + ] +) + + +@app.callback( + Output("modal", "opened"), + Output("modal", "title"), + Output("modal_event_display_context", "children"), + Input("modal-close-button", "n_clicks"), + Input("calendar", "clickedEvent"), + State("modal", "opened"), +) +def open_event_modal(n, clickedEvent, opened): + ctx = callback_context + + if not ctx.triggered: + raise PreventUpdate + else: + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + + if button_id == "calendar" and clickedEvent is not None: + event_title = clickedEvent["title"] + event_context = clickedEvent["extendedProps"]["context"] + return ( + True, + event_title, + html.Div( + dash_quill.Quill( + id="input3", + value=f"{event_context}", + modules={ + "toolbar": False, + "clipboard": { + "matchVisual": False, + }, + }, + ), + style={"width": "100%", "overflowY": "auto"}, + ), + ) + elif button_id == "modal-close-button" and n is not None: + return False, dash.no_update, dash.no_update + + return opened, dash.no_update + + +@app.callback( + Output("add_modal", "opened"), + Output("start_date", "value"), + Output("end_date", "value"), + Output("start_time", "value"), + Output("end_time", "value"), + Input("calendar", "dateClicked"), + Input("modal_close_new_event_button", "n_clicks"), + State("add_modal", "opened"), +) +def open_add_modal(dateClicked, close_clicks, opened): + + ctx = callback_context + + if not ctx.triggered: + raise PreventUpdate + else: + button_id = ctx.triggered[0]["prop_id"].split(".")[0] + + if button_id == "calendar" and dateClicked is not None: + try: + start_time = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z").time() + start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%dT%H:%M:%S%z") + start_date = start_date_obj.strftime("%Y-%m-%d") + end_date = start_date_obj.strftime("%Y-%m-%d") + except ValueError: + start_time = datetime.now().time() + start_date_obj = datetime.strptime(dateClicked, "%Y-%m-%d") + start_date = start_date_obj.strftime("%Y-%m-%d") + end_date = start_date_obj.strftime("%Y-%m-%d") + end_time = datetime.combine(date.today(), start_time) + timedelta(hours=1) + start_time_str = start_time.strftime("%Y-%m-%d %H:%M:%S") + end_time_str = end_time.strftime("%Y-%m-%d %H:%M:%S") + return True, start_date, end_date, start_time_str, end_time_str + + elif button_id == "modal_close_new_event_button" and close_clicks is not None: + return False, dash.no_update, dash.no_update, dash.no_update, dash.no_update + + return opened, dash.no_update, dash.no_update, dash.no_update, dash.no_update + + +@app.callback( + Output("calendar", "events"), + Output("add_modal", "opened", allow_duplicate=True), + Output("event_name_input", "value"), + Output("event_color_select", "value"), + Output("rich_text_input", "value"), + Input("modal_submit_new_event_button", "n_clicks"), + State("start_date", "value"), + State("start_time", "value"), + State("end_date", "value"), + State("end_time", "value"), + State("event_name_input", "value"), + State("event_color_select", "value"), + State("rich_text_output", "children"), + State("calendar", "events"), +) +def add_new_event( + n, + start_date, + start_time, + end_date, + end_time, + event_name, + event_color, + event_context, + current_events, +): + if n is None: + raise PreventUpdate + + start_time_obj = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S") + end_time_obj = datetime.strptime(end_time, "%Y-%m-%d %H:%M:%S") + + start_time_str = start_time_obj.strftime("%H:%M:%S") + end_time_str = end_time_obj.strftime("%H:%M:%S") + + start_date = f"{start_date}T{start_time_str}" + end_date = f"{end_date}T{end_time_str}" + + new_event = { + "title": event_name, + "start": start_date, + "end": end_date, + "className": event_color, + "context": event_context, + } + + return current_events + [new_event], False, "", "bg-gradient-primary", "" + + +@app.callback( + Output("rich_text_output", "children"), + [Input("rich_text_input", "value")], + [State("rich_text_input", "charCount")], +) +def display_output(value, charCount): + return value + + +if __name__ == "__main__": + app.run(host='0.0.0.0', debug=True, port=8050) \ No newline at end of file diff --git a/dashboard/infoscreen_server.py b/dashboard/infoscreen_server.py new file mode 100644 index 0000000..dbfc21e --- /dev/null +++ b/dashboard/infoscreen_server.py @@ -0,0 +1,83 @@ +""" +Collapsible navbar on both desktop and mobile +""" + + +import dash_mantine_components as dmc +from dash import Dash, Input, Output, State, callback +from dash_iconify import DashIconify + +app = Dash(external_stylesheets=dmc.styles.ALL) + +logo = "https://github.com/user-attachments/assets/c1ff143b-4365-4fd1-880f-3e97aab5c302" + +def get_icon(icon): + return DashIconify(icon=icon, height=16) + +layout = dmc.AppShell( + [ + dmc.AppShellHeader( + dmc.Group( + [ + dmc.Burger( + id="mobile-burger", + size="sm", + hiddenFrom="sm", + opened=False, + ), + dmc.Burger( + id="desktop-burger", + size="sm", + visibleFrom="sm", + opened=True, + ), + dmc.Image(src=logo, h=40), + dmc.Title("Demo App", c="blue"), + ], + h="100%", + px="md", + ) + ), + dmc.AppShellNavbar( + id="navbar", + children=[ + "Navbar", + dmc.NavLink( + label="With icon", + leftSection=get_icon(icon="bi:house-door-fill"), + ), + ], + p="md", + ), + dmc.AppShellMain("Main"), + ], + header={"height": 60}, + navbar={ + "width": 300, + "breakpoint": "sm", + "collapsed": {"mobile": True, "desktop": False}, + }, + padding="md", + id="appshell", +) + +app.layout = dmc.MantineProvider(layout) + + +@callback( + Output("appshell", "navbar"), + Input("mobile-burger", "opened"), + Input("desktop-burger", "opened"), + State("appshell", "navbar"), +) +def toggle_navbar(mobile_opened, desktop_opened, navbar): + navbar["collapsed"] = { + "mobile": not mobile_opened, + "desktop": not desktop_opened, + } + return navbar + + + +if __name__ == "__main__": + app.run(host='0.0.0.0', debug=True, port=8050) diff --git a/dashboard/pages/appointments.py b/dashboard/pages/appointments.py new file mode 100644 index 0000000..3983191 --- /dev/null +++ b/dashboard/pages/appointments.py @@ -0,0 +1,16 @@ +# dashboard/pages/appointments.py +from dash import html +import dash + +dash.register_page(__name__, path="/appointments", name="Termine") + +layout = html.Div( + className="appointments-page", + children=[ + html.H3("Terminverwaltung"), + html.Div(id="calendar-container"), + html.Button("Neuen Termin anlegen", id="btn-add-appointment"), + html.Div(id="appointments-feedback"), + # Hier später das Modal oder das FullCalendar‐Element einfügen + ] +) diff --git a/dashboard/pages/login.py b/dashboard/pages/login.py new file mode 100644 index 0000000..8e97912 --- /dev/null +++ b/dashboard/pages/login.py @@ -0,0 +1,16 @@ +# dashboard/pages/login.py +from dash import html, dcc +import dash + +dash.register_page(__name__, path="/login", name="Login") + +layout = html.Div( + className="login-page", + children=[ + html.H2("Bitte einloggen"), + dcc.Input(id="input-user", type="text", placeholder="Benutzername"), + dcc.Input(id="input-pass", type="password", placeholder="Passwort"), + html.Button("Einloggen", id="btn-login"), + html.Div(id="login-feedback", className="text-danger") + ] +) diff --git a/dashboard/pages/overview.py b/dashboard/pages/overview.py new file mode 100644 index 0000000..7e2933d --- /dev/null +++ b/dashboard/pages/overview.py @@ -0,0 +1,13 @@ +# dashboard/pages/overview.py +from dash import html, dcc +import dash + +dash.register_page(__name__, path="/overview", name="Übersicht") + +layout = html.Div( + className="overview-page", + children=[ + dcc.Interval(id="interval-update", interval=10_000, n_intervals=0), + html.Div(id="clients-cards-container") + ] +) diff --git a/dashboard/pages/settings.py b/dashboard/pages/settings.py new file mode 100644 index 0000000..045823c --- /dev/null +++ b/dashboard/pages/settings.py @@ -0,0 +1,13 @@ +# dashboard/pages/settings.py +from dash import html +import dash + +dash.register_page(__name__, path="/settings", name="Einstellungen") + +layout = html.Div( + className="settings-page", + children=[ + html.H3("Allgemeine Einstellungen"), + # Formularfelder / Tabs für globale Optionen + ] +) diff --git a/dashboard/pages/users.py b/dashboard/pages/users.py new file mode 100644 index 0000000..a1718b2 --- /dev/null +++ b/dashboard/pages/users.py @@ -0,0 +1,15 @@ +# dashboard/pages/users.py +from dash import html, dash_table, dcc +import dash + +dash.register_page(__name__, path="/users", name="Benutzer") + +layout = html.Div( + className="users-page", + children=[ + html.H3("Benutzerverwaltung"), + html.Button("Neuen Benutzer anlegen", id="btn-new-user"), + html.Div(id="users-table-container"), + html.Div(id="users-feedback") + ] +) diff --git a/dashboard/requirements.txt b/dashboard/requirements.txt new file mode 100644 index 0000000..97f2c9c --- /dev/null +++ b/dashboard/requirements.txt @@ -0,0 +1,10 @@ +bcrypt>=4.3.0 +dash>=3.0.4 +dash-bootstrap-components>=2.0.3 +dash_iconify>=0.1.2 +dash_mantine_components>=1.2.0 +dash-quill>=0.0.4 +full-calendar-component>=0.0.4 +pandas>=2.2.3 +paho-mqtt>=2.1.0 +python-dotenv>=1.1.0 diff --git a/dashboard/utils/__init__.py b/dashboard/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/utils/auth.py b/dashboard/utils/auth.py new file mode 100644 index 0000000..0583a06 --- /dev/null +++ b/dashboard/utils/auth.py @@ -0,0 +1,12 @@ +# dashboard/utils/auth.py +import bcrypt + +def hash_password(plain_text: str) -> str: + return bcrypt.hashpw(plain_text.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + +def check_password(plain_text: str, hashed: str) -> bool: + return bcrypt.checkpw(plain_text.encode("utf-8"), hashed.encode("utf-8")) + +def get_user_role(username: str) -> str: + # Beispiel: aus der Datenbank auslesen (oder Hardcode während Dev-Phase) + pass diff --git a/dashboard/utils/db.py b/dashboard/utils/db.py new file mode 100644 index 0000000..834422b --- /dev/null +++ b/dashboard/utils/db.py @@ -0,0 +1,46 @@ +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +# .env laden +load_dotenv() + +# Datenbank-Zugangsdaten aus .env +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "3306") +DB_NAME = os.getenv("DB_NAME") + +# Pooling Parameter aus .env (optional mit Default-Werten) +POOL_SIZE = int(os.getenv("POOL_SIZE", 10)) +MAX_OVERFLOW = int(os.getenv("MAX_OVERFLOW", 20)) +POOL_TIMEOUT = int(os.getenv("POOL_TIMEOUT", 30)) +POOL_RECYCLE = int(os.getenv("POOL_RECYCLE", 1800)) + +# Connection-String zusammenbauen +DATABASE_URL = ( + f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" +) + +# Engine mit Pooling konfigurieren +engine = create_engine( + DATABASE_URL, + pool_size=POOL_SIZE, + max_overflow=MAX_OVERFLOW, + pool_timeout=POOL_TIMEOUT, + pool_recycle=POOL_RECYCLE, + echo=True, # für Debug, später False +) + +# Session Factory +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +def get_session(): + return SessionLocal() + +def execute_query(query): + with engine.connect() as connection: + result = connection.execute(text(query)) + return [dict(row) for row in result] diff --git a/dashboard/utils/mqtt_client.py b/dashboard/utils/mqtt_client.py new file mode 100644 index 0000000..e8e1e27 --- /dev/null +++ b/dashboard/utils/mqtt_client.py @@ -0,0 +1,121 @@ +# dashboard/utils/mqtt_client.py + +import os +import threading +import time +from dotenv import load_dotenv +import paho.mqtt.client as mqtt + +# 1. Laden der Umgebungsvariablen aus .env +load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), "..", ".env")) + +# 2. Lese MQTT‐Einstellungen +MQTT_BROKER_HOST = os.getenv("MQTT_BROKER_HOST", "localhost") +MQTT_BROKER_PORT = int(os.getenv("MQTT_BROKER_PORT", "1883")) +MQTT_USERNAME = os.getenv("MQTT_USERNAME", None) +MQTT_PASSWORD = os.getenv("MQTT_PASSWORD", None) +MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE", "60")) +MQTT_CLIENT_ID = os.getenv("MQTT_CLIENT_ID", f"dash-{int(time.time())}") + +# 3. Erstelle eine globale Client‐Instanz +client = mqtt.Client(client_id=MQTT_CLIENT_ID) + +# Falls Nutzer/Passwort gesetzt sind, authentifizieren +if MQTT_USERNAME and MQTT_PASSWORD: + client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) + + +# 4. Callback‐Stubs (kannst du bei Bedarf anpassen) +def _on_connect(client, userdata, flags, rc): + if rc == 0: + print(f"[mqtt_client.py] Erfolgreich mit MQTT‐Broker verbunden (Code {rc})") + else: + print(f"[mqtt_client.py] Verbindungsfehler, rc={rc}") + + +def _on_disconnect(client, userdata, rc): + print(f"[mqtt_client.py] Verbindung getrennt (rc={rc}). Versuche, neu zu verbinden …") + + +def _on_message(client, userdata, msg): + """ + Diese Callback‐Funktion wird aufgerufen, sobald eine Nachricht auf einem + Topic ankommt, auf das wir subscribed haben. Du kannst hier eine Queue + füllen oder direkt eine Datenbank‐Funktion aufrufen. + """ + topic = msg.topic + payload = msg.payload.decode("utf-8", errors="ignore") + print(f"[mqtt_client.py] Nachricht eingegangen – Topic: {topic}, Payload: {payload}") + # Beispiel: Wenn du Live‐Statusdaten in die Datenbank schreibst, + # könntest du hier utils/db.execute_non_query(...) aufrufen. + + +# 5. Setze die Callbacks +client.on_connect = _on_connect +client.on_disconnect = _on_disconnect +client.on_message = _on_message + + +def start_loop(): + """ + Startet die Endlos‐Schleife, in der der Client auf eingehende + MQTT‐Nachrichten hört und automatisch reconnectet. + Muss idealerweise in einem eigenen Thread laufen, damit Dash‐Callbacks + nicht blockieren. + """ + try: + client.connect(MQTT_BROKER_HOST, MQTT_BROKER_PORT, keepalive=MQTT_KEEPALIVE) + client.loop_start() + except Exception as e: + print(f"[mqtt_client.py] Konnte keine Verbindung zum MQTT‐Broker herstellen: {e}") + + +def stop_loop(): + """ + Stoppt die MQTT‐Loop und trennt die Verbindung. + """ + try: + client.loop_stop() + client.disconnect() + except Exception as e: + print(f"[mqtt_client.py] Fehler beim Stoppen der MQTT‐Schleife: {e}") + + +def publish(topic: str, payload: str, qos: int = 0, retain: bool = False) -> bool: + """ + Verschickt eine MQTT‐Nachricht: + - topic: z. B. "clients/{client_id}/control" + - payload: z. B. '{"command":"restart"}' + - qos: 0, 1 oder 2 + - retain: True/False + Rückgabe: True, falls Veröffentlichung bestätigt wurde; sonst False. + """ + try: + result = client.publish(topic, payload, qos=qos, retain=retain) + status = result.rc # 0=Erfolg, sonst Fehler + if status == mqtt.MQTT_ERR_SUCCESS: + return True + else: + print(f"[mqtt_client.py] Publish-Fehler für Topic {topic}, rc={status}") + return False + except Exception as e: + print(f"[mqtt_client.py] Exception beim Publish: {e}") + return False + + +def subscribe(topic: str, qos: int = 0) -> bool: + """ + Abonniert ein MQTT‐Topic, sodass _on_message gerufen wird, sobald Nachrichten + ankommen. + Rückgabe: True bei Erfolg, ansonsten False. + """ + try: + result, mid = client.subscribe(topic, qos=qos) + if result == mqtt.MQTT_ERR_SUCCESS: + return True + else: + print(f"[mqtt_client.py] Subscribe‐Fehler für Topic {topic}, rc={result}") + return False + except Exception as e: + print(f"[mqtt_client.py] Exception beim Subscribe: {e}") + return False diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e37525d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,111 @@ +networks: + infoscreen-net: + driver: bridge + +services: + db: + image: mariadb:11.4.7 + container_name: infoscreen-db + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MYSQL_DATABASE: ${DB_NAME} + MYSQL_USER: ${DB_USER} + MYSQL_PASSWORD: ${DB_PASSWORD} + volumes: + - db-data:/var/lib/mysql + ports: + - "3306:3306" + networks: + - infoscreen-net + # ✅ HINZUGEFÜGT: Healthcheck für MariaDB + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + + mqtt: + image: eclipse-mosquitto:2.0.21 + container_name: infoscreen-mqtt + restart: unless-stopped + volumes: + - ./mosquitto/config:/mosquitto/config + - ./mosquitto/data:/mosquitto/data + - ./mosquitto/log:/mosquitto/log + # ✅ HINZUGEFÜGT: MQTT-Ports explizit exponiert + ports: + - "1883:1883" # Standard MQTT + - "9001:9001" # WebSocket (falls benötigt) + networks: + - infoscreen-net + # ✅ HINZUGEFÜGT: Healthcheck für MQTT + healthcheck: + test: ["CMD-SHELL", "mosquitto_pub -h localhost -t test -m 'health' || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + server: + build: + context: ./server + dockerfile: Dockerfile + image: infoscreen-api:latest + container_name: infoscreen-api + restart: unless-stopped + depends_on: + # ✅ GEÄNDERT: Erweiterte depends_on mit Healthcheck-Conditions + db: + condition: service_healthy + mqtt: + condition: service_healthy + environment: + DB_CONN: "mysql+pymysql://${DB_USER}:${DB_PASSWORD}@db/${DB_NAME}" + FLASK_ENV: ${FLASK_ENV} + ENV_FILE: ${ENV_FILE} + MQTT_BROKER_URL: ${MQTT_BROKER_URL} + MQTT_USER: ${MQTT_USER} + MQTT_PASSWORD: ${MQTT_PASSWORD} + ports: + - "8000:8000" + networks: + - infoscreen-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + + dashboard: + build: + context: ./dashboard + dockerfile: Dockerfile + image: infoscreen-dashboard:latest + container_name: infoscreen-dashboard + restart: unless-stopped + depends_on: + # ✅ GEÄNDERT: Healthcheck-Condition für Server + server: + condition: service_healthy + environment: + API_URL: ${API_URL} + ports: + - "8050:8050" + networks: + - infoscreen-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8050/_alive"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 45s + +volumes: + db-data: +# mosquitto-conf: +# mosquitto-data: +# # ✅ HINZUGEFÜGT: Log-Volume für MQTT +# mosquitto-logs: \ No newline at end of file diff --git a/helpers/__init__.py b/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/helpers/check_folder.py b/helpers/check_folder.py new file mode 100644 index 0000000..4557d5a --- /dev/null +++ b/helpers/check_folder.py @@ -0,0 +1,56 @@ +import os +from pathlib import Path + +def ensure_folder_exists(folder_path): + """ + Check if a folder exists and create it if it doesn't. + + Args: + folder_path (str or Path): Path to the folder to check/create + + Returns: + bool: True if folder was created, False if it already existed + + Raises: + OSError: If folder creation fails due to permissions or other issues + """ + folder_path = Path(folder_path) + + if folder_path.exists(): + if folder_path.is_dir(): + return False # Folder already exists + else: + raise OSError(f"Path '{folder_path}' exists but is not a directory") + + try: + folder_path.mkdir(parents=True, exist_ok=True) + return True # Folder was created + except OSError as e: + raise OSError(f"Failed to create folder '{folder_path}': {e}") + +# Alternative simpler version using os module +def ensure_folder_exists_simple(folder_path): + """ + Simple version using os.makedirs with exist_ok parameter. + + Args: + folder_path (str): Path to the folder to check/create + """ + os.makedirs(folder_path, exist_ok=True) + +# Usage examples +if __name__ == "__main__": + # Example 1: Create a single folder + folder_created = ensure_folder_exists("my_new_folder") + print(f"Folder created: {folder_created}") + + # Example 2: Create nested folders + ensure_folder_exists("data/processed/results") + + # Example 3: Using the simple version + ensure_folder_exists_simple("logs/2024") + + # Example 4: Using with absolute path + import tempfile + temp_dir = tempfile.gettempdir() + ensure_folder_exists(os.path.join(temp_dir, "my_app", "cache")) \ No newline at end of file diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..a62ef94 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,42 @@ +# server/Dockerfile +# Produktions-Dockerfile für die Flask-API mit Gunicorn + +# --- Basisimage --- +FROM python:3.13-slim + +# --- Arbeitsverzeichnis --- +WORKDIR /app + +# --- Systemabhängigkeiten für MariaDB-Client & Locale --- +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libmariadb-dev-compat libmariadb-dev locales \ + && rm -rf /var/lib/apt/lists/* + +# --- Locale konfigurieren --- +RUN sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen +ENV LANG=de_DE.UTF-8 \ + LC_ALL=de_DE.UTF-8 + +# --- Python-Abhängigkeiten --- +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# --- Applikationscode --- +COPY server/ /app + +# --- Non-Root User anlegen und Rechte setzen --- +ARG USER_ID=1000 +ARG GROUP_ID=1000 +RUN groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} \ + --shell /bin/bash --create-home infoscreen_taa \ + && chown -R infoscreen_taa:infoscreen_taa /app +USER infoscreen_taa + +# --- Port für die API exposed --- +EXPOSE 8000 + +# --- Startbefehl für Gunicorn --- +CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "wsgi:app"] diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev new file mode 100644 index 0000000..b13c4d2 --- /dev/null +++ b/server/Dockerfile.dev @@ -0,0 +1,46 @@ +# Datei: server/Dockerfile.dev +# Entwicklungs-Dockerfile für die API (Flask + SQLAlchemy) + +FROM python:3.13-slim + +# Build args für UID/GID +ARG USER_ID=1000 +ARG GROUP_ID=1000 + +# Erstelle non-root User +RUN apt-get update \ + && apt-get install -y --no-install-recommends locales curl \ + && groupadd -g ${GROUP_ID} infoscreen_taa \ + && useradd -u ${USER_ID} -g ${GROUP_ID} --shell /bin/bash --create-home infoscreen_taa \ + && sed -i 's/# de_DE.UTF-8 UTF-8/de_DE.UTF-8 UTF-8/' /etc/locale.gen \ + && locale-gen \ + && rm -rf /var/lib/apt/lists/* + +ENV LANG=de_DE.UTF-8 \ + LANGUAGE=de_DE:de \ + LC_ALL=de_DE.UTF-8 + +# Arbeitsverzeichnis +WORKDIR /app + +# Kopiere nur Requirements für schnellen Rebuild +COPY requirements.txt ./ +COPY requirements-dev.txt ./ + +# Installiere Python-Abhängigkeiten (Prod + Dev) +RUN pip install --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt \ + && pip install --no-cache-dir -r requirements-dev.txt + +# Expose Ports für Flask API +EXPOSE 8000 + +# Setze Env für Dev +ENV FLASK_ENV=development +ENV ENV_FILE=.env + +# Wechsle zum non-root User +USER infoscreen_taa + +# Default Command: Flask Development Server +CMD ["flask", "run", "--host=0.0.0.0", "--port=8000"] diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/init_database.py b/server/init_database.py new file mode 100644 index 0000000..b63f398 --- /dev/null +++ b/server/init_database.py @@ -0,0 +1,67 @@ +from sqlalchemy import create_engine, Column, Integer, String, Enum, TIMESTAMP, func, text +from sqlalchemy.orm import sessionmaker, declarative_base +import enum +import bcrypt + +# Basis-Klasse für unsere Modelle +Base = declarative_base() + +# Enum zur Definition der möglichen Rollen +class UserRole(enum.Enum): + user = "user" + admin = "admin" + superadmin = "superadmin" + +# Definition des User Models (Tabelle) +class User(Base): + __tablename__ = 'users' + + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(60), nullable=False) # bcrypt erzeugt einen 60-Zeichen-Hash + role = Column(Enum(UserRole), nullable=False, default=UserRole.user) + created_at = Column(TIMESTAMP, server_default=func.current_timestamp()) + updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) + +# Definition des Client Models (Tabelle) +class Client(Base): + __tablename__ = 'clients' + + # Die UUID wird vom Client erzeugt und muss daher übermittelt werden (kein Default) + uuid = Column(String(36), primary_key=True, nullable=False) + # Der Hardware-Hash wird ebenfalls direkt vom Client geliefert + hardware_hash = Column(String(64), nullable=False) + # Spalte für den Standort, der vom Benutzer bei der Anmeldung eingegeben wird + location = Column(String(100), nullable=True) + # Spalte für die IP-Adresse, die vom Server beim Kontakt ermittelt wird + ip_address = Column(String(45), nullable=True) + # Speicherung des Registrierungszeitpunkts, wird automatisch gesetzt + registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False) + # Speicherung des Zeitpunkts der letzten Rückmeldung (Alive-Signal) + last_alive = Column( + TIMESTAMP, + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + nullable=False + ) + +def main(): + # Zuerst Verbindung zur MariaDB ohne spezifische Datenbank, um die Datenbank anzulegen. + admin_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa' + admin_engine = create_engine(admin_connection_str, echo=True) + + # Datenbank "infoscreen_by_taa" anlegen, falls sie noch nicht existiert. + with admin_engine.connect() as conn: + conn.execute(text("CREATE DATABASE IF NOT EXISTS infoscreen_by_taa CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")) + + # Verbindung zur spezifischen Datenbank herstellen. + db_connection_str = 'mysql+pymysql://infoscreen_admin:KqtpM7wmNd$M1Da&mFKs@infoscreen-db/infoscreen_by_taa' + engine = create_engine(db_connection_str, echo=True) + + # Erstelle die Tabellen 'users' und 'clients' + Base.metadata.create_all(engine) + + print("Die Tabellen 'users' und 'clients' wurden in der Datenbank 'infoscreen_by_taa' erstellt.") + +if __name__ == '__main__': + main() diff --git a/server/init_db.py b/server/init_db.py new file mode 100644 index 0000000..5141d06 --- /dev/null +++ b/server/init_db.py @@ -0,0 +1,57 @@ +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from models import Base, User, UserRole +import os +from dotenv import load_dotenv +import bcrypt + +# .env laden +load_dotenv() + +# Konfiguration aus .env +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_NAME = os.getenv("DB_NAME") + +DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME") +DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD") + +# Erst ohne DB verbinden, um sie ggf. zu erstellen +admin_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/" +admin_engine = create_engine(admin_conn_str, echo=True) + +with admin_engine.connect() as conn: + conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")) + +# Jetzt mit Datenbank verbinden +db_conn_str = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +engine = create_engine(db_conn_str, echo=True) + +# Tabellen anlegen +Base.metadata.create_all(engine) + +# Session erstellen +Session = sessionmaker(bind=engine) +session = Session() + +# Prüfen, ob der User bereits existiert +existing_user = session.query(User).filter_by(username=DEFAULT_ADMIN_USERNAME).first() + +if not existing_user: + # Passwort hashen + hashed_pw = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) + + # Neuen User anlegen + admin_user = User( + username=DEFAULT_ADMIN_USERNAME, + password_hash=hashed_pw.decode('utf-8'), + role=UserRole.admin + ) + session.add(admin_user) + session.commit() + print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' wurde erfolgreich angelegt.") +else: + print(f"Admin-User '{DEFAULT_ADMIN_USERNAME}' existiert bereits.") + +session.close() diff --git a/server/init_mariadb.py b/server/init_mariadb.py new file mode 100644 index 0000000..f741cfe --- /dev/null +++ b/server/init_mariadb.py @@ -0,0 +1,36 @@ +from sqlalchemy import create_engine, text +import os +from dotenv import load_dotenv + +# .env-Datei laden +load_dotenv() + +# Schritt 1: Verbindung zur MariaDB herstellen, OHNE eine bestimmte Datenbank +DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_ROOT_PASSWORD')}@{os.getenv('DB_HOST')}:3306" + +engine = create_engine(DATABASE_URL, isolation_level="AUTOCOMMIT", echo=True) + +db_name = os.getenv("DB_NAME") +db_user = os.getenv("DB_USER") +db_password = os.getenv("DB_PASSWORD") + +with engine.connect() as connection: + # Datenbank erstellen + connection.execute( + text(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + ) + + # Benutzer erstellen + connection.execute( + text(f"CREATE USER IF NOT EXISTS '{db_user}'@'%' IDENTIFIED BY '{db_password}'") + ) + + # Berechtigungen vergeben + connection.execute( + text(f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO '{db_user}'@'%'") + ) + + # Berechtigungen neu laden + connection.execute(text("FLUSH PRIVILEGES")) + +print("✅ Datenbank und Benutzer erfolgreich erstellt.") diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..87ab7f3 --- /dev/null +++ b/server/models.py @@ -0,0 +1,28 @@ +from sqlalchemy import Column, Integer, String, Enum, TIMESTAMP, func +from sqlalchemy.orm import declarative_base +import enum + +Base = declarative_base() + +class UserRole(enum.Enum): + user = "user" + admin = "admin" + superadmin = "superadmin" + +class User(Base): + __tablename__ = 'users' + id = Column(Integer, primary_key=True, autoincrement=True) + username = Column(String(50), unique=True, nullable=False) + password_hash = Column(String(60), nullable=False) + role = Column(Enum(UserRole), nullable=False, default=UserRole.user) + created_at = Column(TIMESTAMP, server_default=func.current_timestamp()) + updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp()) + +class Client(Base): + __tablename__ = 'clients' + uuid = Column(String(36), primary_key=True, nullable=False) + hardware_hash = Column(String(64), nullable=False) + location = Column(String(100), nullable=True) + ip_address = Column(String(45), nullable=True) + registration_time = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False) + last_alive = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), nullable=False) diff --git a/server/mqtt_multitopic_receiver.py b/server/mqtt_multitopic_receiver.py new file mode 100644 index 0000000..f3088d7 --- /dev/null +++ b/server/mqtt_multitopic_receiver.py @@ -0,0 +1,148 @@ +import os +import json +import base64 +import glob +from datetime import datetime, timezone +# import paho.mqtt.client as mqtt +from paho.mqtt import client as mqtt_client +import pytz +from sqlalchemy import create_engine, func +from sqlalchemy.orm import sessionmaker +from models import Client, Base +from helpers.check_folder import ensure_folder_exists + +# Konfiguration +MQTT_BROKER = os.getenv("MQTT_BROKER_HOST", "localhost") +MQTT_PORT = int(os.getenv("MQTT_BROKER_PORT", 1883)) +MQTT_USER = os.getenv("MQTT_USER") +MQTT_PASSWORD = os.getenv("MQTT_PASSWORD") +MQTT_KEEPALIVE = int(os.getenv("MQTT_KEEPALIVE")) +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_NAME = os.getenv("DB_NAME") + +topics = [ + ("infoscreen/screenshot", 0), + ("infoscreen/heartbeat", 0), + # ... weitere Topics hier +] +SAVE_DIR = "received_screenshots" +MAX_PER_CLIENT = 20 + +# Ordner für empfangene Screenshots anlegen +ensure_folder_exists(SAVE_DIR) + +# Datenbank konfigurieren (MariaDB) +# Ersetze user, password, host und datenbankname entsprechend. +DB_URL = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}/{DB_NAME}" +engine = create_engine(DB_URL, echo=False) +Session = sessionmaker(bind=engine) +# Falls Tabellen noch nicht existieren +Base.metadata.create_all(engine) + + +def prune_old_screenshots(client_id: str): + """Löscht alte Screenshots, wenn mehr als MAX_PER_CLIENT vorhanden sind.""" + pattern = os.path.join(SAVE_DIR, f"{client_id}_*.jpg") + files = sorted(glob.glob(pattern), key=os.path.getmtime) + while len(files) > MAX_PER_CLIENT: + oldest = files.pop(0) + try: + os.remove(oldest) + print(f"Altes Bild gelöscht: {oldest}") + except OSError as e: + print(f"Fehler beim Löschen von {oldest}: {e}") + + +def handle_screenshot(msg): + """Verarbeitet eingehende Screenshot-Payloads.""" + try: + payload = json.loads(msg.payload.decode("utf-8")) + client_id = payload.get("client_id", "unknown") + ts = datetime.fromtimestamp( + payload.get("timestamp", datetime.now().timestamp()) + ) + b64_str = payload["screenshot"] + img_data = base64.b64decode(b64_str) + + # Dateiname mit Client-ID und Zeitstempel + filename = ts.strftime(f"{client_id}_%Y%m%d_%H%M%S.jpg") + filepath = os.path.join(SAVE_DIR, filename) + + # Bild speichern + with open(filepath, "wb") as f: + f.write(img_data) + print(f"Bild gespeichert: {filepath}") + + # Alte Screenshots beschneiden + prune_old_screenshots(client_id) + + except Exception as e: + print("Fehler beim Verarbeiten der Screenshot-Nachricht:", e) + +def handle_heartbeat(msg): + """Verarbeitet Heartbeat und aktualisiert oder legt Clients an.""" + session = Session() + try: + payload = json.loads(msg.payload.decode("utf-8")) + uuid = payload.get("client_id") + hardware_hash = payload.get("hardware_hash") + ip_address = payload.get("ip_address") + # Versuche, Client zu finden + client = session.query(Client).filter_by(uuid=uuid).first() + if client: + # Bekannter Client: last_alive und IP aktualisieren + client.ip_address = ip_address + client.last_alive = func.now() + session.commit() + print(f"Heartbeat aktualisiert für Client {uuid}") + else: + # Neuer Client: Location per input abfragen + location = input(f"Neuer Client {uuid} gefunden. Bitte Standort eingeben: ") + # ip_address = msg._sock.getpeername()[0] + new_client = Client( + uuid=uuid, + hardware_hash=hardware_hash, + location=location, + ip_address=ip_address + ) + session.add(new_client) + session.commit() + print(f"Neuer Client {uuid} angelegt mit Standort {location}") + except Exception as e: + print("Fehler beim Verarbeiten der Heartbeat-Nachricht:", e) + session.rollback() + finally: + session.close() + + +# Mapping von Topics auf Handler-Funktionen +handlers = { + "infoscreen/screenshot": handle_screenshot, + "infoscreen/heartbeat": handle_heartbeat, + # ... weitere Zuordnungen hier +} + + +def on_connect(client, userdata, flags, rc, properties): + print("Verbunden mit Code:", rc) + client.subscribe(topics) + + +def on_message(client, userdata, msg): + topic = msg.topic + if topic in handlers: + handlers[topic](msg) + else: + print(f"Unbekanntes Topic '{topic}', keine Verarbeitung definiert.") + + +if __name__ == "__main__": + client = mqtt_client.Client(callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2) + client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG + client.on_connect = on_connect + client.on_message = on_message + + client.connect(MQTT_BROKER, MQTT_PORT, keepalive=MQTT_KEEPALIVE) + client.loop_forever() diff --git a/server/mqtt_receiver.py b/server/mqtt_receiver.py new file mode 100644 index 0000000..f07b818 --- /dev/null +++ b/server/mqtt_receiver.py @@ -0,0 +1,57 @@ +import os +import base64 +import json +from datetime import datetime +import paho.mqtt.client as mqtt + +# MQTT-Konfiguration +MQTT_BROKER = "mqtt_broker" +MQTT_PORT = 1883 +MQTT_USER = "infoscreen_taa_user" +MQTT_PASSWORD = "infoscreen_taa_MQTT25!" +TOPIC_SCREENSHOTS = "infoscreen/screenshot" +SAVE_DIR = "received_screenshots" +topics = [ + ("infoscreen/screenshot", 0), + ("infoscreen/heartbeat", 0), + # ... weitere Topics hier +] + +# Ordner für empfangene Screenshots anlegen +os.makedirs(SAVE_DIR, exist_ok=True) + +# Callback, wenn eine Nachricht eintrifft +def on_message(client, userdata, msg): + try: + payload = json.loads(msg.payload.decode('utf-8')) + b64_str = payload["screenshot"] + img_data = base64.b64decode(b64_str) + + # Dateiname mit Zeitstempel + ts = datetime.fromtimestamp(payload.get("timestamp", datetime.now().timestamp())) + filename = ts.strftime("screenshot_%Y%m%d_%H%M%S.jpg") + filepath = os.path.join(SAVE_DIR, filename) + + # Bild speichern + with open(filepath, "wb") as f: + f.write(img_data) + print(f"Bild gespeichert: {filepath}") + except Exception as e: + print("Fehler beim Verarbeiten der Nachricht:", e) + +# Callback bei erfolgreicher Verbindung +def on_connect(client, userdata, flags, rc, properties): + if rc == 0: + print("Mit MQTT-Server verbunden.") + client.subscribe(TOPIC_SCREENSHOTS, qos=1) + else: + print(f"Verbindung fehlgeschlagen (Code {rc})") + +if __name__ == "__main__": + client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) + client.username_pw_set(MQTT_USER, MQTT_PASSWORD) # <<<< AUTHENTIFIZIERUNG + client.on_connect = on_connect + client.on_message = on_message + + client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60) + client.loop_forever() diff --git a/server/nano.7258.save b/server/nano.7258.save new file mode 100644 index 0000000..9cc1e40 --- /dev/null +++ b/server/nano.7258.save @@ -0,0 +1,16 @@ +# server/wsgi.py + +from flask import Flask, jsonify + +app = Flask(__name__) + +@app.route("/health") +def health(): + return jsonify(status="ok") + +# Optional: Test-Route +@app.route("/") +def index(): + return "Hello from Infoscreen‐API!" + +# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher) diff --git a/server/requirements-dev.txt b/server/requirements-dev.txt new file mode 100644 index 0000000..a1cd17c --- /dev/null +++ b/server/requirements-dev.txt @@ -0,0 +1 @@ +python-dotenv>=1.1.0 \ No newline at end of file diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..578c4ef --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,6 @@ +bcrypt>=4.3.0 +paho-mqtt>=2.1.0 +PyMySQL>=1.1.1 +python-dotenv>=1.1.0 +SQLAlchemy>=2.0.41 +flask diff --git a/server/test_sql.py b/server/test_sql.py new file mode 100644 index 0000000..e6d7c66 --- /dev/null +++ b/server/test_sql.py @@ -0,0 +1,26 @@ +import os +from dotenv import load_dotenv +import pymysql + +load_dotenv() + +try: + connection = pymysql.connect( + host='localhost', + port=3306, + user=os.getenv('DB_USER'), + password=os.getenv('DB_PASSWORD'), + database=os.getenv('DB_NAME') + ) + print("✅ Verbindung erfolgreich!") + + with connection.cursor() as cursor: + cursor.execute("SELECT VERSION()") + version = cursor.fetchone() + print(f"MariaDB Version: {version[0]}") + +except Exception as e: + print(f"❌ Verbindungsfehler: {e}") +finally: + if 'connection' in locals(): + connection.close() \ No newline at end of file diff --git a/server/wsgi.py b/server/wsgi.py new file mode 100644 index 0000000..9cc1e40 --- /dev/null +++ b/server/wsgi.py @@ -0,0 +1,16 @@ +# server/wsgi.py + +from flask import Flask, jsonify + +app = Flask(__name__) + +@app.route("/health") +def health(): + return jsonify(status="ok") + +# Optional: Test-Route +@app.route("/") +def index(): + return "Hello from Infoscreen‐API!" + +# (Weitere Endpunkte, Blueprints, Datenbank-Initialisierung usw. kommen hierher)