Kiosk Container

From Sea of Fate
Jump to navigationJump to search

Introduction

The main purpose of the Kiosk container is to serve webpages in the form of pictures. The kiosk tablet or screen points at the index.html and the admin is done from admin_index.html. Most of the page layout was created by Gemini and nearly all of the this description page in was also generated by Gemini.

Status Monitoring

As an addition the pages also have some basic functionality showing the status of some of the services offered by Pear's VMs.This is not intended to replace a proper service monitoring application but it has been noted that sometimes during reconfiguring Pear that some services don't restart properly (mainly if Pear is shutdown so the guests don't shutdown gracefully), most often it is Raisin that does not restart Nginx so there is no reverse proxy so no websites served externally (the webservers are running but with no reverse proxy they only listen to the LAN inside Pfsense). I will at some point soon be installing a Prometheus + Grafana stack and possibly a Zabbix server as well.

Other services

In addition to the display of pictures we can display basic reminders, next birthday and time. To set these extra display items there is a Kiosk Management Panel at admin_index.html. The Kiosk Management Panel is a switchboard to the actual configuration pages.

  • Manage Photos is used to upload and remove images used on the kiosk with a preview of the images
  • Manage Reminders is used to add or remove up to 20 possible reminders that will be shown at the top of the kiosk screen
  • Manage Birthdays is an odd one but useful. What it does is compiles a list of all of the birthdays of family and friends and it will display in the "Info and Times" section the next birthday so there is plenty of warning about the birthday to get a present for them.
  • Configure Kiosk Display allows refresh timings and colour setting of the Kiosk screen. Note that if the colours are changed they may not show on the actual kiosk until it is refreshed but the timings should take effect immediately.
  • View Kiosk Display is a link to the index.html page to give a preview of what is going to be served to the actual kiosk. It would be better if the preview was opened in an incognito window so that any js or css will be fresh.

Kiosk Webserver Setup Guide (Ubuntu 22.04 LXC)

This guide details the setup of a dedicated web server within an Ubuntu 22.04 LXC container. The primary purpose of this kiosk is to display a rotating slideshow of local pictures. Additionally, it provides real-time status updates for various internal and external services, local times, and reminders, all for quick convenience.

This setup is designed to be accessible only from your local network (behind pfSense). For broader network monitoring, you will be installing the Prometheus + Grafana stack next.

1. Prepare the LXC Container

First, ensure your Ubuntu 22.04 LXC container is updated and ready.

  • Access your LXC console via Proxmox UI or SSH.
  • Update package lists and upgrade existing packages:
sudo apt update
sudo apt upgrade -y

2. Install Web Server and PHP

Install Apache2 (your web server), PHP, and necessary PHP modules.

  • Install Apache2, PHP, and key PHP modules:
sudo apt install apache2 php libapache2-mod-php php-curl php-mbstring php-gd -y
  • Restart Apache to ensure PHP modules are loaded:
sudo systemctl restart apache2

3. Create Necessary Directories and Set Permissions

The web server needs specific directories to store images, reminders, birthdays, and cache files, with appropriate permissions.

  • Create directories:
sudo mkdir -p /var/www/html/local_photos
sudo mkdir -p /var/www/html/cache
  • Set ownership to the web server user (`www-data`) and grant write permissions to `local_photos` and `cache`:
sudo chown -R www-data:www-data /var/www/html/local_photos/
sudo chmod -R 775 /var/www/html/local_photos/

sudo chown -R www-data:www-data /var/www/html/cache/
sudo chmod -R 775 /var/www/html/cache/
  • Create empty JSON files for configuration, reminders, and birthdays. These need to be writable by `www-data`:
sudo touch /var/www/html/config.json
sudo touch /var/www/html/reminders.json
sudo touch /var/www/html/birthdays.json

sudo chown www-data:www-data /var/www/html/config.json
sudo chown www-data:www-data /var/www/html/reminders.json
sudo chown www-data:www-data /var/www/html/birthdays.json

sudo chmod 664 /var/www/html/config.json
sudo chmod 664 /var/www/html/reminders.json
sudo chmod 664 /var/www/html/birthdays.json

4. Configure Kiosk Settings (`config.json`)

This file controls timings and colors for your kiosk display.

  • Open for editing:
sudo nano /var/www/html/config.json
  • Paste the following content:
{
    "statusFetchInterval": 30000,
    "pictureDisplayInterval": 10000,
    "reminderDisplayDuration": 10000,
    "colors": {
        "bodyBg": "#000000",
        "primaryBarBg": "#333333",
        "secondaryBarBg": "#333333",
        "textColor": "#ffffff",
        "groupBg": "#222222",
        "groupBorder": "#555555",
        "onlineColor": "#00FF00",
        "offlineColor": "#FF0000",
        "httpErrorColor": "#FFFF00",
        "cfErrorColor": "#FFA500",
        "generalErrorColor": "#FF6347",
        "connectionFailedColor": "#FF0000",
        "pingableColor": "#1E90FF",
        "contentMissingColor": "#FFD700",
        "reminderUpcomingColor": "#61dafb",
        "reminderDueColor": "#FFD700"
    }
}
  • Save and exit (`Ctrl+X`, `Y`, `Enter`).

5. Create Kiosk Display Files

These are the core files for your main kiosk display page.

  • File: /var/www/html/index.html
 <!DOCTYPE html>
 <html lang="en">
 <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>Kiosk Display</title>
     <link rel="stylesheet" href="style.css">
 </head>
 <body>
     <div id="top-info-bar">
         <div id="ticker-content">
             </div>
     </div>

     <div id="image-container">
         <img id="kiosk-image" src="" alt="Gallery Image">
     </div>

     <div id="bottom-status-bar">
         <div class="status-group">
             <span class="status-group-title">Websites</span>
             <div class="status-item">Main Site: <span id="www-status" class="status-indicator">...</span></div>
             <div class="status-item">Wiki: <span id="wiki-status" class="status-indicator">...</span></div>
             <div class="status-item">Files: <span id="files-status" class="status-indicator">...</span></div>
             <div class="status-item">Photos: <span id="photos-status" class="status-indicator">...</span></div>
         </div>

         <div class="status-group">
             <span class="status-group-title">Internal Services</span>
             <div class="status-item">Samba Server: <span id="samba-status" class="status-indicator">...</span></div>
             <div class="status-item">Jellyfin: <span id="jellyfin-status" class="status-indicator">...</span></div>
             <div class="status-item">Nginx Proxy: <span id="nginx-proxy-status" class="status-indicator">...</span></div>
         </div>
        
         <div class="status-group">
             <span class="status-group-title">Info & Times</span>
             <div class="status-item">Current Photo: <span id="current-photo-filename" class="status-indicator">...</span></div>
             <div class="status-item">UK Time: <span id="uk-time" class="status-indicator">...</span></div>
             <div class="status-item">Spain Time: <span id="spain-time" class="status-indicator">...</span></div>
             <div class="status-item">Next Birthday: <span id="next-birthday-display" class="status-indicator">...</span></div>
         </div>
     </div>

     <script src="script.js"></script>
 </body>
 </html>

* '''File: /var/www/html/style.css'''
 :root { /* Define CSS variables */
     --body-bg: #000000;
     --primary-bar-bg: #333333; /* Top and bottom bar background */
     --secondary-bar-bg: #222222; /* Status group background */
     --text-color: #ffffff;
     --group-border: #555555;

     --status-online-color: #00FF00;
     --status-offline-color: #FF0000;
     --status-http-error-color: #FFFF00;
     --status-cf-error-color: #FFA500;
     --status-general-error-color: #FF6347;
     --status-connection-failed-color: #FF0000;
     --status-pingable-color: #1E90FF;
     --status-content-missing-color: #FFD700;

     --reminder-upcoming-color: #61dafb;
     --reminder-due-color: #FFD700;
 }

 body {
     margin: 0;
     overflow: hidden;
     background-color: var(--body-bg);
     display: flex;
     flex-direction: column;
     height: 100vh;
     width: 100vw;
     font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
     color: var(--text-color);
     font-size: 1.2em;
 }

 #top-info-bar {
     width: 100%;
     background-color: var(--primary-bar-bg);
     color: var(--text-color);
     padding: 5px 0;
     box-sizing: border-box;
     flex-shrink: 0;
     height: 40px;
     display: flex;
     justify-content: center;
     align-items: center;
     font-size: 1.2em;
     overflow: hidden;
     position: relative;
     z-index: 10;
 }

 #ticker-content {
     white-space: nowrap;
     overflow: hidden;
     text-overflow: ellipsis;
     padding: 0 10px;
     flex-grow: 1;
     flex-shrink: 1;
     min-width: 0;
     text-align: center;
 }

 #image-container {
     flex-grow: 1;
     flex-shrink: 1;
     display: flex;
     justify-content: center;
     align-items: center;
     overflow: hidden;
     width: 100%;
     position: relative;
 }

 #kiosk-image {
     max-width: 100%;
     max-height: 100%;
     object-fit: contain;
     display: block;
     transition: opacity 1s ease-in-out;
     z-index: 5;
 }

 #bottom-status-bar {
     min-height: 120px;
     display: flex;
     flex-wrap: wrap;
     justify-content: space-around;
     align-items: stretch;
     font-size: 0.9em;
     background-color: var(--primary-bar-bg);
     color: var(--text-color);
     padding: 5px 0;
     box-sizing: border-box;
     flex-shrink: 0;
     z-index: 10;
 }
 .status-group {
     flex: 1 1 30%;
     min-width: 150px;
     margin: 5px;
     padding: 5px;
     border: 1px solid var(--group-border);
     border-radius: 5px;
     background-color: var(--secondary-bar-bg);
     display: flex;
     flex-direction: column;
     justify-content: flex-start;
 }
 .status-group-title {
     font-weight: bold;
     text-decoration: underline;
     margin-bottom: 5px;
     display: block;
     text-align: center;
 }
 .status-item {
     display: flex;
     justify-content: space-between;
     padding: 2px 0;
     flex-shrink: 0;
 }
 .status-label {
     font-weight: normal;
 }
 .status-indicator {
     font-weight: bold;
     text-align: right;
     margin-left: 5px;
 }
 /* Status Colors - now reference CSS variables */
 .status-indicator.online { color: var(--status-online-color); }
 .status-indicator.offline { color: var(--status-offline-color); }
 .status-indicator.http-error { color: var(--status-http-error-color); }
 .status-indicator.cf-error { color: var(--status-cf-error-color); }
 .status-indicator.error { color: var(--status-general-error-color); }
 .status-indicator.connection-failed { color: var(--status-connection-failed-color); }
 .status-indicator.pingable { color: var(--status-pingable-color); }
 .status-indicator.content-missing { color: var(--status-content-missing-color); }

 /* Reminder Specific Styles (for single display) */
 .reminder-text {
     margin-right: 5px;
 }
 .reminder-time {
     font-weight: bold;
     flex-shrink: 0;
 }
 .reminder-upcoming {
     color: var(--reminder-upcoming-color);
     font-weight: bold;
 }
 .reminder-due {
     color: var(--reminder-due-color);
     font-weight: bold;
     animation: pulse 1s infinite alternate;
 }
 @keyframes pulse {
     from { transform: scale(1); opacity: 1; }
     to { transform: scale(1.05); opacity: 0.8; }
 }
