Files
infoscreen-dev/SERVER_VOLUME_CONTROL_SETUP.md
RobbStarkAustria cfc1931975 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
2026-03-22 12:41:13 +01:00

499 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`.