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:
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user