diff --git a/apps/ui/templates/dashboard.html b/apps/ui/templates/dashboard.html
index 6948355..5b33feb 100644
--- a/apps/ui/templates/dashboard.html
+++ b/apps/ui/templates/dashboard.html
@@ -737,12 +737,27 @@
// API_BASE injected from backend (supports Docker/K8s environments)
window.API_BASE = '{{ api_base }}';
+ window.RUNTIME_CONFIG = window.RUNTIME_CONFIG || {};
// Helper function to construct API URLs
function api(url) {
return `${window.API_BASE}${url}`;
}
+ // iOS/Safari Polyfill laden (nur wenn nötig)
+ (function() {
+ var isIOS = /iP(hone|od|ad)/.test(navigator.platform) ||
+ (navigator.userAgent.includes("Mac") && "ontouchend" in document);
+ if (isIOS && typeof window.EventSourcePolyfill === "undefined") {
+ var s = document.createElement("script");
+ s.src = "https://cdn.jsdelivr.net/npm/event-source-polyfill@1.0.31/src/eventsource.min.js";
+ s.onerror = function() {
+ console.warn("EventSource polyfill konnte nicht geladen werden");
+ };
+ document.head.appendChild(s);
+ }
+ })();
+
let eventSource = null;
let currentState = {};
let thermostatTargets = {};
@@ -999,147 +1014,148 @@
}
}
- // Connect to SSE
- let reconnectAttempts = 0;
- const maxReconnectDelay = 30000; // Max 30 seconds
+ // Safari/iOS-kompatibler SSE Client mit Auto-Reconnect
+ let reconnectDelay = 2500;
+ let reconnectTimer = null;
- function connectSSE() {
- // Close existing connection if any
+ // Global handleSSE function für SSE-Nachrichten
+ window.handleSSE = function(data) {
+ console.log('SSE message:', data);
+
+ addEvent(data);
+
+ // Update device state
+ if (data.type === 'state' && data.device_id && data.payload) {
+ const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
+ if (!card) {
+ console.warn(`No card found for device ${data.device_id}`);
+ return;
+ }
+
+ // Check if it's a light
+ if (data.payload.power !== undefined) {
+ currentState[data.device_id] = data.payload.power;
+ updateDeviceUI(
+ data.device_id,
+ data.payload.power,
+ data.payload.brightness
+ );
+ }
+
+ // Check if it's a thermostat
+ if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
+ if (data.payload.mode !== undefined) {
+ thermostatModes[data.device_id] = data.payload.mode;
+ }
+ if (data.payload.target !== undefined) {
+ thermostatTargets[data.device_id] = data.payload.target;
+ }
+ updateThermostatUI(
+ data.device_id,
+ data.payload.current,
+ data.payload.target,
+ data.payload.mode
+ );
+ }
+ }
+ };
+
+ function cleanupSSE() {
if (eventSource) {
- try {
- eventSource.close();
- } catch (e) {
+ try {
+ eventSource.close();
+ } catch(e) {
console.error('Error closing EventSource:', e);
}
eventSource = null;
}
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer) return;
+ console.log(`Reconnecting in ${reconnectDelay}ms...`);
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connectSSE();
+ // Backoff bis 10s
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
+ }, reconnectDelay);
+ }
+
+ function connectSSE() {
+ cleanupSSE();
- console.log(`Connecting to SSE... (attempt ${reconnectAttempts + 1})`);
+ const REALTIME_URL = (window.RUNTIME_CONFIG && window.RUNTIME_CONFIG.REALTIME_URL)
+ ? window.RUNTIME_CONFIG.REALTIME_URL
+ : api('/realtime');
+
+ console.log('Connecting to SSE:', REALTIME_URL);
try {
- eventSource = new EventSource(api('/realtime'));
+ // Verwende Polyfill wenn verfügbar, sonst native EventSource
+ const EventSourceImpl = window.EventSourcePolyfill || window.EventSource;
+ eventSource = new EventSourceImpl(REALTIME_URL, {
+ withCredentials: false
+ });
- eventSource.onopen = () => {
+ eventSource.onopen = function() {
console.log('SSE connected successfully');
- reconnectAttempts = 0; // Reset counter on successful connection
+ reconnectDelay = 2500; // Reset backoff
document.getElementById('connection-status').textContent = 'Verbunden';
document.getElementById('connection-status').className = 'status connected';
};
- eventSource.addEventListener('message', (e) => {
- const data = JSON.parse(e.data);
- console.log('SSE message:', data);
+ eventSource.onmessage = function(evt) {
+ if (!evt || !evt.data) return;
- addEvent(data);
-
- // Update device state
- if (data.type === 'state' && data.device_id && data.payload) {
- const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
- if (!card) {
- console.warn(`No card found for device ${data.device_id}`);
- return;
- }
-
- // Check if it's a light
- if (data.payload.power !== undefined) {
- currentState[data.device_id] = data.payload.power;
- updateDeviceUI(
- data.device_id,
- data.payload.power,
- data.payload.brightness
- );
- }
-
- // Check if it's a thermostat
- if (data.payload.mode !== undefined || data.payload.target !== undefined || data.payload.current !== undefined) {
- if (data.payload.mode !== undefined) {
- thermostatModes[data.device_id] = data.payload.mode;
- }
- if (data.payload.target !== undefined) {
- thermostatTargets[data.device_id] = data.payload.target;
- }
- updateThermostatUI(
- data.device_id,
- data.payload.current,
- data.payload.target,
- data.payload.mode
- );
- }
+ // Heartbeats beginnen mit ":" -> ignorieren
+ if (typeof evt.data === "string" && evt.data.charAt(0) === ":") {
+ return;
}
- });
+
+ try {
+ const data = JSON.parse(evt.data);
+ if (window.handleSSE) {
+ window.handleSSE(data);
+ }
+ } catch (e) {
+ console.error('Error parsing SSE message:', e);
+ }
+ };
- eventSource.addEventListener('ping', (e) => {
- console.log('Heartbeat received');
- });
-
- eventSource.onerror = (error) => {
+ eventSource.onerror = function(error) {
console.error('SSE error:', error, 'readyState:', eventSource?.readyState);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
- if (eventSource) {
- try {
- eventSource.close();
- } catch (e) {
- console.error('Error closing EventSource on error:', e);
- }
- eventSource = null;
- }
-
- // Exponential backoff with max delay
- reconnectAttempts++;
- const delay = Math.min(
- 1000 * Math.pow(2, reconnectAttempts - 1),
- maxReconnectDelay
- );
-
- console.log(`Reconnecting in ${delay}ms... (attempt ${reconnectAttempts})`);
- setTimeout(connectSSE, delay);
+ // Safari/iOS verliert Netz beim App-Switch: ruhig reconnecten
+ scheduleReconnect();
};
+
} catch (error) {
console.error('Failed to create EventSource:', error);
document.getElementById('connection-status').textContent = 'Getrennt';
document.getElementById('connection-status').className = 'status disconnected';
-
- reconnectAttempts++;
- const delay = Math.min(
- 1000 * Math.pow(2, reconnectAttempts - 1),
- maxReconnectDelay
- );
-
- setTimeout(connectSSE, delay);
+ scheduleReconnect();
}
}
- // Safari/iOS specific: Reconnect when page becomes visible
- document.addEventListener('visibilitychange', () => {
- if (document.visibilityState === 'visible') {
- console.log('Page visible, checking SSE connection...');
- // Only reconnect if connection is actually dead (CLOSED = 2)
- if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
- console.log('SSE connection dead, forcing reconnect...');
- reconnectAttempts = 0; // Reset for immediate reconnect
+ // Visibility-Change Handler für iOS App-Switch
+ document.addEventListener('visibilitychange', function() {
+ if (!document.hidden) {
+ // Wenn wieder sichtbar & keine offene Verbindung: verbinden
+ if (!eventSource || eventSource.readyState !== 1) {
+ console.log('Page visible again, reconnecting SSE...');
connectSSE();
- } else {
- console.log('SSE connection OK, readyState:', eventSource.readyState);
}
}
});
- // Safari/iOS specific: Reconnect on page focus
- window.addEventListener('focus', () => {
- console.log('Window focused, checking SSE connection...');
- // Only reconnect if connection is actually dead (CLOSED = 2)
- if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
- console.log('SSE connection dead, forcing reconnect...');
- reconnectAttempts = 0; // Reset for immediate reconnect
- connectSSE();
- } else {
- console.log('SSE connection OK, readyState:', eventSource.readyState);
- }
- });
-
- // Initialize
+ // Start SSE connection
connectSSE();
// Load initial device states