This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import {
AlarmSocket,
DEFAULT_RECONNECT_POLICY,
type AlarmFrame,
type SocketState,
} from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, type AlarmStatus } from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
export interface UseAlarmSocketOptions {
resolved: ResolvedConnection | null;
alertId: string | null;
subscribeToken: string | null;
/** Poll cadence while the app is foregrounded (spec: 5s). */
foregroundPollMs?: number;
/** Poll cadence while backgrounded (spec: 30s). */
backgroundPollMs?: number;
onTrigger: (frame: AlarmFrame) => void;
onDisarm: (frame: AlarmFrame) => void;
enabled?: boolean;
}
export interface UseAlarmSocketResult {
socketState: SocketState;
delivery: LiveDelivery;
attempts: number;
lastStatus: AlarmStatus | null;
/** Force an immediate status read (used by pull-to-refresh and Settings). */
refresh: () => Promise<void>;
}
/**
* Owns the live-alarm transport for one subscribed alert.
*
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
* Settings/health screen can say so out loud instead of pretending.
*/
export function useAlarmSocket({
resolved,
alertId,
subscribeToken,
foregroundPollMs = 5000,
backgroundPollMs = 30000,
onTrigger,
onDisarm,
enabled = true,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0);
const [lastStatus, setLastStatus] = useState<AlarmStatus | null>(null);
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
const socketRef = useRef<AlarmSocket | null>(null);
const triggerRef = useRef(onTrigger);
const disarmRef = useRef(onDisarm);
triggerRef.current = onTrigger;
disarmRef.current = onDisarm;
const url = useMemo(
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
[resolved, alertId, subscribeToken],
);
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
const refresh = useCallback(async () => {
if (!alertId) return;
try {
setLastStatus(await fetchAlarmStatus(alertId));
} catch {
// A failed status read is not fatal — the socket is the primary path and
// the next poll retries.
}
}, [alertId]);
// AppState awareness: while backgrounded the JS socket is unreliable, so the
// poll cadence relaxes to the background value and the socket is left to the
// native foreground service (which holds its own connection).
useEffect(() => {
const sub = AppState.addEventListener('change', next => setAppState(next));
return () => sub.remove();
}, []);
useEffect(() => {
if (!enabled || !url || !alertId || !subscribeToken) {
setSocketState('idle');
setAttempts(0);
return;
}
const socket = new AlarmSocket({
url,
policy: DEFAULT_RECONNECT_POLICY,
onStateChange: (state, nextAttempts) => {
setSocketState(state);
setAttempts(nextAttempts);
},
onAlarm: frame => {
if (frame.action === 'trigger') triggerRef.current(frame);
else disarmRef.current(frame);
},
});
socketRef.current = socket;
socket.connect();
return () => {
socket.close();
socketRef.current = null;
};
}, [enabled, url, alertId, subscribeToken]);
// Polling is always on while armed: it is the fallback when the socket is not
// available (maestro mode) and the safety net when it is momentarily down.
useEffect(() => {
if (!enabled || !alertId) return;
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
let cancelled = false;
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId);
if (!cancelled) setLastStatus(status);
} catch {
// ignore — polling must never surface a spinner
}
};
tick();
const handle = setInterval(tick, interval);
return () => {
cancelled = true;
clearInterval(handle);
};
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
return { socketState, delivery, attempts, lastStatus, refresh };
}