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