init
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user