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:
2
backend/.dockerignore
Normal file
2
backend/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -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");
|
||||||
@@ -45,6 +45,14 @@ enum GpsFixType {
|
|||||||
FIXED_RTK
|
FIXED_RTK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EM locator receiver antenna mode used when the point was captured
|
||||||
|
enum LocateMode {
|
||||||
|
PEAK
|
||||||
|
NULL
|
||||||
|
BROAD_PEAK
|
||||||
|
SONDE
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -116,12 +124,16 @@ model Job {
|
|||||||
@@map("jobs")
|
@@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 {
|
model Device {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
orgId String
|
orgId String
|
||||||
name String
|
name String
|
||||||
serialNumber String?
|
serialNumber String?
|
||||||
mqttUsername String @unique
|
mqttUsername String? @unique
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
lastSeenAt DateTime? @db.Timestamptz(6)
|
lastSeenAt DateTime? @db.Timestamptz(6)
|
||||||
createdAt DateTime @default(now()) @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)
|
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
|
||||||
points LocatePoint[]
|
points LocatePoint[]
|
||||||
|
|
||||||
|
@@unique([orgId, serialNumber])
|
||||||
@@map("devices")
|
@@map("devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +153,27 @@ model LocatePoint {
|
|||||||
lat Decimal @db.Decimal(10, 8)
|
lat Decimal @db.Decimal(10, 8)
|
||||||
lng Decimal @db.Decimal(11, 8)
|
lng Decimal @db.Decimal(11, 8)
|
||||||
altitude Decimal? @db.Decimal(8, 3)
|
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)
|
utilityType UtilityType @default(UNKNOWN)
|
||||||
sequence Int?
|
sequence Int?
|
||||||
|
|
||||||
|
// 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)
|
recordedAt DateTime @db.Timestamptz(6)
|
||||||
receivedAt DateTime @default(now()) @db.Timestamptz(6)
|
receivedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||||
raw Json?
|
raw Json?
|
||||||
|
|||||||
@@ -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';
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -68,7 +68,17 @@ async function main() {
|
|||||||
altitude: 187.4 + i * 0.02,
|
altitude: 187.4 + i * 0.02,
|
||||||
fixType: GpsFixType.FIXED_RTK,
|
fixType: GpsFixType.FIXED_RTK,
|
||||||
hAccuracy: 0.014,
|
hAccuracy: 0.014,
|
||||||
|
vAccuracy: 0.021,
|
||||||
|
satellites: 22,
|
||||||
|
hdop: 0.7,
|
||||||
depth: 1.2,
|
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,
|
utilityType: UtilityType.GAS,
|
||||||
sequence: i + 1,
|
sequence: i + 1,
|
||||||
recordedAt: new Date(Date.now() - (12 - i) * 5000),
|
recordedAt: new Date(Date.now() - (12 - i) * 5000),
|
||||||
|
|||||||
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
||||||
|
|
||||||
@@ -14,22 +14,39 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(orgId: string, dto: CreateDeviceDto) {
|
async create(orgId: string, dto: CreateDeviceDto) {
|
||||||
|
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({
|
const existing = await this.prisma.device.findUnique({
|
||||||
where: { mqttUsername: dto.mqttUsername },
|
where: { mqttUsername: dto.mqttUsername },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('A device with this MQTT username already exists');
|
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({
|
const device = await this.prisma.device.create({
|
||||||
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
|
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...device,
|
...device,
|
||||||
provisioning: {
|
provisioning: device.mqttUsername
|
||||||
|
? {
|
||||||
mqttUsername: device.mqttUsername,
|
mqttUsername: device.mqttUsername,
|
||||||
pointsTopic: `devices/${device.mqttUsername}/points`,
|
pointsTopic: `devices/${device.mqttUsername}/points`,
|
||||||
jobsTopic: `devices/${device.mqttUsername}/jobs`,
|
jobsTopic: `devices/${device.mqttUsername}/jobs`,
|
||||||
note: 'Broker credentials must be created separately (mosquitto_passwd) until dynamic broker auth lands.',
|
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}".`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,18 @@ export class CreateDeviceDto {
|
|||||||
@MaxLength(120)
|
@MaxLength(120)
|
||||||
name: string;
|
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()
|
@IsString()
|
||||||
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
@Matches(/^[a-zA-Z0-9._-]{3,64}$/, {
|
||||||
message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore',
|
message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore',
|
||||||
})
|
})
|
||||||
mqttUsername: string;
|
mqttUsername?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(120)
|
@MaxLength(64)
|
||||||
serialNumber?: string;
|
serialNumber?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
@@ -54,6 +54,61 @@ export class MqttPointDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
seq?: number;
|
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()
|
@IsDateString()
|
||||||
ts: string;
|
ts: string;
|
||||||
}
|
}
|
||||||
@@ -69,6 +124,13 @@ export class MqttPointsMessageDto {
|
|||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
ticket?: string;
|
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()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
@ArrayMinSize(1)
|
||||||
@ArrayMaxSize(500)
|
@ArrayMaxSize(500)
|
||||||
|
|||||||
@@ -33,18 +33,31 @@ export class PointsIngestService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locator = await this.resolveLocator(device, msg.serial);
|
||||||
|
|
||||||
const points = await this.prisma.locatePoint.createManyAndReturn({
|
const points = await this.prisma.locatePoint.createManyAndReturn({
|
||||||
data: msg.points.map((p) => ({
|
data: msg.points.map((p) => ({
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
deviceId: device.id,
|
deviceId: locator.id,
|
||||||
lat: p.lat,
|
lat: p.lat,
|
||||||
lng: p.lng,
|
lng: p.lng,
|
||||||
altitude: p.alt,
|
altitude: p.alt,
|
||||||
fixType: p.fix,
|
|
||||||
hAccuracy: p.hAcc,
|
|
||||||
depth: p.depth,
|
|
||||||
utilityType: p.utility,
|
utilityType: p.utility,
|
||||||
sequence: p.seq,
|
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),
|
recordedAt: new Date(p.ts),
|
||||||
raw: p as object,
|
raw: p as object,
|
||||||
})),
|
})),
|
||||||
@@ -58,6 +71,42 @@ export class PointsIngestService {
|
|||||||
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
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> {
|
private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise<Job | null> {
|
||||||
if (msg.jobId) {
|
if (msg.jobId) {
|
||||||
const job = await this.prisma.job.findFirst({
|
const job = await this.prisma.job.findFirst({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GpsFixType, UtilityType } from '@prisma/client';
|
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -49,6 +49,61 @@ export class CreatePointDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
sequence?: number;
|
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()
|
@IsDateString()
|
||||||
recordedAt: string;
|
recordedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,30 @@ export interface PointDto {
|
|||||||
lat: number;
|
lat: number;
|
||||||
lng: number;
|
lng: number;
|
||||||
altitude: number | null;
|
altitude: number | null;
|
||||||
fixType: string;
|
|
||||||
hAccuracy: number | null;
|
|
||||||
depth: number | null;
|
|
||||||
utilityType: string;
|
utilityType: string;
|
||||||
sequence: number | null;
|
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;
|
recordedAt: Date;
|
||||||
receivedAt: Date;
|
receivedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function num(value: unknown): number | null {
|
||||||
|
return value === null || value === undefined ? null : Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function toPointDto(p: LocatePoint): PointDto {
|
export function toPointDto(p: LocatePoint): PointDto {
|
||||||
return {
|
return {
|
||||||
id: p.id.toString(),
|
id: p.id.toString(),
|
||||||
@@ -27,12 +42,23 @@ export function toPointDto(p: LocatePoint): PointDto {
|
|||||||
deviceId: p.deviceId,
|
deviceId: p.deviceId,
|
||||||
lat: Number(p.lat),
|
lat: Number(p.lat),
|
||||||
lng: Number(p.lng),
|
lng: Number(p.lng),
|
||||||
altitude: p.altitude === null ? null : Number(p.altitude),
|
altitude: num(p.altitude),
|
||||||
fixType: p.fixType,
|
|
||||||
hAccuracy: p.hAccuracy === null ? null : Number(p.hAccuracy),
|
|
||||||
depth: p.depth === null ? null : Number(p.depth),
|
|
||||||
utilityType: p.utilityType,
|
utilityType: p.utilityType,
|
||||||
sequence: p.sequence,
|
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,
|
recordedAt: p.recordedAt,
|
||||||
receivedAt: p.receivedAt,
|
receivedAt: p.receivedAt,
|
||||||
};
|
};
|
||||||
@@ -64,19 +90,13 @@ export class PointsService {
|
|||||||
if (!job) {
|
if (!job) {
|
||||||
throw new NotFoundException('Job not found');
|
throw new NotFoundException('Job not found');
|
||||||
}
|
}
|
||||||
|
const { recordedAt, ...fields } = dto;
|
||||||
const point = await this.prisma.locatePoint.create({
|
const point = await this.prisma.locatePoint.create({
|
||||||
data: {
|
data: {
|
||||||
jobId,
|
jobId,
|
||||||
deviceId,
|
deviceId,
|
||||||
lat: dto.lat,
|
...fields,
|
||||||
lng: dto.lng,
|
recordedAt: new Date(recordedAt),
|
||||||
altitude: dto.altitude,
|
|
||||||
fixType: dto.fixType,
|
|
||||||
hAccuracy: dto.hAccuracy,
|
|
||||||
depth: dto.depth,
|
|
||||||
utilityType: dto.utilityType,
|
|
||||||
sequence: dto.sequence,
|
|
||||||
recordedAt: new Date(dto.recordedAt),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return toPointDto(point);
|
return toPointDto(point);
|
||||||
|
|||||||
@@ -3,11 +3,16 @@
|
|||||||
The backend subscribes to devices/# and expects points at
|
The backend subscribes to devices/# and expects points at
|
||||||
devices/{mqttUsername}/points with a JSON body:
|
devices/{mqttUsername}/points with a JSON body:
|
||||||
|
|
||||||
{"ticket": "TKT-...", "points": [{"lat": .., "lng": .., "ts": "ISO8601", ...}]}
|
{"serial": "...", "ticket": "TKT-...",
|
||||||
|
"points": [{"lat": .., "lng": .., "ts": "ISO8601", ...}]}
|
||||||
|
|
||||||
Points for an unknown ticket auto-create a stub job (source=DEVICE).
|
The MQTT username identifies the publisher (app/gateway) and its org; "serial"
|
||||||
|
names the locator receiver the readings came from (auto-registered on first
|
||||||
|
sight). Points for an unknown ticket auto-create a stub job (source=DEVICE).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -19,6 +24,7 @@ BROKER_HOST = os.getenv("MQTT_HOST", "127.0.0.1")
|
|||||||
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
|
||||||
USERNAME = os.getenv("MQTT_USERNAME", "testuser")
|
USERNAME = os.getenv("MQTT_USERNAME", "testuser")
|
||||||
PASSWORD = os.getenv("MQTT_PASSWORD", "testpass")
|
PASSWORD = os.getenv("MQTT_PASSWORD", "testpass")
|
||||||
|
SERIAL = os.getenv("SERIAL", "DEMO-0001")
|
||||||
TICKET = os.getenv("TICKET", "TKT-2026-0001")
|
TICKET = os.getenv("TICKET", "TKT-2026-0001")
|
||||||
INTERVAL = float(os.getenv("MQTT_INTERVAL", "2"))
|
INTERVAL = float(os.getenv("MQTT_INTERVAL", "2"))
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ try:
|
|||||||
seq = 0
|
seq = 0
|
||||||
while True:
|
while True:
|
||||||
payload = {
|
payload = {
|
||||||
|
"serial": SERIAL,
|
||||||
"ticket": TICKET,
|
"ticket": TICKET,
|
||||||
"points": [
|
"points": [
|
||||||
{
|
{
|
||||||
@@ -45,7 +52,17 @@ try:
|
|||||||
"alt": 187.4 + seq * 0.02,
|
"alt": 187.4 + seq * 0.02,
|
||||||
"fix": "FIXED_RTK",
|
"fix": "FIXED_RTK",
|
||||||
"hAcc": 0.014,
|
"hAcc": 0.014,
|
||||||
"depth": 1.2,
|
"vAcc": 0.021,
|
||||||
|
"sats": random.randint(18, 26),
|
||||||
|
"hdop": 0.7,
|
||||||
|
"depth": round(1.1 + random.uniform(0, 0.3), 2),
|
||||||
|
"freqHz": 33000,
|
||||||
|
"currentMa": round(50 - seq * 0.4 + random.uniform(-1, 1), 1),
|
||||||
|
"signalDb": round(62 - seq * 0.2 + random.uniform(-0.5, 0.5), 1),
|
||||||
|
"gainDb": 40,
|
||||||
|
"mode": "PEAK",
|
||||||
|
"compassDeg": round(17.5 + random.uniform(-3, 3), 1),
|
||||||
|
"distortionPct": round(random.uniform(2, 8), 1),
|
||||||
"utility": "GAS",
|
"utility": "GAS",
|
||||||
"seq": seq + 1,
|
"seq": seq + 1,
|
||||||
"ts": datetime.now(timezone.utc).isoformat(),
|
"ts": datetime.now(timezone.utc).isoformat(),
|
||||||
|
|||||||
2
web/.dockerignore
Normal file
2
web/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="google.maps" />
|
/// <reference types="google.maps" />
|
||||||
import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps';
|
import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { JobMapProps, MapPoint, UTILITY_COLORS } from '../types';
|
import { JobMapProps, MapPoint, pointSummary, UTILITY_COLORS } from '../types';
|
||||||
|
|
||||||
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '';
|
const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || '';
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo
|
|||||||
strokeColor: '#ffffff',
|
strokeColor: '#ffffff',
|
||||||
strokeWeight: 1.5,
|
strokeWeight: 1.5,
|
||||||
},
|
},
|
||||||
title: `${p.utilityType} · ${p.fixType}${p.depth != null ? ` · depth ${p.depth}m` : ''}\n${new Date(p.recordedAt).toLocaleString()}`,
|
title: `${pointSummary(p)}\n${new Date(p.recordedAt).toLocaleString()}`,
|
||||||
});
|
});
|
||||||
overlaysRef.current.push(marker);
|
overlaysRef.current.push(marker);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,27 @@ export interface MapPoint {
|
|||||||
lng: number;
|
lng: number;
|
||||||
utilityType: string;
|
utilityType: string;
|
||||||
fixType: string;
|
fixType: string;
|
||||||
depth: number | null;
|
|
||||||
sequence: number | null;
|
sequence: number | null;
|
||||||
recordedAt: string;
|
recordedAt: string;
|
||||||
|
// Locator receiver telemetry (all optional — depends on the instrument)
|
||||||
|
depth: number | null;
|
||||||
|
frequencyHz: number | null;
|
||||||
|
currentMa: number | null;
|
||||||
|
signalDb: number | null;
|
||||||
|
gainDb: number | null;
|
||||||
|
locateMode: string | null;
|
||||||
|
compassDeg: number | null;
|
||||||
|
distortionPct: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointSummary(p: MapPoint): string {
|
||||||
|
const parts = [`${p.utilityType} · ${p.fixType}`];
|
||||||
|
if (p.depth != null) parts.push(`depth ${p.depth}m`);
|
||||||
|
if (p.frequencyHz != null)
|
||||||
|
parts.push(p.frequencyHz >= 1000 ? `${p.frequencyHz / 1000}kHz` : `${p.frequencyHz}Hz`);
|
||||||
|
if (p.currentMa != null) parts.push(`${p.currentMa}mA`);
|
||||||
|
if (p.locateMode) parts.push(p.locateMode);
|
||||||
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobMapProps {
|
export interface JobMapProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useRouter } from 'next/router';
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import JobMap from '../../components/map/JobMap';
|
import JobMap from '../../components/map/JobMap';
|
||||||
import type { MapPoint } from '../../components/map/types';
|
import { pointSummary, type MapPoint } from '../../components/map/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { useRequireAuth } from '../../lib/auth-context';
|
import { useRequireAuth } from '../../lib/auth-context';
|
||||||
import { useJobStream } from '../../lib/use-job-stream';
|
import { useJobStream } from '../../lib/use-job-stream';
|
||||||
@@ -98,13 +98,16 @@ export default function JobDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
{job.description && <p>{job.description}</p>}
|
{job.description && <p>{job.description}</p>}
|
||||||
|
|
||||||
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555' }}>
|
<div style={{ margin: '1rem 0', display: 'flex', gap: '1rem', color: '#555', flexWrap: 'wrap' }}>
|
||||||
<span>
|
<span>
|
||||||
<strong>{points.length}</strong> points
|
<strong>{points.length}</strong> points
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
||||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
● live{live > 0 ? ` (+${live} this session)` : ''}
|
||||||
</span>
|
</span>
|
||||||
|
{points.length > 0 && (
|
||||||
|
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<JobMap points={points} heightPx={520} />
|
<JobMap points={points} heightPx={520} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ interface DeviceRow {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
serialNumber: string | null;
|
serialNumber: string | null;
|
||||||
mqttUsername: string;
|
mqttUsername: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
lastSeenAt: string | null;
|
lastSeenAt: string | null;
|
||||||
}
|
}
|
||||||
@@ -40,15 +40,19 @@ export default function DevicesPage() {
|
|||||||
const addDevice = async (e: FormEvent) => {
|
const addDevice = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const created = await api.post<DeviceRow & { provisioning: { pointsTopic: string } }>(
|
const created = await api.post<DeviceRow & { provisioning: { pointsTopic?: string; note: string } }>(
|
||||||
`/api/orgs/${orgId}/devices`,
|
`/api/orgs/${orgId}/devices`,
|
||||||
{
|
{
|
||||||
name: form.name,
|
name: form.name,
|
||||||
mqttUsername: form.mqttUsername,
|
mqttUsername: form.mqttUsername || undefined,
|
||||||
serialNumber: form.serialNumber || undefined,
|
serialNumber: form.serialNumber || undefined,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
setNotice(`Device created. It should publish points to ${created.provisioning.pointsTopic}`);
|
setNotice(
|
||||||
|
created.provisioning.pointsTopic
|
||||||
|
? `Device created. It should publish points to ${created.provisioning.pointsTopic}`
|
||||||
|
: created.provisioning.note,
|
||||||
|
);
|
||||||
setForm({ name: '', mqttUsername: '', serialNumber: '' });
|
setForm({ name: '', mqttUsername: '', serialNumber: '' });
|
||||||
reload();
|
reload();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -98,16 +102,15 @@ export default function DevicesPage() {
|
|||||||
style={{ padding: '0.4rem' }}
|
style={{ padding: '0.4rem' }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="mqtt username"
|
placeholder="serial number"
|
||||||
value={form.mqttUsername}
|
value={form.serialNumber}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))}
|
||||||
required
|
|
||||||
style={{ padding: '0.4rem' }}
|
style={{ padding: '0.4rem' }}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
placeholder="serial number (optional)"
|
placeholder="mqtt username (only if it connects itself)"
|
||||||
value={form.serialNumber}
|
value={form.mqttUsername}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
|
||||||
style={{ padding: '0.4rem' }}
|
style={{ padding: '0.4rem' }}
|
||||||
/>
|
/>
|
||||||
<button type="submit">Add device</button>
|
<button type="submit">Add device</button>
|
||||||
@@ -129,9 +132,7 @@ export default function DevicesPage() {
|
|||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||||
<td>
|
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
||||||
<code>{d.mqttUsername}</code>
|
|
||||||
</td>
|
|
||||||
<td>{d.serialNumber ?? '—'}</td>
|
<td>{d.serialNumber ?? '—'}</td>
|
||||||
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
||||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user