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 { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
@@ -14,23 +14,40 @@ export class DevicesService {
}
async create(orgId: string, dto: CreateDeviceDto) {
const existing = await this.prisma.device.findUnique({
where: { mqttUsername: dto.mqttUsername },
});
if (existing) {
throw new ConflictException('A device with this MQTT username already exists');
if (!dto.mqttUsername && !dto.serialNumber) {
throw new BadRequestException('A device needs a serial number, an MQTT username, or both');
}
if (dto.mqttUsername) {
const existing = await this.prisma.device.findUnique({
where: { mqttUsername: dto.mqttUsername },
});
if (existing) {
throw new ConflictException('A device with this MQTT username already exists');
}
}
if (dto.serialNumber) {
const existing = await this.prisma.device.findUnique({
where: { orgId_serialNumber: { orgId, serialNumber: dto.serialNumber } },
});
if (existing) {
throw new ConflictException('A device with this serial number already exists in this organization');
}
}
const device = await this.prisma.device.create({
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
});
return {
...device,
provisioning: {
mqttUsername: device.mqttUsername,
pointsTopic: `devices/${device.mqttUsername}/points`,
jobsTopic: `devices/${device.mqttUsername}/jobs`,
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
},
provisioning: device.mqttUsername
? {
mqttUsername: device.mqttUsername,
pointsTopic: `devices/${device.mqttUsername}/points`,
jobsTopic: `devices/${device.mqttUsername}/jobs`,
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}".`,
},
};
}

View File

@@ -6,16 +6,18 @@ export class CreateDeviceDto {
@MaxLength(120)
name: string;
// Must match the broker username (or future TLS cert CN) the device connects with
// Broker username (or future TLS cert CN) if this device connects to MQTT
// itself; locators relayed by a gateway need only a serial number.
@IsOptional()
@IsString()
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore',
})
mqttUsername: string;
mqttUsername?: string;
@IsOptional()
@IsString()
@MaxLength(120)
@MaxLength(64)
serialNumber?: string;
}

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({

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 {
IsDateString,
@@ -49,6 +49,61 @@ export class CreatePointDto {
@IsInt()
sequence?: number;
// GPS quality
@IsOptional()
@IsNumber()
@Min(0)
vAccuracy?: number;
@IsOptional()
@IsInt()
@Min(0)
satellites?: number;
@IsOptional()
@IsNumber()
@Min(0)
hdop?: number;
// Locator receiver telemetry
@IsOptional()
@IsInt()
@Min(0)
frequencyHz?: number;
@IsOptional()
@IsNumber()
@Min(0)
currentMa?: number;
@IsOptional()
@IsNumber()
signalDb?: number;
@IsOptional()
@IsNumber()
gainDb?: number;
@IsOptional()
@IsEnum(LocateMode)
locateMode?: LocateMode;
@IsOptional()
@IsNumber()
phaseDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(360)
compassDeg?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
distortionPct?: number;
@IsDateString()
recordedAt: string;
}

View File

@@ -11,15 +11,30 @@ export interface PointDto {
lat: number;
lng: number;
altitude: number | null;
fixType: string;
hAccuracy: number | null;
depth: number | null;
utilityType: string;
sequence: number | null;
fixType: string;
hAccuracy: number | null;
vAccuracy: number | null;
satellites: number | null;
hdop: number | null;
depth: number | null;
frequencyHz: number | null;
currentMa: number | null;
signalDb: number | null;
gainDb: number | null;
locateMode: string | null;
phaseDeg: number | null;
compassDeg: number | null;
distortionPct: number | null;
recordedAt: Date;
receivedAt: Date;
}
function num(value: unknown): number | null {
return value === null || value === undefined ? null : Number(value);
}
export function toPointDto(p: LocatePoint): PointDto {
return {
id: p.id.toString(),
@@ -27,12 +42,23 @@ export function toPointDto(p: LocatePoint): PointDto {
deviceId: p.deviceId,
lat: Number(p.lat),
lng: Number(p.lng),
altitude: p.altitude === null ? null : Number(p.altitude),
fixType: p.fixType,
hAccuracy: p.hAccuracy === null ? null : Number(p.hAccuracy),
depth: p.depth === null ? null : Number(p.depth),
altitude: num(p.altitude),
utilityType: p.utilityType,
sequence: p.sequence,
fixType: p.fixType,
hAccuracy: num(p.hAccuracy),
vAccuracy: num(p.vAccuracy),
satellites: p.satellites,
hdop: num(p.hdop),
depth: num(p.depth),
frequencyHz: p.frequencyHz,
currentMa: num(p.currentMa),
signalDb: num(p.signalDb),
gainDb: num(p.gainDb),
locateMode: p.locateMode,
phaseDeg: num(p.phaseDeg),
compassDeg: num(p.compassDeg),
distortionPct: num(p.distortionPct),
recordedAt: p.recordedAt,
receivedAt: p.receivedAt,
};
@@ -64,19 +90,13 @@ export class PointsService {
if (!job) {
throw new NotFoundException('Job not found');
}
const { recordedAt, ...fields } = dto;
const point = await this.prisma.locatePoint.create({
data: {
jobId,
deviceId,
lat: dto.lat,
lng: dto.lng,
altitude: dto.altitude,
fixType: dto.fixType,
hAccuracy: dto.hAccuracy,
depth: dto.depth,
utilityType: dto.utilityType,
sequence: dto.sequence,
recordedAt: new Date(dto.recordedAt),
...fields,
recordedAt: new Date(recordedAt),
},
});
return toPointDto(point);