* File: /var/www/html/script.js
 // --- DOM Element Constants ---
 const imageElement = document.getElementById('kiosk-image');
 const currentPhotoFilenameSpan = document.getElementById('current-photo-filename');
 const tickerContent = document.getElementById('ticker-content'); 

 const wwwStatusSpan = document.getElementById('www-status');
 const wikiStatusSpan = document.getElementById('wiki-status');
 const filesStatusSpan = document.getElementById('files-status');
 const photosStatusSpan = document.getElementById('photos-status');

 const sambaStatusSpan = document.getElementById('samba-status');
 const jellyfinStatusSpan = document.getElementById('jellyfin-status');
 const nginxProxyStatusSpan = document.getElementById('nginx-proxy-status');

 const ukTimeSpan = document.getElementById('uk-time');
 const spainTimeSpan = document.getElementById('spain-time');

 const nextBirthdayDisplaySpan = document.getElementById('next-birthday-display');

 // --- GLOBAL SETTINGS & INTERVALS Variables ---
 let kioskSettings = {};

 let activeReminders = []; 
 let currentReminderDisplayIndex = 0; 
 let reminderCyclingIntervalId = null; 

 let pictureCyclingIntervalId = null; 

 const statusClassMap = {
     'Online': 'online', 'Offline': 'offline', 'Offline (CURL Error)': 'offline',
     'HTTP Error': 'http-error', 'CF Error': 'cf-error', 'Error': 'error', 
     'Connection Failed': 'offline', 'Pingable (UDP)': 'pingable', 
     'Online (UDP)': 'pingable', 'Offline (UDP)': 'offline',
     'Content Missing': 'content-missing'
 };
 // --- ALL FUNCTION DEFINITIONS ---
 function setStatusDisplay(spanElement, statusData) {
     if (typeof statusData === 'string') {
         statusData = { status: statusData, message: statusData };
     }
     spanElement.textContent = statusData.status;
     spanElement.className = 'status-indicator ' + (statusClassMap[statusData.status] || 'error'); 
     if (statusData.message && statusData.message !== statusData.status && statusData.message !== 'OK (HTTP 200)' && statusData.message !== 'Jellyfin OK' && 
 statusData.message !== 'Proxy OK (HTTP 200)') {
          spanElement.textContent = statusData.message;
     }
 }

 function displayCurrentReminder() {
     if (activeReminders.length === 0) {
         tickerContent.innerHTML = "<span class='reminder-text'>No reminders active.</span>";
         return;
     }

     const reminder = activeReminders[currentReminderDisplayIndex];
     const currentTime = Date.now() / 1000; 
 
     const dt = new Date(reminder.timestamp * 1000); 
     const formattedDate = dt.toLocaleDateString([], { month: 'short', day: 'numeric' });
     const formattedTime = dt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
    
     let statusClass = '';
     if (reminder.timestamp <= currentTime && reminder.timestamp >= currentTime - (60 * 5)) { 
         statusClass = 'reminder-due';
     } else if (reminder.timestamp > currentTime && reminder.timestamp <= currentTime + (60 * 15)) {
         statusClass = 'reminder-upcoming';
     }
    
     tickerContent.innerHTML = `<span class="reminder-text ${statusClass}">${reminder.text}</span> <span class="reminder-time ${statusClass}">(${formattedDate} 
 ${formattedTime})</span>`;

     currentReminderDisplayIndex = (currentReminderDisplayIndex + 1) % activeReminders.length;
 }
 // Function to update just the picture
 function updatePicture() {
     imageElement.style.opacity = 0; 

     fetch('http://' + window.location.hostname + '/get_next_image.php') 
         .then(response => {
             if (!response.ok) {
                 throw new Error('Network response was not ok: ' + response.statusText);
             }
             return response.json();
         })
         .then(data => {
             if (data.imageUrl) {
                 const tempImage = new Image();
                 tempImage.onload = () => {
                     imageElement.src = tempImage.src;
                     imageElement.style.opacity = 1;
                 };
                 tempImage.onerror = () => {
                     console.error('Failed to load image:', data.imageUrl);
                     imageElement.src = 'image_load_error.small.png';
                     imageElement.style.opacity = 1;
                  };
                 tempImage.src = data.imageUrl;
                
                  currentPhotoFilenameSpan.textContent = data.currentImageFilename || 'N/A';
              } else {
                 console.warn('No image URL received:', data);
                 imageElement.src = 'error_image.small.png';
                 imageElement.style.opacity = 1;
                 currentPhotoFilenameSpan.textContent = 'Image Error';
             }
         })
         .catch(error => {
             console.error('Error fetching picture:', error);
             imageElement.src = 'no_connection.png';
             imageElement.style.opacity = 1;
             currentPhotoFilenameSpan.textContent = 'Pic Conn Error';
         });
 }
 // MODIFIED: updateKioskContent to only fetch status/text data
 function updateKioskContent() {
     fetch('http://' + window.location.hostname + '/get_status_data.php') 
         .then(response => {
             if (!response.ok) {
                 throw new Error('Network response was not ok: ' + response.statusText);
             }
             return response.json();
         })
         .then(data => {
             if (data.error) {
                 console.error('PHP Script Error:', data.error);
                 // Status area errors
                 [wwwStatusSpan, wikiStatusSpan, filesStatusSpan, photosStatusSpan,
                  sambaStatusSpan, jellyfinStatusSpan, nginxProxyStatusSpan,
                  ukTimeSpan, spainTimeSpan, currentPhotoFilenameSpan, nextBirthdayDisplaySpan].forEach(span => {
                     span.textContent = 'Error';
                     span.className = 'status-indicator error';
                 });
                 currentPhotoFilenameSpan.textContent = 'N/A';
                 ukTimeSpan.textContent = 'N/A';
                 spainTimeSpan.textContent = 'N/A';
                 nextBirthdayDisplaySpan.textContent = 'Error';
                 activeReminders = [{ text: 'ERROR: PHP Script Failed!', timestamp: Date.now() / 1000 }];
                 currentReminderDisplayIndex = 0;
                 if (reminderCyclingIntervalId) clearInterval(reminderCyclingIntervalId);
                 displayCurrentReminder();
                 reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration); 
                 return;
            }
            
             // Update Website Statuses
             if (data.websiteStatuses) {
                 setStatusDisplay(wwwStatusSpan, data.websiteStatuses['Main Site']);
                 setStatusDisplay(wikiStatusSpan, data.websiteStatuses['Wiki']);
                 setStatusDisplay(filesStatusSpan, data.websiteStatuses['Files']);
                 setStatusDisplay(photosStatusSpan, data.websiteStatuses['Photos']);
             }

             // Update Internal Service Statuses
             if (data.serviceStatuses) {
                 setStatusDisplay(sambaStatusSpan, data.serviceStatuses['Samba_Server']);
                 setStatusDisplay(jellyfinStatusSpan, data.serviceStatuses['Jellyfin_Host']);
                 setStatusDisplay(nginxProxyStatusSpan, data.serviceStatuses['Nginx_Proxy']);
             }

             // Reminders
             activeReminders = data.activeReminders;
             currentReminderDisplayIndex = 0;
             
             if (reminderCyclingIntervalId) {
                 clearInterval(reminderCyclingIntervalId);
             }
             displayCurrentReminder();
             if (activeReminders.length > 1) {
                 reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration); 
             }

             // Update Next Birthday
             if (data.nextBirthday) {
                 nextBirthdayDisplaySpan.textContent = `${data.nextBirthday.name} (${data.nextBirthday.date})`;
             } else {
                 nextBirthdayDisplaySpan.textContent = 'N/A';
             }
 
         })
         .catch(error => {
             console.error('Error fetching status data (network/server issue):', error);
             // General network error affects all status displays
             [wwwStatusSpan, wikiStatusSpan, filesStatusSpan, photosStatusSpan,
              sambaStatusSpan, jellyfinStatusSpan, nginxProxyStatusSpan,
              ukTimeSpan, spainTimeSpan, currentPhotoFilenameSpan, nextBirthdayDisplaySpan].forEach(span => {
                 span.textContent = 'LXC Down';
                 span.className = 'status-indicator offline';
             });
             currentPhotoFilenameSpan.textContent = 'N/A';
             ukTimeSpan.textContent = 'N/A';
             spainTimeSpan.textContent = 'N/A';
             nextBirthdayDisplaySpan.textContent = 'LXC Down';
             activeReminders = [{ text: 'ERROR: LXC Offline!', timestamp: Date.now() / 1000 }];
             currentReminderDisplayIndex = 0;
             if (reminderCyclingIntervalId) clearInterval(reminderCyclingIntervalId);
             displayCurrentReminder();
             reminderCyclingIntervalId = setInterval(displayCurrentReminder, kioskSettings.reminderDisplayDuration); 
         });
 }
 // Function to update all times (client-side, every second)
 function updateAllTimes() {
     const now = new Date();
     ukTimeSpan.textContent = now.toLocaleTimeString('en-GB', { timeZone: 'Europe/London', hour: '2-digit', minute: '2-digit', hour12: false });
     spainTimeSpan.textContent = now.toLocaleTimeString('es-ES', { timeZone: 'Europe/Madrid', hour: '2-digit', minute: '2-digit', hour12: false });
 }

