diff --git a/backend/.dockerignore b/backend/.dockerignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/backend/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql b/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql
new file mode 100644
index 0000000..35ce8ca
--- /dev/null
+++ b/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql
@@ -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");
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 3b12e22..c7fc54e 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -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)
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index eb367e4..e1855c0 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -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),
diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts
index 026764c..0e29f1b 100644
--- a/backend/src/devices/devices.service.ts
+++ b/backend/src/devices/devices.service.ts
@@ -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}".`,
+ },
};
}
diff --git a/backend/src/devices/dto/devices.dto.ts b/backend/src/devices/dto/devices.dto.ts
index d02939a..885b109 100644
--- a/backend/src/devices/dto/devices.dto.ts
+++ b/backend/src/devices/dto/devices.dto.ts
@@ -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;
}
diff --git a/backend/src/ingest/dto/mqtt-messages.dto.ts b/backend/src/ingest/dto/mqtt-messages.dto.ts
index 5d2817f..c77bcfc 100644
--- a/backend/src/ingest/dto/mqtt-messages.dto.ts
+++ b/backend/src/ingest/dto/mqtt-messages.dto.ts
@@ -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)
diff --git a/backend/src/ingest/points-ingest.service.ts b/backend/src/ingest/points-ingest.service.ts
index aa1fcd2..e715f5b 100644
--- a/backend/src/ingest/points-ingest.service.ts
+++ b/backend/src/ingest/points-ingest.service.ts
@@ -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
{job.description}
} -{d.mqttUsername}
- {d.mqttUsername} : '—'}