/** * What can be said about a panel from its last visit. * * In BYOS nothing can be pushed and nothing can be asked: the device wakes, * fetches, and sleeps. Everything below is therefore inference from one * timestamp and one interval — which is exactly why it is worth writing down * and testing, rather than improvising in a template. */ export type DeviceSnapshot = { lastSeenAt: Date | null; lastRefreshRate: number | null; batteryVoltage: number | null; percentCharged: number | null; rssi: number | null; }; export type DeviceHealth = { /** Never contacted us at all. */ neverSeen: boolean; /** When it is due back, from its last visit plus its own interval. */ nextWakeAt: Date | null; /** Seconds until then; negative once it is late. */ secondsUntilWake: number | null; /** * Late by enough that something is probably wrong rather than merely * imprecise. */ isOverdue: boolean; /** 0-100 when the device reports it, else derived from the voltage. */ batteryPercent: number | null; signal: 'good' | 'fair' | 'poor' | null; }; /** * A panel is only called overdue once it has missed its slot by half again as * long as its own interval. Firmware wake-ups drift by seconds, and an alert * that cries wolf on every cycle is an alert nobody reads. */ export const OVERDUE_FACTOR = 1.5; /** Li-ion, loaded: below this the panel has days rather than weeks left. */ const EMPTY_VOLTS = 3.3; const FULL_VOLTS = 4.1; export function deviceHealth(device: DeviceSnapshot, now: Date): DeviceHealth { if (!device.lastSeenAt) { return { neverSeen: true, nextWakeAt: null, secondsUntilWake: null, isOverdue: false, batteryPercent: batteryPercent(device), signal: signalQuality(device.rssi), }; } const interval = device.lastRefreshRate && device.lastRefreshRate > 0 ? device.lastRefreshRate : null; const nextWakeAt = interval ? new Date(device.lastSeenAt.getTime() + interval * 1000) : null; const secondsUntilWake = nextWakeAt ? Math.round((nextWakeAt.getTime() - now.getTime()) / 1000) : null; const overdueAfter = interval ? device.lastSeenAt.getTime() + interval * OVERDUE_FACTOR * 1000 : null; return { neverSeen: false, nextWakeAt, secondsUntilWake, isOverdue: overdueAfter !== null && now.getTime() > overdueAfter, batteryPercent: batteryPercent(device), signal: signalQuality(device.rssi), }; } function batteryPercent(device: DeviceSnapshot): number | null { if (device.percentCharged !== null) { return clamp(device.percentCharged); } if (device.batteryVoltage === null) { return null; } // Rough, and labelled as such in the UI: a discharge curve is not linear, // but "about a third left" is the only thing anyone acts on. const ratio = (device.batteryVoltage - EMPTY_VOLTS) / (FULL_VOLTS - EMPTY_VOLTS); return clamp(Math.round(ratio * 100)); } function signalQuality(rssi: number | null): DeviceHealth['signal'] { if (rssi === null) { return null; } if (rssi >= -67) { return 'good'; } return rssi >= -80 ? 'fair' : 'poor'; } function clamp(value: number): number { return Math.min(100, Math.max(0, value)); } /** "dans 8 minutes", "il y a 2 heures" — the only two shapes the page needs. */ export function humaniseSeconds(seconds: number): string { const absolute = Math.abs(seconds); const value = absolute < 60 ? 'moins d’une minute' : absolute < 3600 ? `${Math.round(absolute / 60)} minute${Math.round(absolute / 60) > 1 ? 's' : ''}` : absolute < 86_400 ? `${Math.round(absolute / 3600)} heure${Math.round(absolute / 3600) > 1 ? 's' : ''}` : `${Math.round(absolute / 86_400)} jour${Math.round(absolute / 86_400) > 1 ? 's' : ''}`; return seconds >= 0 ? `dans ${value}` : `il y a ${value}`; }