JavaScript: Initial Setup and Configuration Loading

This section of script.js is crucial. It runs once when the index.html page first loads, responsible for fetching your kiosk settings from config.json, applying those settings, and then starting all the recurring update intervals for your display.

If config.json cannot be loaded or parsed, the script will fall back to using default settings, ensuring the kiosk still functions.

 // --- INITIAL SETUP (Load Config First) ---
 // This asynchronous function is called once to initialize the entire kiosk display.
 async function initializeKiosk() {
 try {
 // Attempt to fetch the config.json file from the same web server.
 const response = await fetch('http://' + window.location.hostname + '/config.json');
 if (!response.ok) {
 // If the fetch operation itself fails (e.g., file not found, network error),
 // throw an error to immediately jump to the catch block for fallback.
   throw new Error('Failed to load config.json: ' + response.statusText);
 }
 // Parse the JSON response into the global kioskSettings object.
 kioskSettings = await response.json();
 console.log('Kiosk settings loaded:', kioskSettings);

    // Apply colors from the loaded settings to CSS variables.
    // This dynamically updates the display's colors based on your configuration.
    const root = document.documentElement; // This targets the :root pseudo-class in CSS
    root.style.setProperty('--body-bg', kioskSettings.colors.bodyBg);
    root.style.setProperty('--primary-bar-bg', kioskSettings.colors.primaryBarBg);
    root.style.setProperty('--secondary-bar-bg', kioskSettings.colors.secondaryBarBg);
    root.style.setProperty('--text-color', kioskSettings.colors.textColor);
    root.style.setProperty('--group-border', kioskSettings.colors.groupBorder);
    root.style.setProperty('--status-online-color', kioskSettings.colors.onlineColor);
    root.style.setProperty('--status-offline-color', kioskSettings.colors.offlineColor);
    root.style.setProperty('--status-http-error-color', kioskSettings.colors.httpErrorColor);
    root.style.setProperty('--status-cf-error-color', kioskSettings.colors.cfErrorColor);
    root.style.setProperty('--status-general-error-color', kioskSettings.colors.generalErrorColor);
    root.style.setProperty('--status-connection-failed-color', kioskSettings.colors.connectionFailedColor);
    root.style.setProperty('--status-pingable-color', kioskSettings.colors.pingableColor);
    root.style.setProperty('--status-content-missing-color', kioskSettings.colors.contentMissingColor);
    root.style.setProperty('--reminder-upcoming-color', kioskSettings.colors.reminderUpcomingColor);
    root.style.setProperty('--reminder-due-color', kioskSettings.colors.reminderDueColor);

    // Start all main update intervals using the loaded settings.
    // These functions (updateKioskContent, updatePicture, updateAllTimes) are defined earlier in script.js.

    // Initial fetch of status/text data (and sets up initial reminder cycle).
    updateKioskContent();
    // Schedule subsequent status data fetches using the interval from config.json.
    setInterval(updateKioskContent, kioskSettings.statusFetchInterval);

    // Initial fetch of picture.
    updatePicture();
    // Schedule subsequent picture fetches using the interval from config.json.
    pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval);

    // Keep all times (local, UK, Spain) updating every second. This interval is not configurable via config.json.
    setInterval(updateAllTimes, 1000); 

} catch (error) {
    // This catch block executes if `config.json` cannot be loaded or parsed, or any other error occurs during initialization.
    console.error('Error initializing kiosk with config:', error);
    
    // Fallback to default settings. These values should match your initial CSS defaults for consistency.
    // This ensures the kiosk still runs with basic functionality even if config.json is missing or corrupt.
    kioskSettings = { 
        "statusFetchInterval": 30000, // Default status refresh
        "pictureDisplayInterval": 10000, // Default picture refresh
        "reminderDisplayDuration": 10000, // Default reminder cycle
        "colors": { /* Default colors from CSS will apply if config.json fails */ } 
    };
    
    // Alert the user about the configuration failure (visible in browser, might need to check console on kiosk).
    alert('Kiosk configuration could not be loaded. Running with default settings. Please check config.json file and its permissions.');

    // Continue starting intervals using these fallback default values.
    // The kiosk will still attempt to function using its hardcoded CSS colors.
    updateKioskContent();
    setInterval(updateKioskContent, kioskSettings.statusFetchInterval); 
    updatePicture();
    pictureCyclingIntervalId = setInterval(updatePicture, kioskSettings.pictureDisplayInterval);
    setInterval(updateAllTimes, 1000); 
 }
 }

 // Initial calls that kick off the entire process when script.js first loads in the browser.
 // Update times immediately so they show up quickly before the main config loads.
 updateAllTimes();
 // Start the main kiosk initialization process, which will load the config and then set up other intervals.
 initializeKiosk();