feat: add remote device management and pairing functionality
- Implemented device pairing with a four-digit code and TTL for remote settings. - Introduced automatic checks for device status and configuration validation. - Developed UI components for device setup and management, including error handling and retry options. - Created a device bridge for secure communication between the device and the server. - Added tests for managed state validation and device pairing logic. - Established a local end-to-end fixture for testing device service and API interactions.
This commit is contained in:
64
src/useDeviceControl.ts
Normal file
64
src/useDeviceControl.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { Config, Settings } from './types';
|
||||
import { isManagedState, MANAGED_KEY } from './lib/device.mjs';
|
||||
export type ManagedState = { state: 'paired'; enabled: true; adminUrl: string; config: Config; device: { id: string; name: string; cityId: number; revision: number; settings: Settings } };
|
||||
type Control = { state: string; enabled?: boolean; code?: string; expiresAt?: string; adminUrl?: string };
|
||||
function cached(): ManagedState | null { try { const value = JSON.parse(localStorage.getItem(MANAGED_KEY) || 'null'); return isManagedState(value) ? value : null; } catch { return null; } }
|
||||
const info = () => ({ width: innerWidth, height: innerHeight, version: 'board-0.1.0' });
|
||||
export function useDeviceControl() {
|
||||
const [managed, setManaged] = useState<ManagedState | null>(cached), [control, setControl] = useState<Control>({ state: 'loading' });
|
||||
const [failed, setFailed] = useState(false), [retry, setRetry] = useState(0);
|
||||
const applied = useRef(0), current = useRef(managed);
|
||||
current.current = managed;
|
||||
const onApplied = useCallback((revision: number) => {
|
||||
applied.current = revision;
|
||||
try { localStorage.setItem(MANAGED_KEY, JSON.stringify(current.current)); } catch { /* Keep the current screen working even if persistent storage is unavailable. */ }
|
||||
void fetch('/api/device/heartbeat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...info(), appliedRevision: revision }) }).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
let stopped = false, timer: ReturnType<typeof setTimeout>, controller: AbortController | undefined, lastHeartbeat = 0;
|
||||
let previousState = '', halted = false, running = false, firstRequest = true;
|
||||
async function call(path: string, body?: object) {
|
||||
controller = new AbortController(); const timeout = setTimeout(() => controller?.abort(), 12000);
|
||||
try {
|
||||
const response = await fetch(`/api/device/${path}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal: controller.signal, ...(body ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}) });
|
||||
const value = await response.json(); if (!response.ok) throw new Error(value.error || 'devices_unavailable'); return value;
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
async function load() {
|
||||
if (stopped || halted || running) return;
|
||||
running = true;
|
||||
let delay = 15000;
|
||||
try {
|
||||
let value = await call(firstRequest && retry > 0 ? 'pair' : 'status', firstRequest && retry > 0 ? info() : undefined);
|
||||
if (stopped) return;
|
||||
firstRequest = false;
|
||||
if (value.enabled === false) { halted = true; if (!stopped) { setControl({ state: 'standalone', enabled: false }); setManaged(null); setFailed(false); } return; }
|
||||
if (value.state === 'unpaired' && previousState === 'pending') throw new Error('storage_unavailable');
|
||||
if (['unpaired', 'expired'].includes(value.state)) { if (!stopped) { setManaged(null); applied.current = 0; localStorage.removeItem(MANAGED_KEY); } value = await call('pair', info()); }
|
||||
if (value.state === 'paired' && !value.config) value = await call('status');
|
||||
if (value.state === 'paired') {
|
||||
if (!isManagedState(value)) throw new Error('invalid_settings');
|
||||
if (!stopped) { setManaged(previous => previous && previous.device.id === value.device.id && previous.device.revision === value.device.revision ? previous : value); setControl(value); setFailed(false); }
|
||||
if (Date.now() - lastHeartbeat >= 30000) { await call('heartbeat', { ...info(), appliedRevision: current.current?.device.id === value.device.id ? applied.current : 0 }); lastHeartbeat = Date.now(); }
|
||||
} else if (value.state === 'pending') {
|
||||
if (!/^\d{4}$/.test(value.code) || !Number.isFinite(Date.parse(value.expiresAt))) throw new Error('devices_unavailable');
|
||||
delay = 3000; if (!stopped) { setControl(value); setManaged(null); setFailed(false); }
|
||||
} else throw new Error('devices_unavailable');
|
||||
previousState = value.state;
|
||||
} catch (error) {
|
||||
if (!stopped) {
|
||||
const code = error instanceof Error ? error.message : 'devices_unavailable';
|
||||
setFailed(true);
|
||||
if (['device_revoked', 'device_unpaired'].includes(code)) { halted = true; setFailed(false); setManaged(null); applied.current = 0; try { localStorage.removeItem(MANAGED_KEY); } catch { /* No stored copy. */ } setControl({ state: 'revoked' }); return; }
|
||||
if (code === 'storage_unavailable') halted = true;
|
||||
if (!current.current) setControl(previous => ({ ...previous, state: code === 'storage_unavailable' ? 'storage-error' : previous.code ? 'pending' : 'error' }));
|
||||
delay = 30000;
|
||||
}
|
||||
} finally { running = false; if (!stopped && !halted) timer = setTimeout(load, delay); }
|
||||
}
|
||||
void load(); const online = () => { clearTimeout(timer); void load(); }; window.addEventListener('online', online);
|
||||
return () => { stopped = true; clearTimeout(timer); controller?.abort(); window.removeEventListener('online', online); };
|
||||
}, [retry]);
|
||||
return { managed, control, failed, onApplied, retry: () => setRetry(value => value + 1) };
|
||||
}
|
||||
Reference in New Issue
Block a user