From cfc1931975e877b1eeea1c08ead9da5b867f7982 Mon Sep 17 00:00:00 2001 From: RobbStarkAustria Date: Sun, 22 Mar 2026 12:41:13 +0100 Subject: [PATCH] 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 --- README.md | 4 + SERVER_VOLUME_CONTROL_SETUP.md | 498 +++++++++++++++++++++++++++++++++ src/display_manager.py | 213 +++++++++++--- src/simclient.py | 53 +++- 4 files changed, 728 insertions(+), 40 deletions(-) create mode 100644 SERVER_VOLUME_CONTROL_SETUP.md diff --git a/README.md b/README.md index f9e7d1e..a726036 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ CEC_DEVICE=0 # Target device (0 recommended for TV) CEC_TURN_OFF_DELAY=30 # Seconds to wait before turning off TV CEC_POWER_ON_WAIT=5 # Seconds to wait after power ON (for TV boot) CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF + ``` ### 3. Start Services @@ -217,7 +218,10 @@ Notes: - `autoplay` (boolean): start playback automatically when the event becomes active (default: true). - `loop` (boolean): loop playback indefinitely. - `volume` (float): 0.0–1.0 (mapped internally to VLC's 0–100 volume scale). +- Effective playback volume is calculated as `event.video.volume * client_config.audio.video_volume_multiplier` and then mapped to VLC's 0–100 scale. Example: `volume: 0.8` with `audio.video_volume_multiplier: 0.5` results in 40% VLC volume. - If `python-vlc` is not installed, the Display Manager will fall back to launching the external `vlc` binary. +- HDMI-CEC remains the recommended mechanism for TV power control only. TV volume via CEC is not implemented because support is device-dependent and much less reliable than controlling VLC directly. +- The client-wide multiplier is intended to be sent over the existing MQTT config topic `infoscreen/{client_id}/config` and is persisted locally in `src/config/client_settings.json` for the Display Manager. - Fullscreen behavior: - External VLC fallback uses `--fullscreen`. - `python-vlc` mode enforces fullscreen on startup and retries fullscreen toggling briefly because video outputs may attach asynchronously. diff --git a/SERVER_VOLUME_CONTROL_SETUP.md b/SERVER_VOLUME_CONTROL_SETUP.md new file mode 100644 index 0000000..0123575 --- /dev/null +++ b/SERVER_VOLUME_CONTROL_SETUP.md @@ -0,0 +1,498 @@ +# Server-Side Volume Control Implementation + +This document describes the server-side implementation steps for client-based video volume control in Infoscreen. + +## Architecture Overview + +The system uses a two-level volume model: + +1. **Event Volume** (server → client in event payload) + - Per-content loudness intent + - Set by the scheduler/content editor + - Range: `0.0` to `1.0` + - Applies to all clients in the group + +2. **Client Volume Multiplier** (dashboard → client via MQTT config) + - Room/device-specific loudness adjustment + - Set per client in the dashboard + - Range: `0.0` to `1.0` (0% to 100%) + - Applies to all videos on that client + +**Effective playback volume** = `event.video.volume × client_settings.audio.video_volume_multiplier` + +Example: +| Client | Location | Event Volume | Client Multiplier | Result | +|--------|----------|--------------|-------------------|--------| +| A | Hall | 0.8 | 1.0 | 80% | +| B | Library | 0.8 | 0.3 | 24% | + +## MQTT Payload Structure + +The server sends client configuration via retained MQTT message to: + +``` +infoscreen/{client_id}/config +``` + +### Minimal Payload + +```json +{ + "audio": { + "video_volume_multiplier": 0.5 + } +} +``` + +### Full Payload (for future extensibility) + +```json +{ + "audio": { + "video_volume_multiplier": 0.5, + "enabled": true, + "updated_at": "2025-03-12T14:30:00Z" + }, + "display": { + "brightness": 0.9 + } +} +``` + +### Reset / Clear Setting + +To reset to default, publish empty object or null: + +```json +{} +``` + +or + +``` +null +``` + +The client will delete the local config and use default multiplier `1.0`. + +## Server Implementation Steps + +### Step 1: Database Schema + +Add client audio settings to your clients table or config storage. + +**Option A: Dedicated field** + +```sql +ALTER TABLE clients ADD COLUMN video_volume_multiplier DECIMAL(3,2) DEFAULT 1.00; +``` + +Constraints: +```sql +CHECK (video_volume_multiplier >= 0.0 AND video_volume_multiplier <= 1.0) +``` + +**Option B: JSON config storage (flexible)** + +If you already have a `client_settings` or `config` JSON column: + +```json +{ + "audio": { + "video_volume_multiplier": 1.0 + } +} +``` + +### Step 2: API Endpoint + +Create or extend an API to manage client audio settings. + +**Endpoint:** `PATCH /api/clients/{client_id}/settings` + +**Request Body:** + +```json +{ + "audio": { + "video_volume_multiplier": 0.5 + } +} +``` + +**Response:** + +```json +{ + "status": "success", + "client_id": "550e8400-e29b-41d4-a716-446655440000", + "settings": { + "audio": { + "video_volume_multiplier": 0.5, + "updated_at": "2025-03-12T14:30:00Z" + } + } +} +``` + +**Validation:** + +```python +# Pseudo-code +def validate_video_volume_multiplier(value): + if not isinstance(value, (int, float)): + raise ValueError("Must be numeric") + if value < 0.0 or value > 1.0: + raise ValueError("Must be between 0.0 and 1.0") + return float(value) +``` + +### Step 3: Service Logic + +Create a service method to save the setting and publish via MQTT. + +```python +# Pseudo-code +class ClientSettingsService: + def set_video_volume_multiplier(self, client_id, multiplier, user_id): + # Validate + multiplier = validate_video_volume_multiplier(multiplier) + + # Load current client + client = clients.get_by_id(client_id) + if not client: + raise ClientNotFound(client_id) + + # Store previous value for audit + previous = client.video_volume_multiplier or 1.0 + + # Save to database + client.video_volume_multiplier = multiplier + client.save() + + # Publish MQTT retained config + payload = { + "audio": { + "video_volume_multiplier": multiplier, + "updated_at": datetime.utcnow().isoformat() + } + } + mqtt_client.publish( + topic=f"infoscreen/{client_id}/config", + payload=json.dumps(payload), + retain=True, + qos=1 + ) + + # Audit log + AuditLog.create( + client_id=client_id, + action="video_volume_multiplier_changed", + previous_value=previous, + new_value=multiplier, + user_id=user_id, + timestamp=datetime.utcnow() + ) + + return { + "status": "success", + "client_id": client_id, + "settings": payload + } +``` + +### Step 4: Reset/Clear Setting + +Create an endpoint to clear/reset the client setting. + +**Endpoint:** `DELETE /api/clients/{client_id}/settings/audio/video_volume_multiplier` + +**Behavior:** + +```python +def reset_video_volume_multiplier(client_id, user_id): + # Load client + client = clients.get_by_id(client_id) + if not client: + raise ClientNotFound(client_id) + + # Clear database field + previous = client.video_volume_multiplier or 1.0 + client.video_volume_multiplier = None + client.save() + + # Publish empty config to MQTT to reset client + mqtt_client.publish( + topic=f"infoscreen/{client_id}/config", + payload="{}", + retain=True, + qos=1 + ) + + # Audit log + AuditLog.create( + client_id=client_id, + action="video_volume_multiplier_reset", + previous_value=previous, + new_value=1.0, # Default + user_id=user_id, + timestamp=datetime.utcnow() + ) + + return {"status": "success", "client_id": client_id} +``` + +### Step 5: Dashboard UI + +In the client settings page, add a slider control. + +**UI Elements:** + +| Field | Type | Range | Default | Behavior | +|-------|------|-------|---------|----------| +| Client Volume Limit | Slider | 0% to 100% | 100% | Save on change, publish MQTT | +| Display Value | Text | 0.0 to 1.0 | 1.0 | Show decimal value | +| Description | Help Text | — | — | Explain purpose | + +**Suggested Help Text:** + +> **Client Volume Limit** +> Adjust the overall loudness for this display. Use this to adapt videos to different room environments (quiet library: 30%, noisy hall: 100%). The event volume determines the content-specific loudness; this multiplier applies on top. + +**Example: React-like pseudo code** + +```jsx +function ClientVolumeSettings({ clientId, initialMultiplier }) { + const [multiplier, setMultiplier] = useState(initialMultiplier || 1.0); + const percentage = Math.round(multiplier * 100); + + const handleChange = async (newValue) => { + setMultiplier(newValue); + + // Save to server + try { + const response = await fetch( + `/api/clients/${clientId}/settings`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + audio: { video_volume_multiplier: newValue } + }) + } + ); + + if (!response.ok) { + console.error("Failed to save setting"); + setMultiplier(initialMultiplier); // Revert + } + } catch (error) { + console.error("Error:", error); + } + }; + + return ( +
+ + handleChange(e.target.value / 100)} + /> + {percentage}% +
+ ); +} +``` + +### Step 6: Retain Message Handling + +**Important:** Always publish as retained message. + +Why: +- When the client reconnects, it re-subscribes to `infoscreen/{client_id}/config` +- The broker immediately re-delivers the last retained message +- Client always has the latest setting, even after network outages + +**Best Practice:** When changing a setting, overwrite the old retained message: + +```python +# New setting received +mqtt_client.publish( + topic=f"infoscreen/{client_id}/config", + payload=json.dumps(payload), + retain=True, # Mark as retained + qos=1 # Ensure delivery +) +``` + +### Step 7: Validation & Error Handling + +**Server-side validation:** + +```python +def validate_client_config(config_data): + """Validate incoming client config from dashboard.""" + if not isinstance(config_data, dict): + raise ValueError("Config must be an object") + + audio = config_data.get("audio", {}) + if not isinstance(audio, dict): + raise ValueError("audio must be an object") + + multiplier = audio.get("video_volume_multiplier") + if multiplier is not None: + if not isinstance(multiplier, (int, float)): + raise ValueError("video_volume_multiplier must be numeric") + if multiplier < 0.0 or multiplier > 1.0: + raise ValueError("video_volume_multiplier must be between 0.0 and 1.0") + + return config_data +``` + +**Error responses:** + +```json +{ + "status": "error", + "code": "INVALID_VALUE", + "message": "video_volume_multiplier must be between 0.0 and 1.0", + "details": { + "field": "audio.video_volume_multiplier", + "received": 1.5, + "valid_range": [0.0, 1.0] + } +} +``` + +### Step 8: Audit Logging + +Store all changes for compliance and debugging. + +**Audit Log Schema:** + +```sql +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY, + client_id UUID NOT NULL REFERENCES clients(id), + action VARCHAR(255) NOT NULL, + previous_value DECIMAL(3,2), + new_value DECIMAL(3,2), + user_id UUID, + ip_address VARCHAR(45), + timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + details JSONB +); +``` + +**Example Log Entry:** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "client_id": "550e8400-e29b-41d4-a716-446655440111", + "action": "video_volume_multiplier_changed", + "previous_value": 1.0, + "new_value": 0.3, + "user_id": "550e8400-e29b-41d4-a716-446655440222", + "ip_address": "192.168.1.100", + "timestamp": "2025-03-12T14:30:00Z", + "details": { + "location": "library", + "reason": "quiet environment" + } +} +``` + +## Client Consumption + +The client receives the config and applies it automatically. No manual steps needed on the client side; the code is already implemented. + +**Client flow:** + +1. Client subscribes to `infoscreen/{client_id}/config` +2. Server publishes retained config (persisted by broker) +3. Client receives and saves to `src/config/client_settings.json` +4. Display Manager reads the file and applies the multiplier during video playback +5. Client reconnects → broker re-delivers retained message → client re-syncs + +## Testing Scenario + +### Test Case 1: Basic multiplier application + +1. Create client A and client B in the same group +2. Set client A multiplier to `1.0` (hall) +3. Set client B multiplier to `0.3` (library) +4. Send a video event with `video.volume = 0.8` to the group +5. Check client logs: + - Client A: "Video volume resolved: event=0.80 client=1.00 effective=80%" + - Client B: "Video volume resolved: event=0.80 client=0.30 effective=24%" +6. Verify both clients play at correct perceived volumes + +### Test Case 2: Live multiplier update + +1. Start a video on client B (library) +2. Change client B multiplier from `0.3` to `0.6` in the dashboard +3. Check client logs: + - "Applied VLC volume for active video runtime update: 60%" +4. Verify the video volume increases immediately without restarting the video + +### Test Case 3: Reconnect and retained delivery + +1. Set client C multiplier to `0.5` +2. Disconnect client C from MQTT +3. Reconnect client C +4. Verify client C immediately receives retained config and applies `0.5` + +### Test Case 4: Reset to default + +1. Set client D multiplier to `0.2` +2. Click "Reset" in the dashboard +3. Verify server publishes `{}` to config topic +4. Check client log: "No client settings found, using default video multiplier: 1.00" + +## Recommended Multiplier Ranges + +Based on environment and use case: + +| Environment | Recommended Range | Example Use Cases | +|---|---|---| +| Quiet (Library, Museum) | 0.1 - 0.4 | Minimize noise, focus on learning | +| Normal (Classroom, Office) | 0.6 - 0.9 | Default comfortable level | +| Loud (Hall, Outdoor) | 0.9 - 1.0 | Compete with ambient noise | +| Mute / Subtitles Only | 0.0 | Accessible viewing (hearing impaired) | + +## Summary + +**Server implementation checklist:** + +- [ ] Add `video_volume_multiplier` field to `clients` table or JSON config +- [ ] Implement API endpoint `PATCH /api/clients/{client_id}/settings` +- [ ] Add validation: reject values outside `0.0` to `1.0` +- [ ] Implement service to save and publish MQTT config +- [ ] Add retained message publishing to MQTT +- [ ] Add dashboard UI slider control +- [ ] Implement reset/clear endpoint +- [ ] Add audit logging +- [ ] Test client reconnect and retained message delivery +- [ ] Test live volume update during playback +- [ ] Document multiplier ranges and guidance for operators + +**Key contract:** + +```json +{ + "audio": { + "video_volume_multiplier": 0.5 + } +} +``` + +Published to: +``` +infoscreen/{client_id}/config +``` + +With `retain=true` and `qos=1`. diff --git a/src/display_manager.py b/src/display_manager.py index a198f98..5b3ec20 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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}") diff --git a/src/simclient.py b/src/simclient.py index 261011e..47de4d4 100644 --- a/src/simclient.py +++ b/src/simclient.py @@ -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