feat: dashboard screenshot upload & retention (last 20 per client)

- Listener: subscribe to dashboard topic, forward screenshots to API
- API: store latest + last 20 timestamped screenshots per client, auto-delete older files
- Docs: updated README, TECH-CHANGELOG, and copilot-instructions for screenshot upload and retention policy
This commit is contained in:
RobbStarkAustria
2025-11-30 13:38:07 +00:00
parent df9f29bc6a
commit c193209326
7 changed files with 273 additions and 14 deletions

View File

@@ -2,29 +2,73 @@ import os
import json
import logging
import datetime
import base64
import requests
import paho.mqtt.client as mqtt
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models.models import Client
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s')
# Load .env in development
if os.getenv("ENV", "development") == "development":
from dotenv import load_dotenv
load_dotenv(".env")
try:
from dotenv import load_dotenv
load_dotenv(".env")
except Exception:
pass
# ENV-abhängige Konfiguration
# ENV-dependent configuration
ENV = os.getenv("ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO" if ENV == "production" else "DEBUG")
DB_URL = os.environ.get(
"DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
# Logging
logging.basicConfig(level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s')
DB_URL = os.environ.get("DB_CONN", "mysql+pymysql://user:password@db/infoscreen")
# DB-Konfiguration
engine = create_engine(DB_URL)
Session = sessionmaker(bind=engine)
# API configuration
API_BASE_URL = os.getenv("API_BASE_URL", "http://server:8000")
def handle_screenshot(uuid, payload):
"""
Handle screenshot data received via MQTT and forward to API.
Payload can be either raw binary image data or JSON with base64-encoded image.
"""
try:
# Try to parse as JSON first
try:
data = json.loads(payload.decode())
if "image" in data:
# Payload is JSON with base64 image
api_payload = {"image": data["image"]}
headers = {"Content-Type": "application/json"}
logging.debug(f"Forwarding base64 screenshot from {uuid} to API")
else:
logging.warning(f"Screenshot JSON from {uuid} missing 'image' field")
return
except (json.JSONDecodeError, UnicodeDecodeError):
# Payload is raw binary image data - encode to base64 for API
image_b64 = base64.b64encode(payload).decode('utf-8')
api_payload = {"image": image_b64}
headers = {"Content-Type": "application/json"}
logging.debug(f"Forwarding binary screenshot from {uuid} to API (encoded as base64)")
# Forward to API endpoint
api_url = f"{API_BASE_URL}/api/clients/{uuid}/screenshot"
response = requests.post(api_url, json=api_payload, headers=headers, timeout=10)
if response.status_code == 200:
logging.info(f"Screenshot von {uuid} erfolgreich an API weitergeleitet")
else:
logging.error(f"API returned status {response.status_code} for screenshot from {uuid}: {response.text}")
except requests.exceptions.RequestException as e:
logging.error(f"Failed to forward screenshot from {uuid} to API: {e}")
except Exception as e:
logging.error(f"Error handling screenshot from {uuid}: {e}")
def on_connect(client, userdata, flags, reasonCode, properties):
"""Callback for when client connects or reconnects (API v2)."""
@@ -32,7 +76,9 @@ def on_connect(client, userdata, flags, reasonCode, properties):
# Subscribe on every (re)connect so we don't miss heartbeats after broker restarts
client.subscribe("infoscreen/discovery")
client.subscribe("infoscreen/+/heartbeat")
logging.info(f"MQTT connected (reasonCode: {reasonCode}); (re)subscribed to discovery and heartbeats")
client.subscribe("infoscreen/+/screenshot")
client.subscribe("infoscreen/+/dashboard")
logging.info(f"MQTT connected (reasonCode: {reasonCode}); (re)subscribed to discovery, heartbeats, screenshots, and dashboards")
except Exception as e:
logging.error(f"Subscribe failed on connect: {e}")
@@ -42,6 +88,39 @@ def on_message(client, userdata, msg):
logging.debug(f"Empfangene Nachricht auf Topic: {topic}")
try:
# Dashboard-Handling (nested screenshot payload)
if topic.startswith("infoscreen/") and topic.endswith("/dashboard"):
uuid = topic.split("/")[1]
try:
payload_text = msg.payload.decode()
data = json.loads(payload_text)
shot = data.get("screenshot")
if isinstance(shot, dict):
# Prefer 'data' field (base64) inside screenshot object
image_b64 = shot.get("data")
if image_b64:
logging.debug(f"Dashboard enthält Screenshot für {uuid}; Weiterleitung an API")
# Build a lightweight JSON with image field for API handler
api_payload = json.dumps({"image": image_b64}).encode("utf-8")
handle_screenshot(uuid, api_payload)
# Update last_alive if status present
if data.get("status") == "alive":
session = Session()
client_obj = session.query(Client).filter_by(uuid=uuid).first()
if client_obj:
client_obj.last_alive = datetime.datetime.now(datetime.UTC)
session.commit()
session.close()
except Exception as e:
logging.error(f"Fehler beim Verarbeiten des Dashboard-Payloads von {uuid}: {e}")
return
# Screenshot-Handling
if topic.startswith("infoscreen/") and topic.endswith("/screenshot"):
uuid = topic.split("/")[1]
handle_screenshot(uuid, msg.payload)
return
# Heartbeat-Handling
if topic.startswith("infoscreen/") and topic.endswith("/heartbeat"):
uuid = topic.split("/")[1]

View File

@@ -2,3 +2,4 @@ paho-mqtt>=2.0
SQLAlchemy>=2.0
pymysql
python-dotenv
requests>=2.31.0