Add client volume settings and harden screenshot publishing

- persist client config from MQTT for display manager volume control
- apply effective VLC volume from event volume and client multiplier
- support live runtime volume updates for active video playback
- make latest screenshot handoff atomic to avoid broken latest.jpg races
- ignore broken screenshot pointers when selecting fallback images
- fix screenshot size logging and document server-side volume control
This commit is contained in:
RobbStarkAustria
2026-03-22 12:41:13 +01:00
parent 80e5ce98a0
commit cfc1931975
4 changed files with 728 additions and 40 deletions

View File

@@ -51,6 +51,7 @@ SCREENSHOT_ALWAYS = os.getenv("SCREENSHOT_ALWAYS", "0").lower() in ("1","true","
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")
CLIENT_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "config", "client_settings.json")
# HDMI-CEC Configuration
# Note: CEC is automatically disabled in development mode to avoid constantly switching TV on/off
@@ -407,7 +408,7 @@ class HDMICECController:
class DisplayProcess:
"""Manages a running display application process"""
def __init__(self, process: Optional[subprocess.Popen] = None, event_type: str = "", event_id: str = "", log_file: Optional[IO[bytes]] = None, log_path: Optional[str] = None, player: Optional[object] = None):
def __init__(self, process: Optional[subprocess.Popen] = None, event_type: str = "", event_id: str = "", log_file: Optional[IO[bytes]] = None, log_path: Optional[str] = None, player: Optional[object] = None, volume_pct: Optional[int] = None):
"""process: subprocess.Popen when using external binary
player: python-vlc MediaPlayer or MediaListPlayer when using libvlc
"""
@@ -418,6 +419,7 @@ class DisplayProcess:
self.start_time = datetime.now(timezone.utc)
self.log_file = log_file
self.log_path = log_path
self.volume_pct = volume_pct
def is_running(self) -> bool:
"""Check if the underlying display is still running.
@@ -560,6 +562,8 @@ class DisplayManager:
self.current_event_data: Optional[Dict] = None
self.last_file_mtime: Optional[float] = None
self.running = True
self.client_settings_mtime: Optional[float] = None
self.client_volume_multiplier = 1.0
# Initialize health state tracking for process monitoring
self.health = ProcessHealthState()
@@ -584,6 +588,153 @@ class DisplayManager:
# Start background screenshot thread
self._screenshot_thread = threading.Thread(target=self._screenshot_loop, daemon=True)
self._screenshot_thread.start()
self._load_client_settings(force=True)
def _normalize_volume_level(self, value, default: float = 1.0) -> float:
"""Normalize an event/client volume level to a 0.0-1.0 range."""
try:
level = float(value)
except (TypeError, ValueError):
level = default
return max(0.0, min(1.0, level))
def _extract_client_volume_multiplier(self, settings: Dict) -> float:
"""Read the client-wide video multiplier from persisted dashboard settings."""
if not isinstance(settings, dict):
return 1.0
candidate = settings.get('video_volume_multiplier')
if candidate is None:
candidate = settings.get('volume_multiplier')
audio_settings = settings.get('audio')
if isinstance(audio_settings, dict):
candidate = audio_settings.get('video_volume_multiplier', candidate)
candidate = audio_settings.get('volume_multiplier', candidate)
return self._normalize_volume_level(candidate, default=1.0)
def _load_client_settings(self, force: bool = False):
"""Load dashboard-managed client settings from disk when changed."""
try:
if not os.path.exists(CLIENT_SETTINGS_FILE):
if force or self.client_volume_multiplier != 1.0:
self.client_settings_mtime = None
self.client_volume_multiplier = 1.0
logging.info("No client settings found, using default video multiplier: 1.00")
return
current_mtime = os.path.getmtime(CLIENT_SETTINGS_FILE)
if not force and self.client_settings_mtime == current_mtime:
return
with open(CLIENT_SETTINGS_FILE, 'r', encoding='utf-8') as f:
settings = json.load(f)
self.client_settings_mtime = current_mtime
self.client_volume_multiplier = self._extract_client_volume_multiplier(settings)
logging.info(
"Loaded client settings from %s, video multiplier: %.2f",
CLIENT_SETTINGS_FILE,
self.client_volume_multiplier,
)
except Exception as e:
logging.warning(f"Could not load client settings from {CLIENT_SETTINGS_FILE}: {e}")
def _calculate_video_volume_pct(self, video: Dict) -> int:
"""Combine event volume with the client-level multiplier for VLC."""
self._load_client_settings()
event_volume = self._normalize_volume_level(video.get('volume'), default=1.0)
effective_volume = event_volume * self.client_volume_multiplier
effective_pct = int(round(max(0.0, min(1.0, effective_volume)) * 100))
logging.info(
"Video volume resolved: event=%.2f client=%.2f effective=%d%%",
event_volume,
self.client_volume_multiplier,
effective_pct,
)
return effective_pct
def _apply_vlc_volume(self, player: object, volume_pct: int, context: str, retries: bool = False):
"""Apply VLC volume, retrying briefly after playback starts when requested."""
if player is None:
return
def _set_volume_once() -> bool:
try:
result = player.audio_set_volume(volume_pct)
current_volume = None
try:
current_volume = player.audio_get_volume()
except Exception:
current_volume = None
if result == -1:
logging.debug("VLC rejected volume %d%% for %s", volume_pct, context)
return False
if current_volume not in (None, -1):
logging.info("Applied VLC volume for %s: %d%%", context, current_volume)
return True
logging.debug("Applied VLC volume for %s: requested %d%%", context, volume_pct)
return True
except Exception as e:
logging.debug(f"Could not set volume on {context}: {e}")
return False
if _set_volume_once() or not retries:
return
def _worker():
for delay in (0.2, 0.7, 1.5):
time.sleep(delay)
if _set_volume_once():
return
logging.warning("Failed to apply VLC volume for %s after playback start", context)
threading.Thread(target=_worker, daemon=True).start()
def _get_video_audio_target(self, display_process: DisplayProcess) -> Optional[object]:
"""Return the VLC object that accepts audio volume changes."""
player = getattr(display_process, 'player', None)
if not player:
return None
if hasattr(player, 'audio_set_volume'):
return player
if hasattr(player, 'get_media_player'):
try:
media_player = player.get_media_player()
if media_player and hasattr(media_player, 'audio_set_volume'):
return media_player
except Exception:
return None
return None
def _apply_runtime_video_settings(self, event: Dict):
"""Apply client-setting volume changes to an already running video."""
if not self.current_process or self.current_process.event_type != 'video':
return
video = event.get('video', {}) if isinstance(event.get('video', {}), dict) else {}
desired_volume_pct = self._calculate_video_volume_pct(video)
if self.current_process.volume_pct == desired_volume_pct:
return
audio_target = self._get_video_audio_target(self.current_process)
if audio_target is None:
logging.debug(
"Skipping live volume update for current video event; no controllable VLC player is attached"
)
self.current_process.volume_pct = desired_volume_pct
return
self._apply_vlc_volume(audio_target, desired_volume_pct, 'active video runtime update')
self.current_process.volume_pct = desired_volume_pct
def _signal_handler(self, signum, frame):
"""Handle shutdown signals"""
@@ -959,6 +1110,7 @@ class DisplayManager:
try:
video = event.get('video', {})
video_url = video.get('url')
vol_pct = self._calculate_video_volume_pct(video)
if not video_url:
logging.error("No video URL specified")
@@ -976,6 +1128,11 @@ class DisplayManager:
if not vlc:
# Fallback to launching external vlc binary
if self._command_exists('vlc'):
if vol_pct != 100:
logging.warning(
"External VLC fallback does not support reliable per-event startup volume here; requested effective volume is %d%%",
vol_pct,
)
cmd = [
'vlc',
'--fullscreen',
@@ -1002,7 +1159,7 @@ class DisplayManager:
logging.info(f"Video started with PID: {process.pid} (external vlc)")
# Update health state for monitoring
self.health.update_running(event_id, 'video', 'vlc', process.pid)
return DisplayProcess(process=process, event_type='video', event_id=event_id, log_file=video_log, log_path=video_log_path)
return DisplayProcess(process=process, event_type='video', event_id=event_id, log_file=video_log, log_path=video_log_path, volume_pct=vol_pct)
else:
logging.error("No video player found (python-vlc or vlc binary)")
return None
@@ -1031,17 +1188,6 @@ class DisplayManager:
autoplay = bool(video.get('autoplay', True))
loop_flag = bool(video.get('loop', False))
# Handle volume: expected 0.0-1.0 float in event -> convert to 0-100
vol = video.get('volume')
if vol is None:
vol_pct = 100
else:
try:
vol_pct = int(float(vol) * 100)
vol_pct = max(0, min(100, vol_pct))
except Exception:
vol_pct = 100
if loop_flag:
# Use MediaListPlayer for looped playback
mlp = vlc.MediaListPlayer()
@@ -1064,14 +1210,12 @@ class DisplayManager:
pass
# Set volume on underlying media player
try:
mp.audio_set_volume(vol_pct)
except Exception:
logging.debug("Could not set volume on media player")
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer')
if autoplay:
try:
mlp.play()
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer', retries=True)
_force_fullscreen(mp, 'python-vlc MediaListPlayer')
except Exception as e:
logging.error(f"Failed to play media list: {e}")
@@ -1080,21 +1224,19 @@ class DisplayManager:
logging.info(f"Video started via python-vlc (MediaListPlayer), runtime PID: {runtime_pid}")
# python-vlc runs in-process (no external vlc child), so publish this process PID.
self.health.update_running(event_id, 'video', 'vlc', runtime_pid)
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mlp)
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mlp, volume_pct=vol_pct)
else:
# Single-play MediaPlayer
mp = instance.media_player_new()
media = instance.media_new(video_url)
mp.set_media(media)
try:
mp.audio_set_volume(vol_pct)
except Exception:
logging.debug("Could not set volume on media player")
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaPlayer')
if autoplay:
try:
mp.play()
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaPlayer', retries=True)
_force_fullscreen(mp, 'python-vlc MediaPlayer')
except Exception as e:
logging.error(f"Failed to start media player: {e}")
@@ -1104,7 +1246,7 @@ class DisplayManager:
logging.info(f"Video started via python-vlc (MediaPlayer), runtime PID: {runtime_pid}")
# python-vlc runs in-process (no external vlc child), so publish this process PID.
self.health.update_running(event_id, 'video', 'vlc', runtime_pid)
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mp)
return DisplayProcess(process=None, event_type='video', event_id=event_id, log_file=None, log_path=None, player=mp, volume_pct=vol_pct)
except Exception as e:
logging.error(f"Error starting video with python-vlc: {e}")
@@ -1259,11 +1401,11 @@ class DisplayManager:
)
event_id = self.get_event_identifier(event)
event_type = event.get('event_type', 'webpage') or 'webpage'
logging.info(f"Webpage started with PID: {process.pid}")
# Update health state for monitoring (track chromium browser)
browser_name = 'chromium-browser' if self._command_exists('chromium-browser') else 'chromium'
self.health.update_running(event_id, etype, browser_name, process.pid)
self.health.update_running(event_id, event_type, browser, process.pid)
# Inject auto-scroll JS via Chrome DevTools Protocol (CDP) if enabled and available
if autoscroll_enabled:
@@ -1515,6 +1657,7 @@ class DisplayManager:
# Everything is fine, continue
# Cancel any pending TV turn-off since event is still active
self.cec.cancel_turn_off()
self._apply_runtime_video_settings(active_event)
return
else:
# Different event - stop current and start new
@@ -1711,20 +1854,13 @@ class DisplayManager:
except Exception:
pass
# Maintain latest.jpg symlink/copy
# Maintain latest.jpg as an atomic copy so readers never see a missing
# or broken pointer while a new screenshot is being published.
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)
latest_tmp = os.path.join(self.screenshot_dir, 'latest.jpg.tmp')
shutil.copyfile(final_path, latest_tmp)
os.replace(latest_tmp, latest_link)
except Exception as e:
logging.debug(f"Could not update latest.jpg: {e}")
@@ -1745,7 +1881,8 @@ class DisplayManager:
size = os.path.getsize(final_path)
except Exception:
pass
logging.info(f"Screenshot captured: {os.path.basename(final_path)} ({size or 'unknown'} bytes)")
logged_size = size if size is not None else 'unknown'
logging.info(f"Screenshot captured: {os.path.basename(final_path)} ({logged_size} bytes)")
except Exception as e:
logging.debug(f"Screenshot capture failure: {e}")

View File

@@ -143,6 +143,7 @@ logging.info(f"Monitoring logger initialized: {MONITORING_LOG_PATH}")
# Health state file (written by display_manager, read by simclient)
HEALTH_STATE_FILE = os.path.join(os.path.dirname(__file__), "current_process_health.json")
CLIENT_SETTINGS_FILE = os.path.join(os.path.dirname(__file__), "config", "client_settings.json")
discovered = False
@@ -509,8 +510,13 @@ def get_latest_screenshot():
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)
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
# Exclude 'latest.jpg' (it's just a pointer) and any broken symlinks
screenshot_files = [
f for f in os.listdir(screenshot_dir)
if f.lower().endswith(('.png', '.jpg', '.jpeg'))
and f != 'latest.jpg'
and os.path.exists(os.path.join(screenshot_dir, f))
]
if not screenshot_files:
return None
@@ -554,6 +560,27 @@ def read_health_state():
return None
def save_client_settings(settings_data):
"""Persist dashboard-managed client settings for the display manager."""
try:
os.makedirs(os.path.dirname(CLIENT_SETTINGS_FILE), exist_ok=True)
with open(CLIENT_SETTINGS_FILE, 'w', encoding='utf-8') as f:
json.dump(settings_data, f, ensure_ascii=False, indent=2)
logging.info(f"Client settings saved to {CLIENT_SETTINGS_FILE}")
except Exception as e:
logging.error(f"Error saving client settings: {e}")
def delete_client_settings():
"""Delete persisted client settings so defaults apply again."""
try:
if os.path.exists(CLIENT_SETTINGS_FILE):
os.remove(CLIENT_SETTINGS_FILE)
logging.info(f"Client settings deleted: {CLIENT_SETTINGS_FILE}")
except Exception as e:
logging.error(f"Error deleting client settings: {e}")
def publish_health_message(client, client_id):
"""Publish health status to server via MQTT"""
try:
@@ -883,6 +910,28 @@ def main():
client.message_callback_add(group_id_topic, on_group_id_message)
logging.info(f"Current group_id at start: {current_group_id if current_group_id else 'none'}")
config_topic = f"infoscreen/{client_id}/config"
def on_config_message(client, userdata, msg, properties=None):
payload = msg.payload.decode().strip()
if not payload or payload.lower() in ("null", "none", "empty", "{}"):
logging.info("Empty client config received - deleting persisted client settings")
delete_client_settings()
return
try:
config_data = json.loads(payload)
except json.JSONDecodeError as e:
logging.error(f"Invalid JSON in client config message: {e}")
return
if not isinstance(config_data, dict):
logging.warning("Ignoring non-object client config payload")
return
save_client_settings(config_data)
client.message_callback_add(config_topic, on_config_message)
# Discovery-Phase: Sende Discovery bis ACK empfangen
# The loop is already started, just wait and send discovery messages
discovery_attempts = 0