import Head from 'next/head'; import Link from 'next/link'; import { FormEvent, useEffect, useRef, useState } from 'react'; import { api } from '../../lib/api'; import { useRequireAuth } from '../../lib/auth-context'; interface JobOption { id: string; ticketNumber: string; title: string; status: string; } interface SentEntry { at: string; seq: number; type: MessageType; lat: number; lng: number; depth: number; transport: Transport; } type MessageType = 'log' | 'status'; type Transport = 'relay' | 'mqtts'; const UTILITIES = ['GAS', 'WATER', 'ELECTRIC', 'SEWER', 'TELECOM', 'CATV', 'FIBER', 'STEAM', 'UNKNOWN']; const FIX_TYPES = ['FIXED_RTK', 'FLOAT_RTK', 'DGPS', 'AUTONOMOUS', 'NONE']; const LOCATE_MODES = ['PEAK', 'NULL', 'BROAD_PEAK', 'SONDE']; const SERIAL_KEY = 'ulhub_sim_serial'; function jitter(base: number, spread: number): number { return Math.round((base + (Math.random() * 2 - 1) * spread) * 100) / 100; } // Advances a lat/lng by `distanceM` meters along `headingDeg` (0 = north, 90 = east) function step(lat: number, lng: number, headingDeg: number, distanceM: number) { const rad = (headingDeg * Math.PI) / 180; const dLat = (distanceM * Math.cos(rad)) / 111320; const dLng = (distanceM * Math.sin(rad)) / (111320 * Math.cos((lat * Math.PI) / 180)); return { lat: lat + dLat, lng: lng + dLng }; } export default function SimulatorPage() { const { user, activeOrg, loading } = useRequireAuth(); const orgId = activeOrg?.org.id ?? null; const [jobs, setJobs] = useState([]); const [jobId, setJobId] = useState(''); const [creatingJob, setCreatingJob] = useState(false); const [newTicket, setNewTicket] = useState(''); const [newTitle, setNewTitle] = useState(''); const [messageType, setMessageType] = useState('log'); const [transport, setTransport] = useState('relay'); const [serial, setSerial] = useState(''); const [utility, setUtility] = useState('GAS'); const [fixType, setFixType] = useState('FIXED_RTK'); const [locateMode, setLocateMode] = useState('PEAK'); const [depth, setDepth] = useState(1.2); const [frequencyHz, setFrequencyHz] = useState(33000); const [currentMa, setCurrentMa] = useState(45); const [originLat, setOriginLat] = useState(33.15012345); const [originLng, setOriginLng] = useState(-96.83512345); const [headingDeg, setHeadingDeg] = useState(45); const [stepMeters, setStepMeters] = useState(1.5); const [intervalSec, setIntervalSec] = useState(2); const [count, setCount] = useState(0); const [autoSending, setAutoSending] = useState(false); const [sent, setSent] = useState([]); const [error, setError] = useState(null); const timerRef = useRef | null>(null); useEffect(() => { const stored = localStorage.getItem(SERIAL_KEY); setSerial(stored || `SIM-${Math.floor(1000 + Math.random() * 9000)}`); }, []); useEffect(() => { if (!orgId) { return; } api .get<{ jobs: JobOption[] }>(`/api/orgs/${orgId}/jobs?limit=100`) .then((res) => { const openish = res.jobs.filter((j) => j.status === 'OPEN' || j.status === 'IN_PROGRESS'); setJobs(openish); if (!jobId && openish[0]) { setJobId(openish[0].id); } }) .catch((err) => setError(err.message)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [orgId]); useEffect(() => { return () => { if (timerRef.current) { clearInterval(timerRef.current); } }; }, []); // setInterval captures whatever closure exists when it's created; routing // every tick through a ref that's refreshed each render keeps it reading // current state (count, serial, path config, …) instead of stale values. const sendPointRef = useRef<() => void>(() => {}); const createJob = async (e: FormEvent) => { e.preventDefault(); if (!orgId) { return; } try { const job = await api.post(`/api/orgs/${orgId}/jobs`, { ticketNumber: newTicket, title: newTitle, }); setJobs((j) => [{ ...job }, ...j]); setJobId(job.id); setCreatingJob(false); setNewTicket(''); setNewTitle(''); } catch (err: any) { setError(err.message); } }; const sendPoint = async () => { if (!orgId || !jobId || !serial) { return; } localStorage.setItem(SERIAL_KEY, serial); const pos = step(originLat, originLng, headingDeg, stepMeters * count); const seq = count + 1; const pointDepth = Math.max(0.1, jitter(depth, 0.15)); try { await api.post(`/api/orgs/${orgId}/sim/publish`, { type: messageType, serial, transport, jobId, lat: pos.lat, lng: pos.lng, fix: fixType, hAcc: 0.014, vAcc: 0.02, sats: Math.round(jitter(22, 3)), hdop: 0.7, depth: pointDepth, freqHz: frequencyHz, currentMa: Math.max(0, jitter(currentMa, 3)), signalDb: jitter(60, 5), gainDb: 40, mode: locateMode, compassDeg: jitter(headingDeg, 4), distortionPct: Math.max(0, jitter(4, 2)), utility, seq, ts: new Date().toISOString(), }); setCount(seq); setSent((prev) => [ { at: new Date().toLocaleTimeString(), seq, type: messageType, lat: pos.lat, lng: pos.lng, depth: pointDepth, transport, }, ...prev, ].slice(0, 25), ); setError(null); } catch (err: any) { setError(err.message); setAutoSending(false); } }; sendPointRef.current = sendPoint; const toggleAutoSend = () => { if (autoSending) { if (timerRef.current) { clearInterval(timerRef.current); } setAutoSending(false); return; } setAutoSending(true); sendPointRef.current(); timerRef.current = setInterval(() => sendPointRef.current(), intervalSec * 1000); }; const reset = () => { if (timerRef.current) { clearInterval(timerRef.current); } setAutoSending(false); setCount(0); setSent([]); }; if (loading || !user) { return null; } const selectedJob = jobs.find((j) => j.id === jobId); return (
Transmitter Simulator · UlHub
UlHub · Transmitter Simulator ← Back to app

Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '} devices/{serial || ''}/log.

{error &&

{error}

}
Message type
Connection {transport === 'mqtts' && (

Requires a device with this exact serial number to already exist in this org and have a certificate issued from Devices.

)}
Job {!creatingJob ? (
{selectedJob && ( View map ↗ )}
) : (
setNewTicket(e.target.value)} required style={{ padding: '0.4rem' }} /> setNewTitle(e.target.value)} required style={{ padding: '0.4rem', flex: 1 }} />
)}
Transmitter
Path
{count} sent this session
{sent.map((s) => ( ))} {sent.length === 0 && ( )}
Time Seq Type Lat Lng Depth Via
{s.at} {s.seq} {s.type} {s.lat.toFixed(7)} {s.lng.toFixed(7)} {s.depth} m {s.transport}
No points sent yet.
); }