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

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

View File

@@ -0,0 +1,25 @@
-- Locator receiver telemetry + device identity by serial number.
-- NOTE: hand-edited — Prisma's diff wanted to drop the geom GIST index and the
-- generated-column expression on locate_points.geom; those statements were removed.
-- CreateEnum
CREATE TYPE "LocateMode" AS ENUM ('PEAK', 'NULL', 'BROAD_PEAK', 'SONDE');
-- AlterTable
ALTER TABLE "devices" ALTER COLUMN "mqttUsername" DROP NOT NULL;
-- AlterTable
ALTER TABLE "locate_points" ADD COLUMN "compassDeg" DECIMAL(5,2),
ADD COLUMN "currentMa" DECIMAL(9,3),
ADD COLUMN "distortionPct" DECIMAL(5,2),
ADD COLUMN "frequencyHz" INTEGER,
ADD COLUMN "gainDb" DECIMAL(6,2),
ADD COLUMN "hdop" DECIMAL(4,2),
ADD COLUMN "locateMode" "LocateMode",
ADD COLUMN "phaseDeg" DECIMAL(6,2),
ADD COLUMN "satellites" INTEGER,
ADD COLUMN "signalDb" DECIMAL(6,2),
ADD COLUMN "vAccuracy" DECIMAL(7,3);
-- CreateIndex
CREATE UNIQUE INDEX "devices_orgId_serialNumber_key" ON "devices"("orgId", "serialNumber");

View File

@@ -45,6 +45,14 @@ enum GpsFixType {
FIXED_RTK
}
// EM locator receiver antenna mode used when the point was captured
enum LocateMode {
PEAK
NULL
BROAD_PEAK
SONDE
}
model Organization {
id String @id @default(cuid())
name String
@@ -116,12 +124,16 @@ model Job {
@@map("jobs")
}
// A device is either a locator receiver (identified by serialNumber, usually
// auto-registered from incoming data) or an MQTT publisher (app/gateway with
// broker credentials, identified by mqttUsername) — or both, when a locator
// connects to the broker directly.
model Device {
id String @id @default(cuid())
orgId String
name String
serialNumber String?
mqttUsername String @unique
mqttUsername String? @unique
isActive Boolean @default(true)
lastSeenAt DateTime? @db.Timestamptz(6)
createdAt DateTime @default(now()) @db.Timestamptz(6)
@@ -130,6 +142,7 @@ model Device {
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
points LocatePoint[]
@@unique([orgId, serialNumber])
@@map("devices")
}
@@ -140,15 +153,31 @@ model LocatePoint {
lat Decimal @db.Decimal(10, 8)
lng Decimal @db.Decimal(11, 8)
altitude Decimal? @db.Decimal(8, 3)
fixType GpsFixType @default(NONE)
hAccuracy Decimal? @db.Decimal(7, 3)
depth Decimal? @db.Decimal(6, 3)
utilityType UtilityType @default(UNKNOWN)
sequence Int?
recordedAt DateTime @db.Timestamptz(6)
receivedAt DateTime @default(now()) @db.Timestamptz(6)
raw Json?
geom Unsupported("geometry(Point, 4326)")?
// GPS quality
fixType GpsFixType @default(NONE)
hAccuracy Decimal? @db.Decimal(7, 3) // meters
vAccuracy Decimal? @db.Decimal(7, 3) // meters
satellites Int?
hdop Decimal? @db.Decimal(4, 2)
// Locator receiver telemetry
depth Decimal? @db.Decimal(6, 3) // meters below grade
frequencyHz Int? // active/passive locate frequency
currentMa Decimal? @db.Decimal(9, 3) // signal current on the line
signalDb Decimal? @db.Decimal(6, 2) // signal strength
gainDb Decimal? @db.Decimal(6, 2) // receiver gain
locateMode LocateMode?
phaseDeg Decimal? @db.Decimal(6, 2)
compassDeg Decimal? @db.Decimal(5, 2) // line direction, 0-360
distortionPct Decimal? @db.Decimal(5, 2)
recordedAt DateTime @db.Timestamptz(6)
receivedAt DateTime @default(now()) @db.Timestamptz(6)
raw Json?
geom Unsupported("geometry(Point, 4326)")?
job Job @relation(fields: [jobId], references: [id], onDelete: Cascade)
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)

View File

@@ -1,4 +1,4 @@
import { PrismaClient, GpsFixType, JobStatus, UtilityType } from '@prisma/client';
import { PrismaClient, GpsFixType, JobStatus, LocateMode, UtilityType } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
@@ -68,7 +68,17 @@ async function main() {
altitude: 187.4 + i * 0.02,
fixType: GpsFixType.FIXED_RTK,
hAccuracy: 0.014,
vAccuracy: 0.021,
satellites: 22,
hdop: 0.7,
depth: 1.2,
frequencyHz: 33000,
currentMa: 48.5 - i * 0.4,
signalDb: 62.1 - i * 0.3,
gainDb: 40,
locateMode: LocateMode.PEAK,
compassDeg: 17.5,
distortionPct: 4.2,
utilityType: UtilityType.GAS,
sequence: i + 1,
recordedAt: new Date(Date.now() - (12 - i) * 5000),

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);