- 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
499 lines
13 KiB
Markdown
499 lines
13 KiB
Markdown
# 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`.
|