Files
ulweb/web/lib/use-job-stream.ts
ulhub 8c5de40c5d Add live transmitter position (status) alongside logged points
devices/<serial>/log messages now carry a "type": "log" or "status".
"log" persists a LocatePoint as before; "status" carries the same
position + telemetry shape but is broadcast live over the job's
realtime channel without touching locate_points — it's a current-
position update, not a recorded point.

The job map renders the latest status as a blue "you are here" marker
(with a soft accuracy-radius halo) that moves in place as new updates
arrive, separate from the colored polyline of logged points. Clicking
it shows the same detail readout as a logged point.

The simulator gained a message-type toggle (Log point / Status
update) so both paths can be exercised from /sim.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 14:52:53 +00:00

73 lines
1.9 KiB
TypeScript

import { useEffect, useRef } from 'react';
import type { LiveStatus, MapPoint } from '../components/map/types';
// Subscribes to live points + live position status for a job over the
// backend WebSocket. Reconnects with capped exponential backoff; resubscribes
// on reconnect.
export function useJobStream(
jobId: string | null,
onPoints: (points: MapPoint[]) => void,
onStatus?: (status: LiveStatus) => void,
) {
const pointsRef = useRef(onPoints);
pointsRef.current = onPoints;
const statusRef = useRef(onStatus);
statusRef.current = onStatus;
useEffect(() => {
if (!jobId) {
return;
}
let socket: WebSocket | null = null;
let closed = false;
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const connect = () => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
socket.onopen = () => {
attempt = 0;
socket?.send(JSON.stringify({ type: 'subscribe', channel: `job:${jobId}` }));
};
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.jobId !== jobId) {
return;
}
if (msg.type === 'points') {
pointsRef.current(msg.points);
} else if (msg.type === 'status') {
statusRef.current?.(msg as LiveStatus);
}
} catch {
// ignore malformed frames
}
};
socket.onclose = () => {
if (closed) {
return;
}
attempt += 1;
const delay = Math.min(1000 * 2 ** attempt, 15000);
timer = setTimeout(connect, delay);
};
};
connect();
return () => {
closed = true;
if (timer) {
clearTimeout(timer);
}
socket?.close();
};
}, [jobId]);
}