Tie points to locator serial numbers and store receiver telemetry

Field setups often relay through a phone/gateway that owns the MQTT
connection, so the publishing credential and the instrument are now
separate concepts:

- Points messages carry a "serial" identifying the locator receiver;
  unknown serials are auto-registered as devices in the publisher's
  org (name "Locator <serial>"). Device.mqttUsername is now optional
  and serialNumber is unique per org.
- LocatePoint gains standard receiver telemetry: frequencyHz,
  currentMa, signalDb, gainDb, locateMode (PEAK/NULL/BROAD_PEAK/SONDE),
  phaseDeg, compassDeg, distortionPct, plus GPS quality columns
  vAccuracy, satellites, hdop. All optional; raw payload still kept.
- Migration hand-edited to preserve the generated geom column and its
  GIST index (Prisma diff wanted to drop both).
- REST create/point DTOs, map tooltips, job-detail latest-point
  readout, devices settings form, seed, and the sample publisher all
  carry the new fields.
- Added .dockerignore for backend/web: COPY . . was clobbering the
  image's freshly generated Prisma client with the host's stale
  node_modules, breaking image builds after schema changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ulhub
2026-07-14 14:58:12 +00:00
parent eae4075265
commit 21f5a04133
16 changed files with 380 additions and 68 deletions

View File

@@ -1,4 +1,4 @@
import { GpsFixType, UtilityType } from '@prisma/client';
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
@@ -54,6 +54,61 @@ export class MqttPointDto {
@IsInt()
seq?: number;
// GPS quality
@IsOptional()
@IsNumber()
@Min(0)
vAcc?: number;
@IsOptional()
@IsInt()
@Min(0)
sats?: number;
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
// Locator receiver telemetry
@IsOptional()
@IsInt()
@Min(0)
freqHz?: number;
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@IsOptional()
@IsNumber()
signalDb?: number;
@IsOptional()
@IsNumber()
gainDb?: number;
@IsOptional()
@IsEnum(LocateMode)
mode?: LocateMode;
@IsOptional()
@IsNumber()
phaseDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@IsDateString()
ts: string;
}
@@ -69,6 +124,13 @@ export class MqttPointsMessageDto {
@MaxLength(64)
ticket?: string;
// Locator receiver serial number; the publisher (MQTT credential) may be a
// phone/gateway relaying for one or more locators. Auto-registered on first sight.
@IsOptional()
@IsString()
@MaxLength(64)
serial?: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)

View File

@@ -33,18 +33,31 @@ export class PointsIngestService {
return;
}
const locator = await this.resolveLocator(device, msg.serial);
const points = await this.prisma.locatePoint.createManyAndReturn({
data: msg.points.map((p) => ({
jobId: job.id,
deviceId: device.id,
deviceId: locator.id,
lat: p.lat,
lng: p.lng,
altitude: p.alt,
fixType: p.fix,
hAccuracy: p.hAcc,
depth: p.depth,
utilityType: p.utility,
sequence: p.seq,
fixType: p.fix,
hAccuracy: p.hAcc,
vAccuracy: p.vAcc,
satellites: p.sats,
hdop: p.hdop,
depth: p.depth,
frequencyHz: p.freqHz,
currentMa: p.currentMa,
signalDb: p.signalDb,
gainDb: p.gainDb,
locateMode: p.mode,
phaseDeg: p.phaseDeg,
compassDeg: p.compassDeg,
distortionPct: p.distortionPct,
recordedAt: new Date(p.ts),
raw: p as object,
})),
@@ -58,6 +71,42 @@ 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<Device> {
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<Job | null> {
if (msg.jobId) {
const job = await this.prisma.job.findFirst({