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

@@ -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_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_ON_WAIT=5 # Seconds to wait after power ON (for TV boot)
CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF CEC_POWER_OFF_WAIT=5 # Seconds to wait after power OFF
``` ```
### 3. Start Services ### 3. Start Services
@@ -217,7 +218,10 @@ Notes:
- `autoplay` (boolean): start playback automatically when the event becomes active (default: true). - `autoplay` (boolean): start playback automatically when the event becomes active (default: true).
- `loop` (boolean): loop playback indefinitely. - `loop` (boolean): loop playback indefinitely.
- `volume` (float): 0.01.0 (mapped internally to VLC's 0100 volume scale). - `volume` (float): 0.01.0 (mapped internally to VLC's 0100 volume scale).
- Effective playback volume is calculated as `event.video.volume * client_config.audio.video_volume_multiplier` and then mapped to VLC's 0100 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. - 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: - Fullscreen behavior:
- External VLC fallback uses `--fullscreen`. - External VLC fallback uses `--fullscreen`.
- `python-vlc` mode enforces fullscreen on startup and retries fullscreen toggling briefly because video outputs may attach asynchronously. - `python-vlc` mode enforces fullscreen on startup and retries fullscreen toggling briefly because video outputs may attach asynchronously.

View File

@@ -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 (
<div>
<label>Client Volume Limit</label>
<input
type="range"
min="0"
max="100"
value={percentage}
onChange={(e) => handleChange(e.target.value / 100)}
/>
<span>{percentage}%</span>
</div>
);
}
```
### 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`.

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 CHECK_INTERVAL = int(os.getenv("DISPLAY_CHECK_INTERVAL", "5")) # seconds
PRESENTATION_DIR = os.path.join(os.path.dirname(__file__), "presentation") PRESENTATION_DIR = os.path.join(os.path.dirname(__file__), "presentation")
EVENT_FILE = os.path.join(os.path.dirname(__file__), "current_event.json") 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 # HDMI-CEC Configuration
# Note: CEC is automatically disabled in development mode to avoid constantly switching TV on/off # Note: CEC is automatically disabled in development mode to avoid constantly switching TV on/off
@@ -407,7 +408,7 @@ class HDMICECController:
class DisplayProcess: class DisplayProcess:
"""Manages a running display application process""" """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 """process: subprocess.Popen when using external binary
player: python-vlc MediaPlayer or MediaListPlayer when using libvlc player: python-vlc MediaPlayer or MediaListPlayer when using libvlc
""" """
@@ -418,6 +419,7 @@ class DisplayProcess:
self.start_time = datetime.now(timezone.utc) self.start_time = datetime.now(timezone.utc)
self.log_file = log_file self.log_file = log_file
self.log_path = log_path self.log_path = log_path
self.volume_pct = volume_pct
def is_running(self) -> bool: def is_running(self) -> bool:
"""Check if the underlying display is still running. """Check if the underlying display is still running.
@@ -560,6 +562,8 @@ class DisplayManager:
self.current_event_data: Optional[Dict] = None self.current_event_data: Optional[Dict] = None
self.last_file_mtime: Optional[float] = None self.last_file_mtime: Optional[float] = None
self.running = True self.running = True
self.client_settings_mtime: Optional[float] = None
self.client_volume_multiplier = 1.0
# Initialize health state tracking for process monitoring # Initialize health state tracking for process monitoring
self.health = ProcessHealthState() self.health = ProcessHealthState()
@@ -585,6 +589,153 @@ class DisplayManager:
self._screenshot_thread = threading.Thread(target=self._screenshot_loop, daemon=True) self._screenshot_thread = threading.Thread(target=self._screenshot_loop, daemon=True)
self._screenshot_thread.start() 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): def _signal_handler(self, signum, frame):
"""Handle shutdown signals""" """Handle shutdown signals"""
logging.info(f"Received signal {signum}, shutting down gracefully...") logging.info(f"Received signal {signum}, shutting down gracefully...")
@@ -959,6 +1110,7 @@ class DisplayManager:
try: try:
video = event.get('video', {}) video = event.get('video', {})
video_url = video.get('url') video_url = video.get('url')
vol_pct = self._calculate_video_volume_pct(video)
if not video_url: if not video_url:
logging.error("No video URL specified") logging.error("No video URL specified")
@@ -976,6 +1128,11 @@ class DisplayManager:
if not vlc: if not vlc:
# Fallback to launching external vlc binary # Fallback to launching external vlc binary
if self._command_exists('vlc'): 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 = [ cmd = [
'vlc', 'vlc',
'--fullscreen', '--fullscreen',
@@ -1002,7 +1159,7 @@ class DisplayManager:
logging.info(f"Video started with PID: {process.pid} (external vlc)") logging.info(f"Video started with PID: {process.pid} (external vlc)")
# Update health state for monitoring # Update health state for monitoring
self.health.update_running(event_id, 'video', 'vlc', process.pid) 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: else:
logging.error("No video player found (python-vlc or vlc binary)") logging.error("No video player found (python-vlc or vlc binary)")
return None return None
@@ -1031,17 +1188,6 @@ class DisplayManager:
autoplay = bool(video.get('autoplay', True)) autoplay = bool(video.get('autoplay', True))
loop_flag = bool(video.get('loop', False)) 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: if loop_flag:
# Use MediaListPlayer for looped playback # Use MediaListPlayer for looped playback
mlp = vlc.MediaListPlayer() mlp = vlc.MediaListPlayer()
@@ -1064,14 +1210,12 @@ class DisplayManager:
pass pass
# Set volume on underlying media player # Set volume on underlying media player
try: self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer')
mp.audio_set_volume(vol_pct)
except Exception:
logging.debug("Could not set volume on media player")
if autoplay: if autoplay:
try: try:
mlp.play() mlp.play()
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaListPlayer', retries=True)
_force_fullscreen(mp, 'python-vlc MediaListPlayer') _force_fullscreen(mp, 'python-vlc MediaListPlayer')
except Exception as e: except Exception as e:
logging.error(f"Failed to play media list: {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}") 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. # python-vlc runs in-process (no external vlc child), so publish this process PID.
self.health.update_running(event_id, 'video', 'vlc', runtime_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: else:
# Single-play MediaPlayer # Single-play MediaPlayer
mp = instance.media_player_new() mp = instance.media_player_new()
media = instance.media_new(video_url) media = instance.media_new(video_url)
mp.set_media(media) mp.set_media(media)
try: self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaPlayer')
mp.audio_set_volume(vol_pct)
except Exception:
logging.debug("Could not set volume on media player")
if autoplay: if autoplay:
try: try:
mp.play() mp.play()
self._apply_vlc_volume(mp, vol_pct, 'python-vlc MediaPlayer', retries=True)
_force_fullscreen(mp, 'python-vlc MediaPlayer') _force_fullscreen(mp, 'python-vlc MediaPlayer')
except Exception as e: except Exception as e:
logging.error(f"Failed to start media player: {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}") 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. # python-vlc runs in-process (no external vlc child), so publish this process PID.
self.health.update_running(event_id, 'video', 'vlc', runtime_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: except Exception as e:
logging.error(f"Error starting video with python-vlc: {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_id = self.get_event_identifier(event)
event_type = event.get('event_type', 'webpage') or 'webpage'
logging.info(f"Webpage started with PID: {process.pid}") logging.info(f"Webpage started with PID: {process.pid}")
# Update health state for monitoring (track chromium browser) # 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, event_type, browser, process.pid)
self.health.update_running(event_id, etype, browser_name, process.pid)
# Inject auto-scroll JS via Chrome DevTools Protocol (CDP) if enabled and available # Inject auto-scroll JS via Chrome DevTools Protocol (CDP) if enabled and available
if autoscroll_enabled: if autoscroll_enabled:
@@ -1515,6 +1657,7 @@ class DisplayManager:
# Everything is fine, continue # Everything is fine, continue
# Cancel any pending TV turn-off since event is still active # Cancel any pending TV turn-off since event is still active
self.cec.cancel_turn_off() self.cec.cancel_turn_off()
self._apply_runtime_video_settings(active_event)
return return
else: else:
# Different event - stop current and start new # Different event - stop current and start new
@@ -1711,20 +1854,13 @@ class DisplayManager:
except Exception: except Exception:
pass 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') latest_link = os.path.join(self.screenshot_dir, 'latest.jpg')
try: try:
if os.path.islink(latest_link) or os.path.exists(latest_link): latest_tmp = os.path.join(self.screenshot_dir, 'latest.jpg.tmp')
try: shutil.copyfile(final_path, latest_tmp)
os.remove(latest_link) os.replace(latest_tmp, 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: except Exception as e:
logging.debug(f"Could not update latest.jpg: {e}") logging.debug(f"Could not update latest.jpg: {e}")
@@ -1745,7 +1881,8 @@ class DisplayManager:
size = os.path.getsize(final_path) size = os.path.getsize(final_path)
except Exception: except Exception:
pass 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: except Exception as e:
logging.debug(f"Screenshot capture failure: {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 (written by display_manager, read by simclient)
HEALTH_STATE_FILE = os.path.join(os.path.dirname(__file__), "current_process_health.json") 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 discovered = False
@@ -509,8 +510,13 @@ def get_latest_screenshot():
logging.debug(f"Could not read latest.jpg, falling back to newest file: {e}") logging.debug(f"Could not read latest.jpg, falling back to newest file: {e}")
# Find the most recent screenshot file # Find the most recent screenshot file
screenshot_files = [f for f in os.listdir(screenshot_dir) # Exclude 'latest.jpg' (it's just a pointer) and any broken symlinks
if f.lower().endswith(('.png', '.jpg', '.jpeg'))] 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: if not screenshot_files:
return None return None
@@ -554,6 +560,27 @@ def read_health_state():
return None 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): def publish_health_message(client, client_id):
"""Publish health status to server via MQTT""" """Publish health status to server via MQTT"""
try: try:
@@ -883,6 +910,28 @@ def main():
client.message_callback_add(group_id_topic, on_group_id_message) 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'}") 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 # Discovery-Phase: Sende Discovery bis ACK empfangen
# The loop is already started, just wait and send discovery messages # The loop is already started, just wait and send discovery messages
discovery_attempts = 0 discovery_attempts = 0