snapshot
This commit is contained in:
@@ -32,16 +32,33 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status Indicator */
|
#connection {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 9px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#connection.connected { color: #64e8ba; }
|
||||||
|
#connection.connecting { color: #f890e7; }
|
||||||
|
#connection.error { color: #f890e7; }
|
||||||
|
#connection.backoff { color: #888; }
|
||||||
|
|
||||||
#status {
|
#status {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
margin-bottom: 30px;
|
margin-bottom: 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
min-height: 50px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#status.silent {
|
#status.silent {
|
||||||
@@ -57,12 +74,55 @@
|
|||||||
animation: pulse 1s ease-in-out infinite;
|
animation: pulse 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#status.sending {
|
||||||
|
background: #f890e7;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.confirming {
|
||||||
|
background: #0bd3d3;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status.offline {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #f890e7;
|
||||||
|
border: 1px solid #f890e7;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.7; }
|
50% { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Button Grid */
|
.status-detail {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 400;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-top: 4px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#metrics {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #444;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backoffInfo {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #555;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
@@ -80,11 +140,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease, opacity 0.2s ease;
|
transition: transform 0.1s ease, opacity 0.2s ease;
|
||||||
color: #fff8fa;
|
color: #fff8fa;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button:active {
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:not(:disabled):active {
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
@@ -139,11 +202,22 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sendCustom:active {
|
#sendCustom:not(:disabled):active {
|
||||||
background: #0bd3d3;
|
background: #0bd3d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Feedback toast */
|
#silence {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #64e8ba;
|
||||||
|
color: #64e8ba;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#silence:not(:disabled):active {
|
||||||
|
background: #64e8ba;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
#toast {
|
#toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
@@ -166,89 +240,411 @@
|
|||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#toast.error {
|
||||||
|
background: #f890e7;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>KLUBHAUS ALERT</h1>
|
<h1>KLUBHAUS ALERT</h1>
|
||||||
|
<div id="connection" class="connecting">WebSocket: connecting...</div>
|
||||||
|
<div id="backoffInfo">—</div>
|
||||||
|
|
||||||
<div id="status" class="silent">Checking...</div>
|
<div id="status" class="offline">
|
||||||
|
<span>Initializing...</span>
|
||||||
|
</div>
|
||||||
|
<div id="metrics">—</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button id="frontDoor" onclick="sendAlert('FRONT DOOR')">
|
<button id="frontDoor" onclick="sendAlert('FRONT DOOR')">Front Door</button>
|
||||||
Front Door
|
<button id="loadingDock" onclick="sendAlert('LOADING DOCK')">Loading Dock</button>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="loadingDock" onclick="sendAlert('LOADING DOCK')">
|
|
||||||
Loading Dock
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="custom">
|
<div id="custom">
|
||||||
<input type="text" id="customInput" maxlength="60" placeholder="Custom message (60 chars)">
|
<input type="text" id="customInput" maxlength="60" placeholder="Custom message (60 chars)">
|
||||||
<button id="sendCustom" onclick="sendCustom()">Send Custom</button>
|
<button id="sendCustom" onclick="sendCustom()">Send Custom</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="silence" onclick="sendSilence()">Silence</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast">Sent</div>
|
<div id="toast">Sent</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const ALERT_TOPIC = 'https://ntfy.sh/ALERT_klubhaus_topic';
|
// ============== TOPIC URLS ==============
|
||||||
const STATUS_TOPIC = 'https://ntfy.sh/STATUS_klubhaus_topic/json?poll=1';
|
const COMMAND_POST_URL = 'https://ntfy.sh/ALERT_klubhaus_topic';
|
||||||
|
const SILENCE_POST_URL = 'https://ntfy.sh/SILENCE_klubhaus_topic';
|
||||||
|
const METRICS_POST_URL = 'https://ntfy.sh/METRICS_klubhaus_topic';
|
||||||
|
const STATUS_WS_URL = 'wss://ntfy.sh/STATUS_klubhaus_topic/ws';
|
||||||
|
|
||||||
// Poll status every 5 seconds
|
// ============== BACKOFF CONFIGURATION ==============
|
||||||
async function checkStatus() {
|
const ACTIVE_DURATION = 180000; // 3 minutes full speed after interaction
|
||||||
try {
|
const BASE_INTERVAL = 5000; // 5s when active
|
||||||
const response = await fetch(STATUS_TOPIC);
|
const BACKOFF_INTERVALS = [ // Progressive backoff after idle
|
||||||
if (!response.ok) throw new Error('No status');
|
10000, // 0-5 min idle: 10s
|
||||||
|
30000, // 5-10 min idle: 30s
|
||||||
|
60000, // 10-20 min idle: 60s
|
||||||
|
120000, // 20-40 min idle: 2min
|
||||||
|
300000 // 40+ min idle: 5min
|
||||||
|
];
|
||||||
|
|
||||||
const text = await response.text();
|
// ============== CLIENT ID (persistent, anonymous) ==============
|
||||||
const lines = text.split('\n').filter(l => l.trim());
|
function getOrCreateClientId() {
|
||||||
if (lines.length === 0) return;
|
let id = localStorage.getItem('klubhaus_client_id');
|
||||||
|
if (!id) {
|
||||||
|
// Fingerprint from stable browser characteristics (no PII)
|
||||||
|
const seed = [
|
||||||
|
navigator.userAgent.split('/')[0], // Just "Mozilla" or "Chrome"
|
||||||
|
screen.width,
|
||||||
|
screen.height,
|
||||||
|
navigator.hardwareConcurrency || 'unknown',
|
||||||
|
new Date().getTimezoneOffset(),
|
||||||
|
Math.random().toString(36).substring(2, 8) // 6 random chars
|
||||||
|
].join('|');
|
||||||
|
|
||||||
const data = JSON.parse(lines[0]);
|
id = 'web-' + hashString(seed).substring(0, 12);
|
||||||
const statusEl = document.getElementById('status');
|
localStorage.setItem('klubhaus_client_id', id);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.state === 'ALERTING') {
|
function hashString(str) {
|
||||||
statusEl.className = 'alerting';
|
let hash = 0;
|
||||||
statusEl.textContent = 'ALERTING: ' + (data.message || 'UNKNOWN');
|
for (let i = 0; i < str.length; i++) {
|
||||||
} else {
|
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||||
statusEl.className = 'silent';
|
hash = hash & hash;
|
||||||
statusEl.textContent = 'SILENT — System Ready';
|
}
|
||||||
|
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT_ID = getOrCreateClientId();
|
||||||
|
|
||||||
|
// ============== METRICS (with localStorage persistence) ==============
|
||||||
|
function loadMetrics() {
|
||||||
|
const saved = localStorage.getItem('klubhaus_metrics');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(saved);
|
||||||
|
// Restore with defaults for missing fields
|
||||||
|
return {
|
||||||
|
totalPosted: parsed.totalPosted || 0,
|
||||||
|
totalConfirmed: parsed.totalConfirmed || 0,
|
||||||
|
totalReceived: parsed.totalReceived || 0,
|
||||||
|
totalErrors: parsed.totalErrors || 0,
|
||||||
|
roundTripMs: parsed.roundTripMs || [],
|
||||||
|
sessionStart: parsed.sessionStart || Date.now()
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load metrics:', e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
}
|
||||||
document.getElementById('status').className = 'silent';
|
return {
|
||||||
document.getElementById('status').textContent = 'OFFLINE — No Status';
|
totalPosted: 0,
|
||||||
|
totalConfirmed: 0,
|
||||||
|
totalReceived: 0,
|
||||||
|
totalErrors: 0,
|
||||||
|
roundTripMs: [],
|
||||||
|
sessionStart: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMetrics() {
|
||||||
|
localStorage.setItem('klubhaus_metrics', JSON.stringify(metrics));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize metrics from storage
|
||||||
|
const metrics = loadMetrics();
|
||||||
|
|
||||||
|
// ============== BACKOFF STATE ==============
|
||||||
|
let lastInteraction = Date.now();
|
||||||
|
let currentInterval = BASE_INTERVAL;
|
||||||
|
let ws = null;
|
||||||
|
let isProcessing = false;
|
||||||
|
let lastConfirmedMessage = '';
|
||||||
|
let metricsIntervalId = null;
|
||||||
|
|
||||||
|
function recordInteraction() {
|
||||||
|
lastInteraction = Date.now();
|
||||||
|
currentInterval = BASE_INTERVAL;
|
||||||
|
updateBackoffDisplay();
|
||||||
|
console.log('Interaction recorded, speed: 100%');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentInterval() {
|
||||||
|
const idleTime = Date.now() - lastInteraction;
|
||||||
|
|
||||||
|
if (idleTime < ACTIVE_DURATION) {
|
||||||
|
return BASE_INTERVAL; // Full speed for 3 min
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleMinutes = (idleTime - ACTIVE_DURATION) / 60000;
|
||||||
|
|
||||||
|
if (idleMinutes < 5) return BACKOFF_INTERVALS[0]; // 10s
|
||||||
|
if (idleMinutes < 10) return BACKOFF_INTERVALS[1]; // 30s
|
||||||
|
if (idleMinutes < 20) return BACKOFF_INTERVALS[2]; // 60s
|
||||||
|
if (idleMinutes < 40) return BACKOFF_INTERVALS[3]; // 2min
|
||||||
|
return BACKOFF_INTERVALS[4]; // 5min
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBackoffDisplay() {
|
||||||
|
const el = document.getElementById('backoffInfo');
|
||||||
|
const idleTime = Date.now() - lastInteraction;
|
||||||
|
const interval = getCurrentInterval();
|
||||||
|
|
||||||
|
if (idleTime < ACTIVE_DURATION) {
|
||||||
|
const remaining = Math.ceil((ACTIVE_DURATION - idleTime) / 1000);
|
||||||
|
el.textContent = `Active mode: ${remaining}s remaining`;
|
||||||
|
el.style.color = '#64e8ba';
|
||||||
|
} else {
|
||||||
|
const minutes = Math.floor((idleTime - ACTIVE_DURATION) / 60000);
|
||||||
|
el.textContent = `Idle ${minutes}min | Poll: ${interval/1000}s`;
|
||||||
|
el.style.color = '#555';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendAlert(message) {
|
// Update display every 10 seconds
|
||||||
|
setInterval(updateBackoffDisplay, 10000);
|
||||||
|
|
||||||
|
// ============== WEBSOCKET WITH ADAPTIVE RECONNECT ==============
|
||||||
|
function connectWebSocket() {
|
||||||
|
const connEl = document.getElementById('connection');
|
||||||
|
const interval = getCurrentInterval();
|
||||||
|
|
||||||
|
connEl.className = 'connecting';
|
||||||
|
connEl.textContent = `WebSocket: connecting (poll: ${interval/1000}s)...`;
|
||||||
|
|
||||||
|
ws = new WebSocket(STATUS_WS_URL);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
connEl.className = 'connected';
|
||||||
|
connEl.textContent = 'WebSocket: connected';
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
handleStatusMessage(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Invalid JSON:', event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
connEl.className = 'error';
|
||||||
|
connEl.textContent = 'WebSocket: error';
|
||||||
|
console.error('WebSocket error:', err);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
connEl.className = 'backoff';
|
||||||
|
const nextInterval = getCurrentInterval();
|
||||||
|
connEl.textContent = `WebSocket: reconnect in ${nextInterval/1000}s...`;
|
||||||
|
console.log(`WebSocket closed, reconnecting in ${nextInterval}ms...`);
|
||||||
|
|
||||||
|
// Adaptive reconnect based on idle time
|
||||||
|
setTimeout(connectWebSocket, nextInterval);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStatusMessage(data) {
|
||||||
|
if (data.event === 'open') {
|
||||||
|
console.log('Subscription confirmed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.event !== 'message' || !data.message) return;
|
||||||
|
|
||||||
|
let payload;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(ALERT_TOPIC, {
|
payload = JSON.parse(data.message);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse payload:', data.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.totalReceived++;
|
||||||
|
saveMetrics(); // Persist immediately
|
||||||
|
|
||||||
|
updateStatusDisplay(payload);
|
||||||
|
updateMetricsDisplay();
|
||||||
|
|
||||||
|
if (isProcessing && payload.state === 'ALERTING' && payload.message === lastConfirmedMessage) {
|
||||||
|
finishConfirmation(true, payload.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatusDisplay(data) {
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const state = data.state || 'UNKNOWN';
|
||||||
|
const message = data.message || '';
|
||||||
|
|
||||||
|
if (isProcessing && state === 'ALERTING') return;
|
||||||
|
|
||||||
|
if (state === 'ALERTING') {
|
||||||
|
statusEl.className = 'alerting';
|
||||||
|
statusEl.innerHTML = `<span>ALERTING</span><span class="status-detail">${escapeHtml(message)}</span>`;
|
||||||
|
} else if (state === 'SILENT') {
|
||||||
|
statusEl.className = 'silent';
|
||||||
|
statusEl.innerHTML = `<span>SILENT</span><span class="status-detail">System Ready</span>`;
|
||||||
|
} else {
|
||||||
|
statusEl.className = 'offline';
|
||||||
|
statusEl.innerHTML = `<span>UNKNOWN</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== ALERT SENDING ==============
|
||||||
|
async function sendAlert(message) {
|
||||||
|
recordInteraction(); // Reset backoff
|
||||||
|
|
||||||
|
if (isProcessing) return;
|
||||||
|
isProcessing = true;
|
||||||
|
lastConfirmedMessage = message;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
statusEl.className = 'sending';
|
||||||
|
statusEl.innerHTML = `<span>SENDING</span><span class="status-detail">Posting...</span>`;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(COMMAND_POST_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: message,
|
body: message,
|
||||||
headers: { 'Title': 'KLUBHAUS ALERT' }
|
headers: { 'Title': 'KLUBHAUS ALERT' }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
metrics.totalPosted++;
|
||||||
showToast('ALERT SENT: ' + message);
|
saveMetrics();
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||||
|
|
||||||
|
statusEl.className = 'confirming';
|
||||||
|
statusEl.innerHTML = `<span>CONFIRMING</span><span class="status-detail">Waiting for device...</span>`;
|
||||||
|
|
||||||
|
const confirmed = await waitForWebSocketConfirmation(message, 8000);
|
||||||
|
const roundTripMs = Math.round(performance.now() - startTime);
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
finishConfirmation(true, message, roundTripMs);
|
||||||
} else {
|
} else {
|
||||||
showToast('FAILED — TRY AGAIN');
|
statusEl.innerHTML = `<span>CHECKING</span><span class="status-detail">Fallback...</span>`;
|
||||||
|
const fallback = await fallbackPollForConfirmation(message, 3000);
|
||||||
|
finishConfirmation(fallback, message, roundTripMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast('NETWORK ERROR');
|
metrics.totalErrors++;
|
||||||
|
saveMetrics();
|
||||||
|
statusEl.className = 'offline';
|
||||||
|
statusEl.innerHTML = `<span>FAILED</span><span class="status-detail">${escapeHtml(e.message)}</span>`;
|
||||||
|
showToast('NETWORK ERROR', true);
|
||||||
|
isProcessing = false;
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
updateMetricsDisplay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForWebSocketConfirmation(expectedMessage, timeoutMs) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
const check = setInterval(() => {
|
||||||
|
if (!isProcessing || Date.now() > deadline) {
|
||||||
|
clearInterval(check);
|
||||||
|
resolve(!isProcessing); // Resolved if finishConfirmation called
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fallbackPollForConfirmation(expectedMessage, timeoutMs) {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
// Short poll without WebSocket
|
||||||
|
const response = await fetch('https://ntfy.sh/STATUS_klubhaus_topic/json', {
|
||||||
|
signal: AbortSignal.timeout(3000)
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const lines = text.split('\n').filter(l => l.trim());
|
||||||
|
if (lines.length === 0) continue;
|
||||||
|
|
||||||
|
const data = JSON.parse(lines[0]);
|
||||||
|
if (data.state === 'ALERTING' && data.message === expectedMessage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishConfirmation(success, message, roundTripMs = 0) {
|
||||||
|
isProcessing = false;
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
metrics.totalConfirmed++;
|
||||||
|
if (roundTripMs > 0) {
|
||||||
|
metrics.roundTripMs.push(roundTripMs);
|
||||||
|
if (metrics.roundTripMs.length > 10) metrics.roundTripMs.shift();
|
||||||
|
}
|
||||||
|
saveMetrics();
|
||||||
|
showToast(`ALERT: ${message}${roundTripMs > 0 ? ' (' + roundTripMs + 'ms)' : ''}`);
|
||||||
|
publishSuccessMetric(message, roundTripMs, true);
|
||||||
|
} else {
|
||||||
|
showToast('POSTED — UNCONFIRMED', true);
|
||||||
|
publishSuccessMetric(message, roundTripMs, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetricsDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishSuccessMetric(message, roundTripMs, confirmed) {
|
||||||
|
const payload = {
|
||||||
|
client: CLIENT_ID,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
event: 'publish_success',
|
||||||
|
message: message.substring(0, 20),
|
||||||
|
roundTripMs: roundTripMs,
|
||||||
|
confirmed: confirmed
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(METRICS_POST_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== CUSTOM & SILENCE ==============
|
||||||
function sendCustom() {
|
function sendCustom() {
|
||||||
const input = document.getElementById('customInput');
|
const input = document.getElementById('customInput');
|
||||||
const message = input.value.trim().toUpperCase();
|
const message = input.value.trim().toUpperCase();
|
||||||
|
|
||||||
if (message.length === 0) {
|
if (message.length === 0) {
|
||||||
showToast('ENTER MESSAGE');
|
showToast('ENTER MESSAGE', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.length > 60) {
|
if (message.length > 60) {
|
||||||
showToast('TOO LONG — MAX 60');
|
showToast('TOO LONG — MAX 60', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,21 +652,129 @@
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(text) {
|
async function sendSilence() {
|
||||||
const toast = document.getElementById('toast');
|
recordInteraction();
|
||||||
toast.textContent = text;
|
|
||||||
toast.classList.add('show');
|
if (isProcessing) return;
|
||||||
setTimeout(() => toast.classList.remove('show'), 2000);
|
isProcessing = true;
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
statusEl.className = 'sending';
|
||||||
|
statusEl.innerHTML = `<span>SENDING SILENCE</span><span class="status-detail">To all topics...</span>`;
|
||||||
|
setButtonsDisabled(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
fetch(SILENCE_POST_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'SILENCE',
|
||||||
|
headers: { 'Title': 'KLUBHAUS SILENCE' }
|
||||||
|
}),
|
||||||
|
fetch(COMMAND_POST_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: 'SILENCE',
|
||||||
|
headers: { 'Title': 'KLUBHAUS SILENCE' }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
metrics.totalPosted += 2;
|
||||||
|
saveMetrics();
|
||||||
|
showToast('SILENCE SENT');
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
metrics.totalErrors++;
|
||||||
|
saveMetrics();
|
||||||
|
showToast('SILENCE FAILED', true);
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
|
setButtonsDisabled(false);
|
||||||
|
updateMetricsDisplay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter key in custom input
|
// ============== UI HELPERS ==============
|
||||||
|
function setButtonsDisabled(disabled) {
|
||||||
|
document.getElementById('frontDoor').disabled = disabled;
|
||||||
|
document.getElementById('loadingDock').disabled = disabled;
|
||||||
|
document.getElementById('sendCustom').disabled = disabled;
|
||||||
|
document.getElementById('silence').disabled = disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(text, isError = false) {
|
||||||
|
const toast = document.getElementById('toast');
|
||||||
|
toast.textContent = text;
|
||||||
|
toast.className = isError ? 'show error' : 'show';
|
||||||
|
setTimeout(() => toast.className = '', 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetricsDisplay() {
|
||||||
|
const el = document.getElementById('metrics');
|
||||||
|
const avgRtt = metrics.roundTripMs.length > 0
|
||||||
|
? Math.round(metrics.roundTripMs.reduce((a,b) => a+b, 0) / metrics.roundTripMs.length)
|
||||||
|
: 0;
|
||||||
|
const sessionHours = ((Date.now() - metrics.sessionStart) / 3600000).toFixed(1);
|
||||||
|
el.textContent = `${CLIENT_ID} | posted:${metrics.totalPosted} confirmed:${metrics.totalConfirmed} errors:${metrics.totalErrors} avg:${avgRtt}ms | session:${sessionHours}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== AGGREGATE METRICS (with backoff-aware interval) ==============
|
||||||
|
function scheduleMetricsPublish() {
|
||||||
|
// Clear existing
|
||||||
|
if (metricsIntervalId) clearInterval(metricsIntervalId);
|
||||||
|
|
||||||
|
const interval = getCurrentInterval();
|
||||||
|
console.log(`Scheduling metrics publish: ${interval}ms`);
|
||||||
|
|
||||||
|
metricsIntervalId = setInterval(async () => {
|
||||||
|
const payload = {
|
||||||
|
client: CLIENT_ID,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
metrics: {
|
||||||
|
totalPosted: metrics.totalPosted,
|
||||||
|
totalConfirmed: metrics.totalConfirmed,
|
||||||
|
totalReceived: metrics.totalReceived,
|
||||||
|
totalErrors: metrics.totalErrors,
|
||||||
|
avgRoundTripMs: metrics.roundTripMs.length > 0
|
||||||
|
? Math.round(metrics.roundTripMs.reduce((a,b) => a+b, 0) / metrics.roundTripMs.length)
|
||||||
|
: 0,
|
||||||
|
currentPollInterval: getCurrentInterval()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(METRICS_POST_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
console.log('Aggregate metrics published');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Metrics publish failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reschedule with potentially new interval
|
||||||
|
scheduleMetricsPublish();
|
||||||
|
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== INIT ==============
|
||||||
document.getElementById('customInput').addEventListener('keypress', (e) => {
|
document.getElementById('customInput').addEventListener('keypress', (e) => {
|
||||||
if (e.key === 'Enter') sendCustom();
|
if (e.key === 'Enter') sendCustom();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start polling
|
// Track any interaction
|
||||||
checkStatus();
|
document.addEventListener('click', recordInteraction);
|
||||||
setInterval(checkStatus, 5000);
|
document.addEventListener('touchstart', recordInteraction);
|
||||||
|
document.addEventListener('keydown', recordInteraction);
|
||||||
|
|
||||||
|
connectWebSocket();
|
||||||
|
scheduleMetricsPublish();
|
||||||
|
updateMetricsDisplay();
|
||||||
|
updateBackoffDisplay();
|
||||||
|
|
||||||
|
console.log('KLUBHAUS ALERT v4.1 initialized');
|
||||||
|
console.log('Client ID:', CLIENT_ID);
|
||||||
|
console.log('Loaded metrics:', metrics);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -7,18 +7,16 @@
|
|||||||
const char* ssid = "iot-2GHz";
|
const char* ssid = "iot-2GHz";
|
||||||
const char* password = "lesson-greater";
|
const char* password = "lesson-greater";
|
||||||
|
|
||||||
// TOPIC URLS — naming indicates direction and method
|
// TOPIC URLS — short poll (no ?poll=1) for ESP32 HTTP client compatibility
|
||||||
// Device SUBSCRIBES (GET poll) to these:
|
const char* COMMAND_POLL_URL = "http://ntfy.sh/ALERT_klubhaus_topic/json";
|
||||||
const char* COMMAND_POLL_URL = "http://ntfy.sh/ALERT_klubhaus_topic/json?poll=1";
|
const char* SILENCE_POLL_URL = "http://ntfy.sh/SILENCE_klubhaus_topic/json";
|
||||||
const char* SILENCE_POLL_URL = "http://ntfy.sh/SILENCE_klubhaus_topic/json?poll=1";
|
|
||||||
|
|
||||||
// Device PUBLISHES (POST) to this:
|
|
||||||
const char* STATUS_POST_URL = "http://ntfy.sh/STATUS_klubhaus_topic";
|
const char* STATUS_POST_URL = "http://ntfy.sh/STATUS_klubhaus_topic";
|
||||||
|
|
||||||
const unsigned long COMMAND_POLL_INTERVAL = 30000;
|
const unsigned long COMMAND_POLL_INTERVAL = 30000; // 30s normal
|
||||||
const unsigned long SILENCE_POLL_INTERVAL = 5000;
|
const unsigned long SILENCE_POLL_INTERVAL = 5000; // 5s when alerting
|
||||||
const unsigned long BLINK_DURATION = 180000;
|
const unsigned long BLINK_DURATION = 180000;
|
||||||
const unsigned long BLINK_PERIOD = 1000;
|
const unsigned long BLINK_PERIOD = 1000;
|
||||||
|
const unsigned long ERROR_BACKOFF = 60000; // Back off on 429/500
|
||||||
|
|
||||||
#define BACKLIGHT_PIN 22
|
#define BACKLIGHT_PIN 22
|
||||||
#define BUTTON_PIN 9
|
#define BUTTON_PIN 9
|
||||||
@@ -50,6 +48,7 @@ State lastPublishedState = STATE_SILENT;
|
|||||||
|
|
||||||
unsigned long lastCommandPoll = 0;
|
unsigned long lastCommandPoll = 0;
|
||||||
unsigned long lastSilencePoll = 0;
|
unsigned long lastSilencePoll = 0;
|
||||||
|
unsigned long nextPollTime = 0; // Backoff timer
|
||||||
unsigned long blinkStartTime = 0;
|
unsigned long blinkStartTime = 0;
|
||||||
bool blinkPhase = false;
|
bool blinkPhase = false;
|
||||||
|
|
||||||
@@ -61,8 +60,8 @@ String alertMessage = "";
|
|||||||
void setup() {
|
void setup() {
|
||||||
Serial.begin(115200);
|
Serial.begin(115200);
|
||||||
delay(3000);
|
delay(3000);
|
||||||
Serial.println("\n=== KLUBHAUS DOORBELL v3.3 ===");
|
Serial.println("\n=== KLUBHAUS DOORBELL v3.4 ===");
|
||||||
Serial.println("URLs: COMMAND_POLL_URL, SILENCE_POLL_URL, STATUS_POST_URL");
|
Serial.println("Fix: Short polling, 204 handling, rate limit backoff");
|
||||||
|
|
||||||
pinMode(BACKLIGHT_PIN, OUTPUT);
|
pinMode(BACKLIGHT_PIN, OUTPUT);
|
||||||
digitalWrite(BACKLIGHT_PIN, LOW);
|
digitalWrite(BACKLIGHT_PIN, LOW);
|
||||||
@@ -96,6 +95,13 @@ void setup() {
|
|||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
|
|
||||||
|
// Respect backoff after errors
|
||||||
|
if (now < nextPollTime) {
|
||||||
|
delay(100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
handleButton();
|
handleButton();
|
||||||
|
|
||||||
switch (currentState) {
|
switch (currentState) {
|
||||||
@@ -123,7 +129,7 @@ void loop() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(10);
|
delay(50); // Small delay to prevent tight loops
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============== STATUS PUBLISHING ==============
|
// ============== STATUS PUBLISHING ==============
|
||||||
@@ -188,10 +194,7 @@ void transitionTo(State newState) {
|
|||||||
void checkCommandTopic() {
|
void checkCommandTopic() {
|
||||||
Serial.println("Poll COMMAND...");
|
Serial.println("Poll COMMAND...");
|
||||||
String response = fetchNtfyJson(COMMAND_POLL_URL);
|
String response = fetchNtfyJson(COMMAND_POLL_URL);
|
||||||
if (response.length() == 0) {
|
if (response.length() == 0) return;
|
||||||
Serial.println(" No new command");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
StaticJsonDocument<512> doc;
|
StaticJsonDocument<512> doc;
|
||||||
DeserializationError error = deserializeJson(doc, response);
|
DeserializationError error = deserializeJson(doc, response);
|
||||||
@@ -202,15 +205,23 @@ void checkCommandTopic() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String id = doc["id"] | "";
|
String id = doc["id"] | "";
|
||||||
if (id == lastCommandId) {
|
String message = doc["message"] | "";
|
||||||
|
|
||||||
|
// CRITICAL: Skip empty messages
|
||||||
|
if (message.length() == 0 || message.trim().length() == 0) {
|
||||||
|
Serial.println(" Empty message, skip");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id == lastCommandId && id.length() > 0) {
|
||||||
Serial.println(" Same ID, skip");
|
Serial.println(" Same ID, skip");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastCommandId = id;
|
lastCommandId = id;
|
||||||
|
|
||||||
String message = doc["message"] | "";
|
Serial.print(" NEW: [");
|
||||||
Serial.print(" NEW: ");
|
Serial.print(message);
|
||||||
Serial.println(message);
|
Serial.println("]");
|
||||||
|
|
||||||
if (message.equalsIgnoreCase("SILENCE")) {
|
if (message.equalsIgnoreCase("SILENCE")) {
|
||||||
Serial.println(" -> SILENCE command");
|
Serial.println(" -> SILENCE command");
|
||||||
@@ -232,12 +243,17 @@ void checkSilenceTopic() {
|
|||||||
if (error) return;
|
if (error) return;
|
||||||
|
|
||||||
String id = doc["id"] | "";
|
String id = doc["id"] | "";
|
||||||
if (id == lastSilenceId) return;
|
String message = doc["message"] | "";
|
||||||
|
|
||||||
|
// Skip empty messages
|
||||||
|
if (message.length() == 0 || message.trim().length() == 0) return;
|
||||||
|
|
||||||
|
if (id == lastSilenceId && id.length() > 0) return;
|
||||||
lastSilenceId = id;
|
lastSilenceId = id;
|
||||||
|
|
||||||
String message = doc["message"] | "";
|
Serial.print("Silence topic: [");
|
||||||
Serial.print("Silence topic: ");
|
Serial.print(message);
|
||||||
Serial.println(message);
|
Serial.println("]");
|
||||||
|
|
||||||
if (currentState == STATE_ALERT) {
|
if (currentState == STATE_ALERT) {
|
||||||
Serial.println(" -> Force silence");
|
Serial.println(" -> Force silence");
|
||||||
@@ -249,9 +265,26 @@ void checkSilenceTopic() {
|
|||||||
String fetchNtfyJson(const char* url) {
|
String fetchNtfyJson(const char* url) {
|
||||||
HTTPClient http;
|
HTTPClient http;
|
||||||
http.begin(url);
|
http.begin(url);
|
||||||
http.setTimeout(5000);
|
http.setTimeout(8000); // 8s timeout for short poll
|
||||||
|
|
||||||
int code = http.GET();
|
int code = http.GET();
|
||||||
|
|
||||||
|
// 204 No Content = no new messages (normal, not an error)
|
||||||
|
if (code == 204) {
|
||||||
|
http.end();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limited or server error — back off
|
||||||
|
if (code == 429 || code == 500 || code == 502 || code == 503) {
|
||||||
|
Serial.print(" HTTP error ");
|
||||||
|
Serial.print(code);
|
||||||
|
Serial.println(" — backing off 60s");
|
||||||
|
nextPollTime = millis() + ERROR_BACKOFF;
|
||||||
|
http.end();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (code != 200) {
|
if (code != 200) {
|
||||||
Serial.print(" HTTP error: ");
|
Serial.print(" HTTP error: ");
|
||||||
Serial.println(code);
|
Serial.println(code);
|
||||||
@@ -262,6 +295,7 @@ String fetchNtfyJson(const char* url) {
|
|||||||
String payload = http.getString();
|
String payload = http.getString();
|
||||||
http.end();
|
http.end();
|
||||||
|
|
||||||
|
// Take first line if multiple JSON objects
|
||||||
int newline = payload.indexOf('\n');
|
int newline = payload.indexOf('\n');
|
||||||
if (newline > 0) payload = payload.substring(0, newline);
|
if (newline > 0) payload = payload.substring(0, newline);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user