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

13 KiB
Raw Blame History

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

{
  "audio": {
    "video_volume_multiplier": 0.5
  }
}

Full Payload (for future extensibility)

{
  "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:

{}

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

ALTER TABLE clients ADD COLUMN video_volume_multiplier DECIMAL(3,2) DEFAULT 1.00;

Constraints:

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:

{
  "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:

{
  "audio": {
    "video_volume_multiplier": 0.5
  }
}

Response:

{
  "status": "success",
  "client_id": "550e8400-e29b-41d4-a716-446655440000",
  "settings": {
    "audio": {
      "video_volume_multiplier": 0.5,
      "updated_at": "2025-03-12T14:30:00Z"
    }
  }
}

Validation:

# 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.

# 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:

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

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:

# 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:

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:

{
  "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:

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:

{
  "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"

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:

{
  "audio": {
    "video_volume_multiplier": 0.5
  }
}

Published to:

infoscreen/{client_id}/config

With retain=true and qos=1.