From e91c91e037412e02dfa83b8ce02669a303f601b6 Mon Sep 17 00:00:00 2001 From: ulhub Date: Tue, 14 Jul 2026 19:41:58 +0000 Subject: [PATCH] Tie points to locator serial numbers and store receiver telemetry Adds devices//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//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 --- .../migration.sql | 8 + backend/prisma/schema.prisma | 6 +- backend/src/app.module.ts | 2 + backend/src/devices/devices.service.ts | 7 +- backend/src/ingest/dto/mqtt-messages.dto.ts | 9 + backend/src/ingest/ingest-router.service.ts | 21 +- backend/src/ingest/ingest.module.ts | 12 +- .../src/ingest/locator-registry.service.ts | 37 ++ backend/src/ingest/log-ingest.service.ts | 74 +++ backend/src/ingest/points-ingest.service.ts | 42 +- backend/src/sim/dto/sim.dto.ts | 17 + backend/src/sim/sim.controller.ts | 19 + backend/src/sim/sim.module.ts | 11 + backend/src/sim/sim.service.ts | 25 + mosquitto/config/devices.acl | 4 +- web/pages/sim/index.tsx | 428 ++++++++++++++++++ 16 files changed, 672 insertions(+), 50 deletions(-) create mode 100644 backend/prisma/migrations/20260714163000_global_serial_unique/migration.sql create mode 100644 backend/src/ingest/locator-registry.service.ts create mode 100644 backend/src/ingest/log-ingest.service.ts create mode 100644 backend/src/sim/dto/sim.dto.ts create mode 100644 backend/src/sim/sim.controller.ts create mode 100644 backend/src/sim/sim.module.ts create mode 100644 backend/src/sim/sim.service.ts create mode 100644 web/pages/sim/index.tsx diff --git a/backend/prisma/migrations/20260714163000_global_serial_unique/migration.sql b/backend/prisma/migrations/20260714163000_global_serial_unique/migration.sql new file mode 100644 index 0000000..782778c --- /dev/null +++ b/backend/prisma/migrations/20260714163000_global_serial_unique/migration.sql @@ -0,0 +1,8 @@ +-- Locator serial numbers are globally unique (not per-org), so a bare +-- devices//log MQTT topic can identify the device without org context. + +-- DropIndex +DROP INDEX "devices_orgId_serialNumber_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "devices_serialNumber_key" ON "devices"("serialNumber"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c7fc54e..f244ffb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -132,7 +132,10 @@ model Device { id String @id @default(cuid()) orgId String name String - serialNumber String? + // Globally unique (manufacturer serials aren't org-scoped in the real world) — + // this lets a bare devices//log MQTT topic identify the device without + // any org context in the topic itself. + serialNumber String? @unique mqttUsername String? @unique isActive Boolean @default(true) lastSeenAt DateTime? @db.Timestamptz(6) @@ -142,7 +145,6 @@ model Device { org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) points LocatePoint[] - @@unique([orgId, serialNumber]) @@map("devices") } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index b533413..8ad045b 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { DevicesModule } from './devices/devices.module'; import { IngestModule } from './ingest/ingest.module'; import { RealtimeModule } from './realtime/realtime.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; +import { SimModule } from './sim/sim.module'; @Module({ imports: [ @@ -23,6 +24,7 @@ import { ApiKeysModule } from './api-keys/api-keys.module'; IngestModule, RealtimeModule, ApiKeysModule, + SimModule, ], controllers: [AppController, StatusController], providers: [AppService], diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts index 0e29f1b..9197c6f 100644 --- a/backend/src/devices/devices.service.ts +++ b/backend/src/devices/devices.service.ts @@ -27,10 +27,10 @@ export class DevicesService { } if (dto.serialNumber) { const existing = await this.prisma.device.findUnique({ - where: { orgId_serialNumber: { orgId, serialNumber: dto.serialNumber } }, + where: { serialNumber: dto.serialNumber }, }); if (existing) { - throw new ConflictException('A device with this serial number already exists in this organization'); + throw new ConflictException('A device with this serial number already exists'); } } const device = await this.prisma.device.create({ @@ -46,7 +46,8 @@ export class DevicesService { note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.', } : { - note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}".`, + logTopic: device.serialNumber ? `devices/${device.serialNumber}/log` : undefined, + note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}", or a direct-connect locator can publish single readings to devices/${device.serialNumber}/log.`, }, }; } diff --git a/backend/src/ingest/dto/mqtt-messages.dto.ts b/backend/src/ingest/dto/mqtt-messages.dto.ts index c77bcfc..58dd709 100644 --- a/backend/src/ingest/dto/mqtt-messages.dto.ts +++ b/backend/src/ingest/dto/mqtt-messages.dto.ts @@ -139,6 +139,15 @@ export class MqttPointsMessageDto { points: MqttPointDto[]; } +// Single-reading log entry for a locator that publishes directly (or is +// relayed) to devices//log — the serial comes from the topic itself, +// so identity is job-anchored rather than publisher-credential-anchored. +export class MqttLogMessageDto extends MqttPointDto { + @IsString() + @IsNotEmpty() + jobId: string; +} + export class MqttJobMessageDto { @IsString() @IsNotEmpty() diff --git a/backend/src/ingest/ingest-router.service.ts b/backend/src/ingest/ingest-router.service.ts index ac1dfe5..6b3f8da 100644 --- a/backend/src/ingest/ingest-router.service.ts +++ b/backend/src/ingest/ingest-router.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { JobsIngestService } from './jobs-ingest.service'; +import { LogIngestService } from './log-ingest.service'; import { MqttClientService } from './mqtt-client.service'; import { PointsIngestService } from './points-ingest.service'; @@ -13,6 +14,7 @@ export class IngestRouterService implements OnModuleInit { private readonly mqttClient: MqttClientService, private readonly pointsIngest: PointsIngestService, private readonly jobsIngest: JobsIngestService, + private readonly logIngest: LogIngestService, ) {} onModuleInit() { @@ -27,19 +29,26 @@ export class IngestRouterService implements OnModuleInit { // Raw log captures every message on devices/#, matched or not (audit/debug trail) await this.prisma.deviceEvent.create({ data: { topic, payload } }); - const [root, username, ...rest] = topic.split('/'); + const [root, idSegment, ...rest] = topic.split('/'); const subtopic = rest.join('/'); - if (root !== 'devices' || !username || subtopic === 'jobs/ack') { + if (root !== 'devices' || !idSegment || subtopic === 'jobs/ack') { return; // not device traffic, or our own ack echoed back } - const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } }); + // devices//log identifies the locator by serial in the topic + // itself (job in the payload supplies org) — no publisher device lookup. + if (subtopic === 'log') { + await this.logIngest.handle(idSegment, payload); + return; + } + + const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment } }); if (!device) { - this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`); + this.logger.warn(`Message from unregistered device username "${idSegment}" (raw-logged only)`); return; } if (!device.isActive) { - this.logger.warn(`Message from deactivated device "${username}" ignored`); + this.logger.warn(`Message from deactivated device "${idSegment}" ignored`); return; } @@ -57,7 +66,7 @@ export class IngestRouterService implements OnModuleInit { case 'status': break; // lastSeenAt already stamped above default: - this.logger.debug(`Unhandled subtopic "${subtopic}" from ${username}`); + this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`); } } } diff --git a/backend/src/ingest/ingest.module.ts b/backend/src/ingest/ingest.module.ts index a4f9081..fa06dfd 100644 --- a/backend/src/ingest/ingest.module.ts +++ b/backend/src/ingest/ingest.module.ts @@ -2,11 +2,21 @@ import { Module } from '@nestjs/common'; import { RealtimeModule } from '../realtime/realtime.module'; import { IngestRouterService } from './ingest-router.service'; import { JobsIngestService } from './jobs-ingest.service'; +import { LocatorRegistryService } from './locator-registry.service'; +import { LogIngestService } from './log-ingest.service'; import { MqttClientService } from './mqtt-client.service'; import { PointsIngestService } from './points-ingest.service'; @Module({ imports: [RealtimeModule], - providers: [MqttClientService, IngestRouterService, PointsIngestService, JobsIngestService], + providers: [ + MqttClientService, + IngestRouterService, + PointsIngestService, + JobsIngestService, + LogIngestService, + LocatorRegistryService, + ], + exports: [MqttClientService], }) export class IngestModule {} diff --git a/backend/src/ingest/locator-registry.service.ts b/backend/src/ingest/locator-registry.service.ts new file mode 100644 index 0000000..d796ab2 --- /dev/null +++ b/backend/src/ingest/locator-registry.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Device } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class LocatorRegistryService { + private readonly logger = new Logger(LocatorRegistryService.name); + + constructor(private readonly prisma: PrismaService) {} + + // Serial numbers are globally unique, so a bare serial identifies a locator + // regardless of which org's data pipeline saw it first. Unknown serials are + // auto-registered under the given org so field data is never dropped. + async resolve(orgId: string, serial: string): Promise { + const existing = await this.prisma.device.findUnique({ where: { serialNumber: serial } }); + if (existing) { + this.prisma.device + .update({ where: { id: existing.id }, data: { lastSeenAt: new Date() } }) + .catch(() => undefined); + return existing; + } + + this.logger.log(`Auto-registering locator serial ${serial} under org ${orgId}`); + try { + return await this.prisma.device.create({ + data: { orgId, name: `Locator ${serial}`, serialNumber: serial, lastSeenAt: new Date() }, + }); + } catch { + // lost a concurrent-registration race; the row exists now + const raced = await this.prisma.device.findUnique({ where: { serialNumber: serial } }); + if (!raced) { + throw new Error(`Failed to resolve or create locator ${serial}`); + } + return raced; + } + } +} diff --git a/backend/src/ingest/log-ingest.service.ts b/backend/src/ingest/log-ingest.service.ts new file mode 100644 index 0000000..5f28851 --- /dev/null +++ b/backend/src/ingest/log-ingest.service.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { toPointDto } from '../points/points.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { MqttLogMessageDto } from './dto/mqtt-messages.dto'; +import { LocatorRegistryService } from './locator-registry.service'; + +@Injectable() +export class LogIngestService { + private readonly logger = new Logger(LogIngestService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly realtime: RealtimeService, + private readonly locatorRegistry: LocatorRegistryService, + ) {} + + // devices//log carries the locator's identity in the topic itself + // (no publisher device needs to be pre-registered); the job named in the + // payload supplies the org, since the topic alone doesn't identify one. + async handle(serial: string, rawPayload: string) { + const msg = plainToInstance(MqttLogMessageDto, JSON.parse(rawPayload) as object); + const errors = await validate(msg, { whitelist: true }); + if (errors.length > 0) { + this.logger.warn(`Invalid log message from serial ${serial}: ${errors}`); + return; + } + + const job = await this.prisma.job.findUnique({ where: { id: msg.jobId } }); + if (!job) { + this.logger.warn(`Unknown jobId ${msg.jobId} in log message from serial ${serial}`); + return; + } + + const locator = await this.locatorRegistry.resolve(job.orgId, serial); + + const point = await this.prisma.locatePoint.create({ + data: { + jobId: job.id, + deviceId: locator.id, + lat: msg.lat, + lng: msg.lng, + altitude: msg.alt, + utilityType: msg.utility, + sequence: msg.seq, + fixType: msg.fix, + hAccuracy: msg.hAcc, + vAccuracy: msg.vAcc, + satellites: msg.sats, + hdop: msg.hdop, + depth: msg.depth, + frequencyHz: msg.freqHz, + currentMa: msg.currentMa, + signalDb: msg.signalDb, + gainDb: msg.gainDb, + locateMode: msg.mode, + phaseDeg: msg.phaseDeg, + compassDeg: msg.compassDeg, + distortionPct: msg.distortionPct, + recordedAt: new Date(msg.ts), + raw: msg as object, + }, + }); + + this.realtime.publish(`job:${job.id}`, { + type: 'points', + jobId: job.id, + points: [toPointDto(point)], + }); + this.logger.debug(`Stored 1 point from serial ${serial} for job ${job.ticketNumber}`); + } +} diff --git a/backend/src/ingest/points-ingest.service.ts b/backend/src/ingest/points-ingest.service.ts index e715f5b..15d2df5 100644 --- a/backend/src/ingest/points-ingest.service.ts +++ b/backend/src/ingest/points-ingest.service.ts @@ -6,6 +6,7 @@ import { toPointDto } from '../points/points.service'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; import { MqttPointsMessageDto } from './dto/mqtt-messages.dto'; +import { LocatorRegistryService } from './locator-registry.service'; @Injectable() export class PointsIngestService { @@ -14,6 +15,7 @@ export class PointsIngestService { constructor( private readonly prisma: PrismaService, private readonly realtime: RealtimeService, + private readonly locatorRegistry: LocatorRegistryService, ) {} async handle(device: Device, rawPayload: string) { @@ -33,7 +35,9 @@ export class PointsIngestService { return; } - const locator = await this.resolveLocator(device, msg.serial); + const locator = !msg.serial || msg.serial === device.serialNumber + ? device + : await this.locatorRegistry.resolve(device.orgId, msg.serial); const points = await this.prisma.locatePoint.createManyAndReturn({ data: msg.points.map((p) => ({ @@ -71,42 +75,6 @@ export class PointsIngestService { this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`); } - // Points are attributed to the locator receiver named by its serial number. - // The publishing MQTT credential (a phone/gateway, possibly relaying for - // several locators) only establishes the org; unknown serials are - // auto-registered so field data is never dropped. - private async resolveLocator(publisher: Device, serial: string | undefined): Promise { - if (!serial || serial === publisher.serialNumber) { - return publisher; - } - const existing = await this.prisma.device.findUnique({ - where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } }, - }); - if (existing) { - this.prisma.device - .update({ where: { id: existing.id }, data: { lastSeenAt: new Date() } }) - .catch(() => undefined); - return existing; - } - this.logger.log(`Auto-registering locator serial ${serial} (via ${publisher.mqttUsername})`); - try { - return await this.prisma.device.create({ - data: { - orgId: publisher.orgId, - name: `Locator ${serial}`, - serialNumber: serial, - lastSeenAt: new Date(), - }, - }); - } catch { - // lost a concurrent-registration race; the row exists now - const raced = await this.prisma.device.findUnique({ - where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } }, - }); - return raced ?? publisher; - } - } - private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise { if (msg.jobId) { const job = await this.prisma.job.findFirst({ diff --git a/backend/src/sim/dto/sim.dto.ts b/backend/src/sim/dto/sim.dto.ts new file mode 100644 index 0000000..87a0dec --- /dev/null +++ b/backend/src/sim/dto/sim.dto.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { MqttPointDto } from '../../ingest/dto/mqtt-messages.dto'; + +// What the simulator UI sends the backend. Mirrors a devices//log +// payload plus the two fields that live in the topic/routing rather than the +// wire payload itself (serial, jobId) so this DTO doubles as validation for +// both the REST call and the outgoing MQTT message. +export class SimPublishPointDto extends MqttPointDto { + @IsString() + @IsNotEmpty() + @MaxLength(64) + serial: string; + + @IsString() + @IsNotEmpty() + jobId: string; +} diff --git a/backend/src/sim/sim.controller.ts b/backend/src/sim/sim.controller.ts new file mode 100644 index 0000000..333ebec --- /dev/null +++ b/backend/src/sim/sim.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/auth.guard'; +import { OrgRolesGuard } from '../auth/guards/org-roles.guard'; +import { SimPublishPointDto } from './dto/sim.dto'; +import { SimService } from './sim.service'; + +// JWT-only: this is a UI-driven testing tool, not a public integration surface. +@Controller('orgs/:orgId/sim') +@UseGuards(JwtAuthGuard, OrgRolesGuard) +@Roles('MEMBER') +export class SimController { + constructor(private readonly simService: SimService) {} + + @Post('publish') + publish(@Param('orgId') orgId: string, @Body() dto: SimPublishPointDto) { + return this.simService.publish(orgId, dto); + } +} diff --git a/backend/src/sim/sim.module.ts b/backend/src/sim/sim.module.ts new file mode 100644 index 0000000..0d42403 --- /dev/null +++ b/backend/src/sim/sim.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { IngestModule } from '../ingest/ingest.module'; +import { SimController } from './sim.controller'; +import { SimService } from './sim.service'; + +@Module({ + imports: [IngestModule], + controllers: [SimController], + providers: [SimService], +}) +export class SimModule {} diff --git a/backend/src/sim/sim.service.ts b/backend/src/sim/sim.service.ts new file mode 100644 index 0000000..56c53fe --- /dev/null +++ b/backend/src/sim/sim.service.ts @@ -0,0 +1,25 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MqttClientService } from '../ingest/mqtt-client.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { SimPublishPointDto } from './dto/sim.dto'; + +@Injectable() +export class SimService { + constructor( + private readonly prisma: PrismaService, + private readonly mqttClient: MqttClientService, + ) {} + + // Publishes onto the real broker rather than writing to the DB directly, so + // the simulator exercises the exact same ingest path a real locator would. + async publish(orgId: string, dto: SimPublishPointDto) { + const job = await this.prisma.job.findFirst({ where: { id: dto.jobId, orgId }, select: { id: true } }); + if (!job) { + throw new NotFoundException('Job not found in this organization'); + } + + const { serial, ...payload } = dto; + this.mqttClient.publish(`devices/${serial}/log`, payload); + return { ok: true, topic: `devices/${serial}/log` }; + } +} diff --git a/mosquitto/config/devices.acl b/mosquitto/config/devices.acl index 42fb58e..e0a991b 100644 --- a/mosquitto/config/devices.acl +++ b/mosquitto/config/devices.acl @@ -11,9 +11,11 @@ user admin topic readwrite # topic readwrite $SYS/# -# Backend service: reads all device traffic, writes job acks back to devices +# Backend service: reads all device traffic, writes job acks back to devices, +# and publishes on behalf of the /sim simulator tool (devices//log) user backend topic read devices/# topic write devices/+/jobs/ack +topic write devices/+/log # testuser is a demo *device*: only the per-device pattern rule above applies diff --git a/web/pages/sim/index.tsx b/web/pages/sim/index.tsx new file mode 100644 index 0000000..cd2679c --- /dev/null +++ b/web/pages/sim/index.tsx @@ -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([]); + 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([]); + 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`, { + 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 ( +
+ + Transmitter Simulator · UlHub + +
+ UlHub · Transmitter Simulator + + ← Back to app + +
+ +
+

+ Simulates a locator receiver publishing GPS + telemetry to the MQTT broker at{' '} + devices/{serial || ''}/log, exactly as a real device would. +

+ {error &&

{error}

} + +
+ 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 && ( + + + + )} + +
TimeSeqLatLngDepth
{s.at}{s.seq}{s.lat.toFixed(7)}{s.lng.toFixed(7)}{s.depth} m
+ No points sent yet. +
+
+
+ ); +}