103 lines
3.2 KiB
JavaScript
103 lines
3.2 KiB
JavaScript
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";
|
|
}
|