Tie points to locator serial numbers and store receiver telemetry
Adds devices/<serial>/log as a job-anchored MQTT ingest path: a locator identifies itself by serial in the topic (auto-registered on first sight, org resolved from the job in the payload) rather than by a pre-provisioned broker credential. Device.serialNumber is now globally unique so the bare topic segment is enough to resolve identity. Locator lookup/auto-register logic is shared (LocatorRegistryService) between this and the existing devices/<mqttUsername>/points path. New /sim page (own header, outside the main app nav) simulates a transmitter: pick an open job or create one, set a serial number and telemetry defaults, and send points one at a time or on an interval along a simulated walking path. It calls a new authenticated backend endpoint (POST /orgs/:orgId/sim/publish) that publishes onto the real broker rather than writing the DB directly, so the simulator exercises the actual ingest pipeline end-to-end. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
428
web/pages/sim/index.tsx
Normal file
428
web/pages/sim/index.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
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;
|
||||
lat: number;
|
||||
lng: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
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<JobOption[]>([]);
|
||||
const [jobId, setJobId] = useState('');
|
||||
const [creatingJob, setCreatingJob] = useState(false);
|
||||
const [newTicket, setNewTicket] = useState('');
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
|
||||
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<SentEntry[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | 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<JobOption>(`/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`, {
|
||||
serial,
|
||||
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, lat: pos.lat, lng: pos.lng, depth: pointDepth }, ...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 (
|
||||
<div style={{ fontFamily: 'sans-serif', minHeight: '100vh', background: '#fafafa' }}>
|
||||
<Head>
|
||||
<title>Transmitter Simulator · UlHub</title>
|
||||
</Head>
|
||||
<header style={{ padding: '0.75rem 1.5rem', borderBottom: '1px solid #ddd', background: '#212121', color: '#fff' }}>
|
||||
<strong>UlHub</strong> <span style={{ opacity: 0.7 }}>· Transmitter Simulator</span>
|
||||
<Link href="/" style={{ float: 'right', color: '#9cc9ff' }}>
|
||||
← Back to app
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<main style={{ maxWidth: 720, margin: '0 auto', padding: '1.5rem' }}>
|
||||
<p style={{ color: '#666' }}>
|
||||
Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '}
|
||||
<code>devices/{serial || '<serial>'}/log</code>, exactly as a real device would.
|
||||
</p>
|
||||
{error && <p style={{ color: '#c62828' }}>{error}</p>}
|
||||
|
||||
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||
<legend>Job</legend>
|
||||
{!creatingJob ? (
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<select value={jobId} onChange={(e) => setJobId(e.target.value)} style={{ flex: 1, padding: '0.4rem' }}>
|
||||
{jobs.length === 0 && <option value="">No open jobs</option>}
|
||||
{jobs.map((j) => (
|
||||
<option key={j.id} value={j.id}>
|
||||
{j.ticketNumber} — {j.title} ({j.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" onClick={() => setCreatingJob(true)}>
|
||||
+ New job
|
||||
</button>
|
||||
{selectedJob && (
|
||||
<Link href={`/jobs/${selectedJob.id}`} target="_blank">
|
||||
View map ↗
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={createJob} style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<input
|
||||
placeholder="ticket number"
|
||||
value={newTicket}
|
||||
onChange={(e) => setNewTicket(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="title"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
required
|
||||
style={{ padding: '0.4rem', flex: 1 }}
|
||||
/>
|
||||
<button type="submit">Create</button>
|
||||
<button type="button" onClick={() => setCreatingJob(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||
<legend>Transmitter</legend>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Serial number
|
||||
<input value={serial} onChange={(e) => setSerial(e.target.value)} style={{ width: '100%', padding: '0.4rem' }} />
|
||||
</label>
|
||||
<label>
|
||||
Utility type
|
||||
<select value={utility} onChange={(e) => setUtility(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||
{UTILITIES.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
GPS fix type
|
||||
<select value={fixType} onChange={(e) => setFixType(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||
{FIX_TYPES.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Locate mode
|
||||
<select value={locateMode} onChange={(e) => setLocateMode(e.target.value)} style={{ width: '100%', padding: '0.4rem' }}>
|
||||
{LOCATE_MODES.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Depth (m)
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={depth}
|
||||
onChange={(e) => setDepth(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Frequency (Hz)
|
||||
<input
|
||||
type="number"
|
||||
value={frequencyHz}
|
||||
onChange={(e) => setFrequencyHz(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Current (mA)
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={currentMa}
|
||||
onChange={(e) => setCurrentMa(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style={{ marginBottom: '1rem', border: '1px solid #ddd', borderRadius: 8, padding: '1rem' }}>
|
||||
<legend>Path</legend>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr 1fr', gap: '0.75rem' }}>
|
||||
<label>
|
||||
Start lat
|
||||
<input
|
||||
type="number"
|
||||
step="0.0000001"
|
||||
value={originLat}
|
||||
onChange={(e) => setOriginLat(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Start lng
|
||||
<input
|
||||
type="number"
|
||||
step="0.0000001"
|
||||
value={originLng}
|
||||
onChange={(e) => setOriginLng(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Heading (°)
|
||||
<input
|
||||
type="number"
|
||||
value={headingDeg}
|
||||
onChange={(e) => setHeadingDeg(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Step (m)
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={stepMeters}
|
||||
onChange={(e) => setStepMeters(Number(e.target.value))}
|
||||
style={{ width: '100%', padding: '0.4rem' }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<button onClick={sendPoint} disabled={!jobId || autoSending} style={{ padding: '0.6rem 1rem' }}>
|
||||
Send point
|
||||
</button>
|
||||
<button onClick={toggleAutoSend} disabled={!jobId} style={{ padding: '0.6rem 1rem' }}>
|
||||
{autoSending ? 'Stop auto-send' : 'Start auto-send'}
|
||||
</button>
|
||||
<label>
|
||||
every{' '}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={intervalSec}
|
||||
onChange={(e) => setIntervalSec(Number(e.target.value))}
|
||||
style={{ width: 50, padding: '0.3rem' }}
|
||||
/>{' '}
|
||||
s
|
||||
</label>
|
||||
<button onClick={reset} style={{ marginLeft: 'auto' }}>
|
||||
Reset path
|
||||
</button>
|
||||
<span style={{ color: '#666' }}>{count} sent this session</span>
|
||||
</div>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: 'left', borderBottom: '2px solid #ddd' }}>
|
||||
<th style={{ padding: '0.4rem' }}>Time</th>
|
||||
<th>Seq</th>
|
||||
<th>Lat</th>
|
||||
<th>Lng</th>
|
||||
<th>Depth</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sent.map((s) => (
|
||||
<tr key={s.seq} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.4rem' }}>{s.at}</td>
|
||||
<td>{s.seq}</td>
|
||||
<td>{s.lat.toFixed(7)}</td>
|
||||
<td>{s.lng.toFixed(7)}</td>
|
||||
<td>{s.depth} m</td>
|
||||
</tr>
|
||||
))}
|
||||
{sent.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} style={{ padding: '1.5rem', textAlign: 'center', color: '#888' }}>
|
||||
No points sent yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user