|
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | +// Copyright (c) 2025-2026 Ravi Singh (Techposts) |
| 3 | + |
| 4 | +import db, { stmts } from './db.js'; |
| 5 | +import { broadcastToUser } from './sse.js'; |
| 6 | +import { sendPush } from './push-sender.js'; |
| 7 | + |
| 8 | +const alertState = new Map(); |
| 9 | +const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY || ''; |
| 10 | + |
| 11 | +function getState(deviceId) { |
| 12 | + if (!alertState.has(deviceId)) { |
| 13 | + alertState.set(deviceId, { |
| 14 | + low_water: false, tank_empty: false, high_water: false, |
| 15 | + low_battery: false, battery_critical: false, weak_signal: false, |
| 16 | + device_offline: false, device_stale: false, rapid_drop: false, |
| 17 | + recentReadings: [], lastOnlineTime: null, wasOffline: false, |
| 18 | + }); |
| 19 | + } |
| 20 | + return alertState.get(deviceId); |
| 21 | +} |
| 22 | + |
| 23 | +export async function checkAlerts(device, site) { |
| 24 | + const state = getState(device.id); |
| 25 | + const pct = device.last_water_pct; |
| 26 | + const bat = device.last_battery_pct; |
| 27 | + const rssi = device.last_rssi; |
| 28 | + const now = Date.now(); |
| 29 | + |
| 30 | + if (pct != null) { |
| 31 | + state.recentReadings.push({ pct, timestamp: now }); |
| 32 | + const twoHoursAgo = now - 2 * 3600_000; |
| 33 | + state.recentReadings = state.recentReadings.filter(r => r.timestamp > twoHoursAgo); |
| 34 | + } |
| 35 | + |
| 36 | + // Tank empty |
| 37 | + if (pct != null && pct <= 0 && !state.tank_empty) { |
| 38 | + state.tank_empty = true; |
| 39 | + await createAlert(device, site, 'tank_empty', `${device.name} is completely empty! 0% water remaining.`, 'critical'); |
| 40 | + } else if (pct > 5) { state.tank_empty = false; } |
| 41 | + |
| 42 | + // Low water |
| 43 | + if (pct != null && pct > 0 && pct <= device.alert_low_pct && !state.low_water) { |
| 44 | + state.low_water = true; |
| 45 | + await createAlert(device, site, 'low_water', `${device.name} water level low: ${Math.round(pct)}% (threshold: ${device.alert_low_pct}%)`, 'critical'); |
| 46 | + } else if (pct > device.alert_low_pct + 5) { |
| 47 | + if (state.low_water) { |
| 48 | + state.low_water = false; |
| 49 | + await createAlert(device, site, 'level_recovered', `${device.name} water level recovered to ${Math.round(pct)}%`, 'info'); |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + // High water |
| 54 | + if (pct != null && pct >= device.alert_high_pct && !state.high_water) { |
| 55 | + state.high_water = true; |
| 56 | + await createAlert(device, site, 'high_water', `${device.name} water level high: ${Math.round(pct)}% — possible overflow risk`, 'warning'); |
| 57 | + } else if (pct < device.alert_high_pct - 5) { state.high_water = false; } |
| 58 | + |
| 59 | + // Rapid drop |
| 60 | + if (state.recentReadings.length >= 3) { |
| 61 | + const oneHourAgo = now - 3600_000; |
| 62 | + const hourReadings = state.recentReadings.filter(r => r.timestamp > oneHourAgo); |
| 63 | + if (hourReadings.length >= 2) { |
| 64 | + const drop = hourReadings[0].pct - hourReadings[hourReadings.length - 1].pct; |
| 65 | + if (drop >= 20 && !state.rapid_drop) { |
| 66 | + state.rapid_drop = true; |
| 67 | + await createAlert(device, site, 'rapid_drop', `${device.name} dropped ${Math.round(drop)}% in the last hour — possible leak`, 'warning'); |
| 68 | + } else if (drop < 10) { state.rapid_drop = false; } |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + // Rapid rise |
| 73 | + if (state.recentReadings.length >= 2) { |
| 74 | + const thirtyMinAgo = now - 1800_000; |
| 75 | + const recentR = state.recentReadings.filter(r => r.timestamp > thirtyMinAgo); |
| 76 | + if (recentR.length >= 2) { |
| 77 | + const rise = recentR[recentR.length - 1].pct - recentR[0].pct; |
| 78 | + if (rise >= 30) { |
| 79 | + await createAlert(device, site, 'rapid_rise', `${device.name} filling rapidly: +${Math.round(rise)}% in ${Math.round((now - recentR[0].timestamp) / 60000)}min`, 'info'); |
| 80 | + state.recentReadings = recentR.slice(-2); |
| 81 | + } |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + // Battery critical |
| 86 | + if (bat != null && bat <= 5 && !state.battery_critical) { |
| 87 | + state.battery_critical = true; |
| 88 | + await createAlert(device, site, 'battery_critical', `${device.name} battery critically low: ${Math.round(bat)}%`, 'critical'); |
| 89 | + } else if (bat > 15) { state.battery_critical = false; } |
| 90 | + |
| 91 | + // Low battery |
| 92 | + if (bat != null && bat > 5 && bat <= 15 && !state.low_battery) { |
| 93 | + state.low_battery = true; |
| 94 | + await createAlert(device, site, 'low_battery', `${device.name} battery low: ${Math.round(bat)}%`, 'warning'); |
| 95 | + } else if (bat > 25) { |
| 96 | + if (state.low_battery) { |
| 97 | + state.low_battery = false; |
| 98 | + await createAlert(device, site, 'battery_ok', `${device.name} battery recovered to ${Math.round(bat)}%`, 'info'); |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + // Weak signal |
| 103 | + if (rssi != null && rssi < -100 && !state.weak_signal) { |
| 104 | + state.weak_signal = true; |
| 105 | + await createAlert(device, site, 'weak_signal', `${device.name} LoRa signal very weak: ${rssi} dBm`, 'warning'); |
| 106 | + } else if (rssi != null && rssi > -90) { state.weak_signal = false; } |
| 107 | + |
| 108 | + // Online recovery |
| 109 | + if (state.wasOffline && device.state === 'online') { |
| 110 | + state.wasOffline = false; |
| 111 | + state.device_offline = false; |
| 112 | + state.device_stale = false; |
| 113 | + await createAlert(device, site, 'device_online', `${device.name} is back online`, 'info'); |
| 114 | + } |
| 115 | + |
| 116 | + state.lastOnlineTime = now; |
| 117 | +} |
| 118 | + |
| 119 | +export async function checkDeviceTimeouts() { |
| 120 | + const allDevices = await stmts.getAllDevicesWithSites(); |
| 121 | + const now = Date.now(); |
| 122 | + |
| 123 | + for (const device of allDevices) { |
| 124 | + if (!device.last_seen) continue; |
| 125 | + const state = getState(device.id); |
| 126 | + const lastSeen = new Date(device.last_seen).getTime(); |
| 127 | + const ageSec = (now - lastSeen) / 1000; |
| 128 | + const site = { id: device.site_id, name: device.site_name, user_id: device.user_id }; |
| 129 | + |
| 130 | + if (ageSec > 600 && ageSec <= 900 && !state.device_stale) { |
| 131 | + state.device_stale = true; |
| 132 | + await db.run('UPDATE devices SET state = $1 WHERE id = $2', 'stale', device.id); |
| 133 | + await createAlert(device, site, 'device_stale', `${device.name} hasn't reported in ${Math.round(ageSec / 60)} minutes`, 'info'); |
| 134 | + } |
| 135 | + |
| 136 | + if (ageSec > 900 && !state.device_offline) { |
| 137 | + state.device_offline = true; |
| 138 | + state.wasOffline = true; |
| 139 | + await db.run('UPDATE devices SET state = $1 WHERE id = $2', 'offline', device.id); |
| 140 | + await createAlert(device, site, 'device_offline', `${device.name} is offline — no data for ${Math.round(ageSec / 60)} minutes`, 'critical'); |
| 141 | + } |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +const ESCALATION = { |
| 146 | + critical: [0, 60, 240, 720, 1440], |
| 147 | + warning: [0, 240, 1440], |
| 148 | + info: [0], |
| 149 | +}; |
| 150 | + |
| 151 | +async function createAlert(device, site, type, message, severity = 'info') { |
| 152 | + const intervals = ESCALATION[severity] || ESCALATION.info; |
| 153 | + |
| 154 | + const lastAcked = await db.get( |
| 155 | + 'SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 1 ORDER BY created_at DESC LIMIT 1', |
| 156 | + device.id, type |
| 157 | + ); |
| 158 | + |
| 159 | + if (lastAcked) { |
| 160 | + const unackedSinceLast = await db.get( |
| 161 | + 'SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0 AND created_at > (SELECT created_at FROM alerts WHERE id = $3)', |
| 162 | + device.id, type, lastAcked.id |
| 163 | + ); |
| 164 | + if (unackedSinceLast) { |
| 165 | + const step = (await db.get( |
| 166 | + 'SELECT COUNT(*) as c FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0 AND created_at > (SELECT created_at FROM alerts WHERE id = $3)', |
| 167 | + device.id, type, lastAcked.id |
| 168 | + )).c; |
| 169 | + if (step >= intervals.length) return; |
| 170 | + const nextInterval = intervals[Math.min(step, intervals.length - 1)]; |
| 171 | + const tooSoon = await db.get( |
| 172 | + `SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - ($3 || ' minutes')::interval`, |
| 173 | + device.id, type, nextInterval || 1440 |
| 174 | + ); |
| 175 | + if (tooSoon) return; |
| 176 | + } |
| 177 | + } else { |
| 178 | + const lastAlert = await db.get( |
| 179 | + 'SELECT id, created_at FROM alerts WHERE device_id = $1 AND type = $2 ORDER BY created_at DESC LIMIT 1', |
| 180 | + device.id, type |
| 181 | + ); |
| 182 | + if (lastAlert) { |
| 183 | + const step = (await db.get( |
| 184 | + 'SELECT COUNT(*) as c FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0', |
| 185 | + device.id, type |
| 186 | + )).c; |
| 187 | + if (step >= intervals.length) { |
| 188 | + const tooSoon = await db.get( |
| 189 | + `SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - interval '1440 minutes'`, |
| 190 | + device.id, type |
| 191 | + ); |
| 192 | + if (tooSoon) return; |
| 193 | + } else { |
| 194 | + const nextInterval = intervals[Math.min(step, intervals.length - 1)]; |
| 195 | + const tooSoon = await db.get( |
| 196 | + `SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - ($3 || ' minutes')::interval`, |
| 197 | + device.id, type, Math.max(nextInterval, 1) |
| 198 | + ); |
| 199 | + if (tooSoon) return; |
| 200 | + } |
| 201 | + } |
| 202 | + } |
| 203 | + |
| 204 | + await stmts.insertAlert(device.id, type, message); |
| 205 | + |
| 206 | + broadcastToUser(site.user_id, { |
| 207 | + type: 'alert', |
| 208 | + alert: { device_name: device.name, site_name: site.name, type, message, severity } |
| 209 | + }); |
| 210 | + |
| 211 | + if ((severity === 'critical' || severity === 'warning') && VAPID_PUBLIC) { |
| 212 | + const subs = await stmts.getPushSubs(device.id); |
| 213 | + if (subs.length === 0) return; |
| 214 | + const payload = JSON.stringify({ |
| 215 | + title: severity === 'critical' ? '\u{1F6A8} TankSync Alert' : '\u{26A0}\u{FE0F} TankSync Warning', |
| 216 | + body: message, icon: '/icon-192.png', badge: '/icon-192.png', |
| 217 | + tag: `${type}-${device.id}`, renotify: severity === 'critical', |
| 218 | + data: { type, deviceId: device.id, severity, url: `/tank/${device.id}` } |
| 219 | + }); |
| 220 | + for (const sub of subs) { |
| 221 | + sendPush(sub, payload).then(() => { |
| 222 | + console.log(`[Push] Sent to sub ${sub.id} (${type})`); |
| 223 | + }).catch(async (err) => { |
| 224 | + if ([403, 404, 410].includes(err.statusCode)) { |
| 225 | + await db.run('DELETE FROM push_subs WHERE id = $1', sub.id); |
| 226 | + } else { |
| 227 | + console.error(`[Push] Failed sub ${sub.id}:`, err.statusCode || err.message); |
| 228 | + } |
| 229 | + }); |
| 230 | + } |
| 231 | + } |
| 232 | +} |
| 233 | + |
| 234 | +export { checkDeviceTimeouts as runTimeoutChecks }; |
0 commit comments