Services: ws.service, api.service, simulation.service, rssi.service (android+ios) Stores: poseStore, settingsStore, matStore (Zustand) Types: sensing, mat, api, navigation Hooks: usePoseStream, useRssiScanner, useServerReachability Theme: colors, typography, spacing, ThemeContext Navigation: MainTabs (5 tabs), RootNavigator, types Components: GaugeArc, SparklineChart, OccupancyGrid, StatusDot, ConnectionBanner, SignalBar, +more Utils: ringBuffer, colorMap, formatters, urlValidator Verified: tsc 0 errors, jest passes
165 lines
4.4 KiB
TypeScript
165 lines
4.4 KiB
TypeScript
import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation';
|
|
import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket';
|
|
import { usePoseStore } from '@/stores/poseStore';
|
|
import { generateSimulatedData } from '@/services/simulation.service';
|
|
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
|
|
|
|
type FrameListener = (frame: SensingFrame) => void;
|
|
|
|
class WsService {
|
|
private ws: WebSocket | null = null;
|
|
private listeners = new Set<FrameListener>();
|
|
private reconnectAttempt = 0;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private simulationTimer: ReturnType<typeof setInterval> | null = null;
|
|
private targetUrl = '';
|
|
private active = false;
|
|
private status: ConnectionStatus = 'disconnected';
|
|
|
|
connect(url: string): void {
|
|
this.targetUrl = url;
|
|
this.active = true;
|
|
this.reconnectAttempt = 0;
|
|
|
|
if (!url) {
|
|
this.handleStatusChange('simulated');
|
|
this.startSimulation();
|
|
return;
|
|
}
|
|
|
|
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
this.handleStatusChange('connecting');
|
|
|
|
try {
|
|
const endpoint = this.buildWsUrl(url);
|
|
const socket = new WebSocket(endpoint);
|
|
this.ws = socket;
|
|
|
|
socket.onopen = () => {
|
|
this.reconnectAttempt = 0;
|
|
this.stopSimulation();
|
|
this.handleStatusChange('connected');
|
|
};
|
|
|
|
socket.onmessage = (evt) => {
|
|
try {
|
|
const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data);
|
|
const frame = JSON.parse(raw) as SensingFrame;
|
|
this.listeners.forEach((listener) => listener(frame));
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
// handled by onclose
|
|
};
|
|
|
|
socket.onclose = (evt) => {
|
|
this.ws = null;
|
|
if (!this.active) {
|
|
this.handleStatusChange('disconnected');
|
|
return;
|
|
}
|
|
if (evt.code === 1000) {
|
|
this.handleStatusChange('disconnected');
|
|
return;
|
|
}
|
|
this.scheduleReconnect();
|
|
};
|
|
} catch {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
disconnect(): void {
|
|
this.active = false;
|
|
this.clearReconnectTimer();
|
|
this.stopSimulation();
|
|
if (this.ws) {
|
|
this.ws.close(1000, 'client disconnect');
|
|
this.ws = null;
|
|
}
|
|
this.handleStatusChange('disconnected');
|
|
}
|
|
|
|
subscribe(listener: FrameListener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
getStatus(): ConnectionStatus {
|
|
return this.status;
|
|
}
|
|
|
|
private buildWsUrl(rawUrl: string): string {
|
|
const parsed = new URL(rawUrl);
|
|
const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:';
|
|
return `${proto}//${parsed.host}${WS_PATH}`;
|
|
}
|
|
|
|
private handleStatusChange(status: ConnectionStatus): void {
|
|
if (status === this.status) {
|
|
return;
|
|
}
|
|
this.status = status;
|
|
usePoseStore.getState().setConnectionStatus(status);
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
if (!this.active) {
|
|
this.handleStatusChange('disconnected');
|
|
return;
|
|
}
|
|
|
|
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
|
this.handleStatusChange('simulated');
|
|
this.startSimulation();
|
|
return;
|
|
}
|
|
|
|
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
|
this.reconnectAttempt += 1;
|
|
this.clearReconnectTimer();
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.connect(this.targetUrl);
|
|
}, delay);
|
|
this.startSimulation();
|
|
}
|
|
|
|
private startSimulation(): void {
|
|
if (this.simulationTimer) {
|
|
return;
|
|
}
|
|
this.simulationTimer = setInterval(() => {
|
|
this.handleStatusChange('simulated');
|
|
const frame = generateSimulatedData();
|
|
this.listeners.forEach((listener) => {
|
|
listener(frame);
|
|
});
|
|
}, SIMULATION_TICK_INTERVAL_MS);
|
|
}
|
|
|
|
private stopSimulation(): void {
|
|
if (this.simulationTimer) {
|
|
clearInterval(this.simulationTimer);
|
|
this.simulationTimer = null;
|
|
}
|
|
}
|
|
|
|
private clearReconnectTimer(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const wsService = new WsService();
|