feat: server-side PPTX conversion + screenshot dashboard system

PPTX Conversion:
- Remove LibreOffice from client dependencies (server uses Gotenberg)
- Update docs to reflect clients only display pre-rendered PDFs
- Clarify server-side conversion workflow in README and copilot-instructions

Screenshot System:
- Add background screenshot capture in display_manager.py
- Implement Wayland/X11 session detection with tool fallback
- Add client-side image processing (downscale + JPEG compression)
- Create timestamped files + latest.jpg symlink for easy access
- Implement file rotation (max 20 screenshots by default)
- Enhance simclient.py to transmit via MQTT dashboard topic
- Add structured JSON payload with screenshot, timestamp, system info
- New env vars: SCREENSHOT_CAPTURE_INTERVAL, SCREENSHOT_MAX_WIDTH,
  SCREENSHOT_JPEG_QUALITY, SCREENSHOT_MAX_FILES, SCREENSHOT_ALWAYS

Architecture: Two-process design with shared screenshots/ volume
This commit is contained in:
RobbStarkAustria
2025-11-30 13:49:27 +01:00
parent 65d7b99198
commit d021e21544
4 changed files with 623 additions and 100 deletions

View File

@@ -41,6 +41,12 @@ for env_path in env_paths:
# Configuration
ENV = os.getenv("ENV", "development")
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG" if ENV == "development" else "INFO")
# Screenshot capture configuration (distinct from transmission interval used by simclient)
SCREENSHOT_CAPTURE_INTERVAL = int(os.getenv("SCREENSHOT_CAPTURE_INTERVAL", os.getenv("SCREENSHOT_INTERVAL", "30"))) # seconds
SCREENSHOT_MAX_WIDTH = int(os.getenv("SCREENSHOT_MAX_WIDTH", "800")) # Width to downscale (preserve aspect)
SCREENSHOT_JPEG_QUALITY = int(os.getenv("SCREENSHOT_JPEG_QUALITY", "70")) # JPEG quality 1-95
SCREENSHOT_MAX_FILES = int(os.getenv("SCREENSHOT_MAX_FILES", "20")) # Rotate old screenshots
SCREENSHOT_ALWAYS = os.getenv("SCREENSHOT_ALWAYS", "0").lower() in ("1","true","yes")
CHECK_INTERVAL = int(os.getenv("DISPLAY_CHECK_INTERVAL", "5")) # seconds
PRESENTATION_DIR = os.path.join(os.path.dirname(__file__), "presentation")
EVENT_FILE = os.path.join(os.path.dirname(__file__), "current_event.json")
@@ -479,6 +485,14 @@ class DisplayManager:
# Setup signal handlers for graceful shutdown
signal.signal(signal.SIGTERM, self._signal_handler)
signal.signal(signal.SIGINT, self._signal_handler)
# Screenshot directory (shared with simclient container via volume)
self.screenshot_dir = os.path.join(os.path.dirname(__file__), 'screenshots')
os.makedirs(self.screenshot_dir, exist_ok=True)
# Start background screenshot thread
self._screenshot_thread = threading.Thread(target=self._screenshot_loop, daemon=True)
self._screenshot_thread.start()
def _signal_handler(self, signum, frame):
"""Handle shutdown signals"""
@@ -1395,6 +1409,7 @@ class DisplayManager:
logging.info("Display Manager starting...")
logging.info(f"Monitoring event file: {EVENT_FILE}")
logging.info(f"Check interval: {CHECK_INTERVAL}s")
logging.info(f"Screenshot capture interval: {SCREENSHOT_CAPTURE_INTERVAL}s (max width {SCREENSHOT_MAX_WIDTH}px, quality {SCREENSHOT_JPEG_QUALITY})")
# Log timezone information for debugging
now_utc = datetime.now(timezone.utc)
@@ -1414,6 +1429,190 @@ class DisplayManager:
logging.info("Display Manager stopped")
# -------------------------------------------------------------
# Screenshot capture subsystem
# -------------------------------------------------------------
def _screenshot_loop(self):
"""Background loop that captures screenshots periodically while an event is active.
Runs in a daemon thread. Only captures when a display process is active and running.
"""
last_capture = 0
while self.running:
try:
if SCREENSHOT_CAPTURE_INTERVAL <= 0:
time.sleep(60)
continue
now = time.time()
if now - last_capture >= SCREENSHOT_CAPTURE_INTERVAL:
if SCREENSHOT_ALWAYS or (self.current_process and self.current_process.is_running()):
self._capture_screenshot()
last_capture = now
else:
# When no active process we can optionally capture blank screen once every 5 intervals
# but for now skip to avoid noise.
pass
time.sleep(1)
except Exception as e:
logging.debug(f"Screenshot loop error: {e}")
time.sleep(5)
def _capture_screenshot(self):
"""Capture a screenshot of the current display and store it in the shared screenshots directory.
Strategy:
1. Prefer 'scrot' if available (fast, simple)
2. Fallback to ImageMagick 'import -window root'
3. Fallback to Pillow-based X11 grab using xwd pipe if available
4. If none available, log warning once.
Downscale and JPEG-compress for bandwidth savings.
Maintains a 'latest.jpg' file and rotates older screenshots beyond SCREENSHOT_MAX_FILES.
"""
try:
ts = datetime.now().strftime('%Y%m%d_%H%M%S')
raw_path = os.path.join(self.screenshot_dir, f'screenshot_{ts}.png')
final_path = os.path.join(self.screenshot_dir, f'screenshot_{ts}.jpg')
captured = False
def command_exists(cmd: str) -> bool:
try:
subprocess.run(['which', cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True)
return True
except Exception:
return False
# Detect session type
session = 'wayland' if os.environ.get('WAYLAND_DISPLAY') else 'x11'
display_env = os.environ.get('DISPLAY')
logging.debug(f"Screenshot session={session} DISPLAY={display_env} WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY')}")
if session == 'wayland':
# 1W: grim (wayland/sway, wlroots) captures root
if command_exists('grim'):
cmd = ['grim', raw_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
if result.returncode == 0 and os.path.exists(raw_path):
captured = True
else:
logging.debug(f"grim failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:120]}")
# 2W: gnome-screenshot (GNOME Wayland)
if not captured and command_exists('gnome-screenshot'):
cmd = ['gnome-screenshot', '-f', raw_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
if result.returncode == 0 and os.path.exists(raw_path):
captured = True
else:
logging.debug(f"gnome-screenshot failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:120]}")
# 3W: spectacle (KDE) if available
if not captured and command_exists('spectacle'):
cmd = ['spectacle', '--noninteractive', '--fullscreen', '--output', raw_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=20)
if result.returncode == 0 and os.path.exists(raw_path):
captured = True
else:
logging.debug(f"spectacle failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:120]}")
else:
# X11 path
# 1X: scrot
if command_exists('scrot'):
cmd = ['scrot', raw_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
if result.returncode == 0 and os.path.exists(raw_path):
captured = True
else:
logging.debug(f"scrot failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:120]}")
# 2X: import (ImageMagick)
if not captured and command_exists('import'):
cmd = ['import', '-window', 'root', raw_path]
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
if result.returncode == 0 and os.path.exists(raw_path):
captured = True
else:
logging.debug(f"import failed rc={result.returncode}: {result.stderr.decode(errors='ignore')[:120]}")
# 3X: Try xwd pipe -> convert via ImageMagick if available
if not captured and command_exists('xwd') and command_exists('convert'):
xwd_file = os.path.join(self.screenshot_dir, f'xwd_{ts}.xwd')
try:
r1 = subprocess.run(['xwd', '-root', '-silent', '-out', xwd_file], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=15)
if r1.returncode == 0 and os.path.exists(xwd_file):
r2 = subprocess.run(['convert', xwd_file, raw_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30)
if r2.returncode == 0 and os.path.exists(raw_path):
captured = True
if os.path.exists(xwd_file):
try:
os.remove(xwd_file)
except Exception:
pass
except Exception as e:
logging.debug(f"xwd/convert pipeline failed: {e}")
if not captured:
# Warn only occasionally
logging.warning("No screenshot tool available for current session. For X11, install 'scrot' or ImageMagick. For Wayland, install 'grim' or 'gnome-screenshot'.")
return
# Open image and downscale/compress
try:
from PIL import Image
with Image.open(raw_path) as im:
width, height = im.size
if width > SCREENSHOT_MAX_WIDTH > 0:
new_height = int(height * (SCREENSHOT_MAX_WIDTH / width))
im = im.resize((SCREENSHOT_MAX_WIDTH, new_height), Image.LANCZOS)
im = im.convert('RGB') # Ensure JPEG compatible
im.save(final_path, 'JPEG', quality=SCREENSHOT_JPEG_QUALITY, optimize=True)
except Exception as e:
logging.debug(f"Resize/compress error: {e}; keeping raw PNG")
final_path = raw_path # Fallback
# Remove raw PNG if we produced JPEG
if final_path != raw_path and os.path.exists(raw_path):
try:
os.remove(raw_path)
except Exception:
pass
# Maintain latest.jpg symlink/copy
latest_link = os.path.join(self.screenshot_dir, 'latest.jpg')
try:
if os.path.islink(latest_link) or os.path.exists(latest_link):
try:
os.remove(latest_link)
except Exception:
pass
# Prefer symlink if possible
try:
os.symlink(os.path.basename(final_path), latest_link)
except Exception:
# Fallback copy
shutil.copyfile(final_path, latest_link)
except Exception as e:
logging.debug(f"Could not update latest.jpg: {e}")
# Rotate old screenshots
try:
files = sorted([f for f in os.listdir(self.screenshot_dir) if f.lower().startswith('screenshot_')], reverse=True)
if len(files) > SCREENSHOT_MAX_FILES:
for old in files[SCREENSHOT_MAX_FILES:]:
try:
os.remove(os.path.join(self.screenshot_dir, old))
except Exception:
pass
except Exception:
pass
size = None
try:
size = os.path.getsize(final_path)
except Exception:
pass
logging.info(f"Screenshot captured: {os.path.basename(final_path)} ({size or 'unknown'} bytes)")
except Exception as e:
logging.debug(f"Screenshot capture failure: {e}")
def main():
"""Entry point"""

View File

@@ -474,6 +474,22 @@ def get_latest_screenshot():
screenshot_dir = os.path.join(os.path.dirname(__file__), "screenshots")
if not os.path.exists(screenshot_dir):
return None
# Prefer 'latest.jpg' symlink/copy if present (written by display_manager)
preferred_path = os.path.join(screenshot_dir, "latest.jpg")
if os.path.exists(preferred_path):
try:
with open(preferred_path, "rb") as f:
screenshot_data = base64.b64encode(f.read()).decode('utf-8')
file_stats = os.stat(preferred_path)
logging.debug(f"Using preferred latest.jpg for screenshot ({file_stats.st_size} bytes)")
return {
"filename": os.path.basename(preferred_path),
"data": screenshot_data,
"timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(),
"size": file_stats.st_size
}
except Exception as e:
logging.debug(f"Could not read latest.jpg, falling back to newest file: {e}")
# Find the most recent screenshot file
screenshot_files = [f for f in os.listdir(screenshot_dir)
@@ -495,12 +511,14 @@ def get_latest_screenshot():
# Get file info
file_stats = os.stat(screenshot_path)
return {
info = {
"filename": latest_file,
"data": screenshot_data,
"timestamp": datetime.fromtimestamp(file_stats.st_mtime).isoformat(),
"size": file_stats.st_size
}
logging.debug(f"Selected latest screenshot: {latest_file} ({file_stats.st_size} bytes)")
return info
except Exception as e:
logging.error(f"Error reading screenshot: {e}")
@@ -526,12 +544,17 @@ def send_screenshot_heartbeat(client, client_id):
# Send to dashboard monitoring topic
dashboard_topic = f"infoscreen/{client_id}/dashboard"
client.publish(dashboard_topic, json.dumps(heartbeat_data))
if screenshot_info:
logging.info(f"Screenshot heartbeat sent: {screenshot_info['filename']} ({screenshot_info['size']} bytes)")
payload = json.dumps(heartbeat_data)
res = client.publish(dashboard_topic, payload, qos=0)
if res.rc == mqtt.MQTT_ERR_SUCCESS:
if screenshot_info:
logging.info(f"Dashboard heartbeat sent with screenshot: {screenshot_info['filename']} ({screenshot_info['size']} bytes)")
else:
logging.info("Dashboard heartbeat sent (no screenshot available)")
elif res.rc == mqtt.MQTT_ERR_NO_CONN:
logging.warning("Dashboard heartbeat publish returned NO_CONN; will retry on next interval")
else:
logging.debug("Heartbeat sent without screenshot")
logging.warning(f"Dashboard heartbeat publish failed with code: {res.rc}")
except Exception as e:
logging.error(f"Error sending screenshot heartbeat: {e}")