import { randomUUID } from "node:crypto"; import { getPool } from "../db/pool"; function nowIso() { return new Date().toISOString(); } function mapHeartbeat(row) { return { id: row.id, device_id: row.device_id, timestamp: row.timestamp, received_at: row.received_at, firmware_version: row.firmware_version || undefined, network_strength: row.network_strength, battery_level: row.battery_level, state: row.state || undefined }; } export async function createDeviceHeartbeat(payload) { const now = nowIso(); const id = randomUUID(); const { rows } = await getPool().query(`INSERT INTO device_heartbeats ( id, device_id, timestamp, received_at, firmware_version, network_strength, battery_level, state, payload_json ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, [ id, payload.device_id, payload.timestamp, now, payload.firmware_version, payload.network_strength, payload.battery_level, payload.state, payload.payload_json || {} ]); return mapHeartbeat(rows[0]); } export async function getLatestHeartbeatByDeviceId(deviceId) { const { rows } = await getPool().query(`SELECT * FROM device_heartbeats WHERE device_id = $1 ORDER BY received_at DESC LIMIT 1`, [deviceId]); return rows[0] ? mapHeartbeat(rows[0]) : null; } export async function getHeartbeatCountForDeviceLastHours(deviceId, hours = 24) { const { rows } = await getPool().query(`SELECT COUNT(*)::INT AS cnt FROM device_heartbeats WHERE device_id = $1 AND (NOW() AT TIME ZONE 'utc' - timestamp) <= ($2 || ' hours')::interval`, [deviceId, hours]); return Number(rows[0]?.cnt || 0); } export async function listHeartbeats(filter) { const clauses = []; const params = []; let i = 1; if (filter?.device_id) { clauses.push(`device_id = $${i++}`); params.push(filter.device_id); } if (filter?.state) { clauses.push(`state = $${i++}`); params.push(filter.state); } if (filter?.from) { clauses.push(`timestamp >= $${i++}`); params.push(filter.from); } if (filter?.to) { clauses.push(`timestamp <= $${i++}`); params.push(filter.to); } const whereSql = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""; const limitSql = filter?.limit ? `LIMIT ${Number(filter.limit)}` : ""; const { rows } = await getPool().query(`SELECT * FROM device_heartbeats ${whereSql} ORDER BY received_at DESC ${limitSql}`, params); return rows.map(mapHeartbeat); } export function deriveDeviceStatus(input) { const now = Date.now(); const lastSeen = Date.parse(input?.last_seen_at || ""); if (!Number.isFinite(lastSeen)) { return "offline"; } const ageSeconds = (now - lastSeen) / 1000; if (ageSeconds > 900) { return "offline"; } if (ageSeconds > 90) { return "stale"; } if ((typeof input?.network_strength === "number" && input.network_strength < 40) || (typeof input?.battery_level === "number" && input.battery_level < 20)) { return "degraded"; } return "online"; } export function deriveDeviceHealthSummary(input) { const now = Date.now(); const lastSeen = Date.parse(input?.last_seen_at || ""); const reasons = []; if (!Number.isFinite(lastSeen)) { return { status: "offline", score: 0, age_seconds: null, reasons: ["no_heartbeat"] }; } const ageSeconds = Math.max(0, Math.floor((now - lastSeen) / 1000)); const networkStrength = typeof input?.network_strength === "number" ? input.network_strength : null; const batteryLevel = typeof input?.battery_level === "number" ? input.battery_level : null; if (ageSeconds > 900) { reasons.push("offline_threshold_exceeded"); } else if (ageSeconds > 90) { reasons.push("stale_threshold_exceeded"); } if (typeof networkStrength === "number" && networkStrength < 40) { reasons.push("low_signal"); } if (typeof batteryLevel === "number" && batteryLevel < 20) { reasons.push("low_battery"); } let status = "online"; if (reasons.includes("offline_threshold_exceeded")) { status = "offline"; } else if (reasons.includes("stale_threshold_exceeded")) { status = "stale"; } else if (reasons.includes("low_signal") || reasons.includes("low_battery")) { status = "degraded"; } let score = 100; if (ageSeconds > 900) { score -= 80; } else if (ageSeconds > 90) { score -= 35; } else if (ageSeconds > 60) { score -= 10; } if (typeof networkStrength === "number") { score -= Math.max(0, 40 - networkStrength); } if (typeof batteryLevel === "number") { score -= Math.max(0, 20 - batteryLevel); } return { status, score: Math.max(0, Math.min(100, Math.round(score))), age_seconds: ageSeconds, reasons }; }