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:
@@ -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"""
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user