From 21f5a0413348cd4cbe55272a0a37187b365885a1 Mon Sep 17 00:00:00 2001
From: ulhub
Date: Tue, 14 Jul 2026 14:58:12 +0000
Subject: [PATCH] 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 "). 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
---
backend/.dockerignore | 2 +
.../migration.sql | 25 ++++++++
backend/prisma/schema.prisma | 45 ++++++++++---
backend/prisma/seed.ts | 12 +++-
backend/src/devices/devices.service.ts | 41 ++++++++----
backend/src/devices/dto/devices.dto.ts | 8 ++-
backend/src/ingest/dto/mqtt-messages.dto.ts | 64 ++++++++++++++++++-
backend/src/ingest/points-ingest.service.ts | 57 +++++++++++++++--
backend/src/points/dto/points.dto.ts | 57 ++++++++++++++++-
backend/src/points/points.service.ts | 52 ++++++++++-----
test/publish_sample.py | 23 ++++++-
web/.dockerignore | 2 +
web/components/map/google/GoogleJobMap.tsx | 4 +-
web/components/map/types.ts | 20 +++++-
web/pages/jobs/[jobId].tsx | 7 +-
web/pages/settings/devices.tsx | 29 +++++----
16 files changed, 380 insertions(+), 68 deletions(-)
create mode 100644 backend/.dockerignore
create mode 100644 backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql
create mode 100644 web/.dockerignore
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 {
+ 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 {
if (msg.jobId) {
const job = await this.prisma.job.findFirst({
diff --git a/backend/src/points/dto/points.dto.ts b/backend/src/points/dto/points.dto.ts
index c82dacb..2715fe7 100644
--- a/backend/src/points/dto/points.dto.ts
+++ b/backend/src/points/dto/points.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 {
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;
}
diff --git a/backend/src/points/points.service.ts b/backend/src/points/points.service.ts
index 3d29885..3c21eb0 100644
--- a/backend/src/points/points.service.ts
+++ b/backend/src/points/points.service.ts
@@ -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);
diff --git a/test/publish_sample.py b/test/publish_sample.py
index fda0230..3caa6e9 100644
--- a/test/publish_sample.py
+++ b/test/publish_sample.py
@@ -3,11 +3,16 @@
The backend subscribes to devices/# and expects points at
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 os
import time
@@ -19,6 +24,7 @@ BROKER_HOST = os.getenv("MQTT_HOST", "127.0.0.1")
BROKER_PORT = int(os.getenv("MQTT_PORT", "1883"))
USERNAME = os.getenv("MQTT_USERNAME", "testuser")
PASSWORD = os.getenv("MQTT_PASSWORD", "testpass")
+SERIAL = os.getenv("SERIAL", "DEMO-0001")
TICKET = os.getenv("TICKET", "TKT-2026-0001")
INTERVAL = float(os.getenv("MQTT_INTERVAL", "2"))
@@ -37,6 +43,7 @@ try:
seq = 0
while True:
payload = {
+ "serial": SERIAL,
"ticket": TICKET,
"points": [
{
@@ -45,7 +52,17 @@ try:
"alt": 187.4 + seq * 0.02,
"fix": "FIXED_RTK",
"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",
"seq": seq + 1,
"ts": datetime.now(timezone.utc).isoformat(),
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 0000000..b90a368
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+.next
diff --git a/web/components/map/google/GoogleJobMap.tsx b/web/components/map/google/GoogleJobMap.tsx
index 60152b0..4678521 100644
--- a/web/components/map/google/GoogleJobMap.tsx
+++ b/web/components/map/google/GoogleJobMap.tsx
@@ -1,7 +1,7 @@
///
import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps';
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 || '';
@@ -67,7 +67,7 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo
strokeColor: '#ffffff',
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);
}
diff --git a/web/components/map/types.ts b/web/components/map/types.ts
index 4b77ff3..16822f0 100644
--- a/web/components/map/types.ts
+++ b/web/components/map/types.ts
@@ -8,9 +8,27 @@ export interface MapPoint {
lng: number;
utilityType: string;
fixType: string;
- depth: number | null;
sequence: number | null;
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 {
diff --git a/web/pages/jobs/[jobId].tsx b/web/pages/jobs/[jobId].tsx
index f278954..c7b9674 100644
--- a/web/pages/jobs/[jobId].tsx
+++ b/web/pages/jobs/[jobId].tsx
@@ -2,7 +2,7 @@ import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import Layout from '../../components/Layout';
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 { useRequireAuth } from '../../lib/auth-context';
import { useJobStream } from '../../lib/use-job-stream';
@@ -98,13 +98,16 @@ export default function JobDetailPage() {
{job.description && {job.description}
}
-
+
{points.length} points
0 ? '#388e3c' : '#999' }}>
● live{live > 0 ? ` (+${live} this session)` : ''}
+ {points.length > 0 && (
+ latest: {pointSummary(points[points.length - 1])}
+ )}
diff --git a/web/pages/settings/devices.tsx b/web/pages/settings/devices.tsx
index 0e10990..a780dc3 100644
--- a/web/pages/settings/devices.tsx
+++ b/web/pages/settings/devices.tsx
@@ -7,7 +7,7 @@ interface DeviceRow {
id: string;
name: string;
serialNumber: string | null;
- mqttUsername: string;
+ mqttUsername: string | null;
isActive: boolean;
lastSeenAt: string | null;
}
@@ -40,15 +40,19 @@ export default function DevicesPage() {
const addDevice = async (e: FormEvent) => {
e.preventDefault();
try {
- const created = await api.post
(
+ const created = await api.post(
`/api/orgs/${orgId}/devices`,
{
name: form.name,
- mqttUsername: form.mqttUsername,
+ mqttUsername: form.mqttUsername || 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: '' });
reload();
} catch (err: any) {
@@ -98,16 +102,15 @@ export default function DevicesPage() {
style={{ padding: '0.4rem' }}
/>
setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
- required
+ placeholder="serial number"
+ value={form.serialNumber}
+ onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))}
style={{ padding: '0.4rem' }}
/>
setForm((f) => ({ ...f, serialNumber: e.target.value }))}
+ placeholder="mqtt username (only if it connects itself)"
+ value={form.mqttUsername}
+ onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))}
style={{ padding: '0.4rem' }}
/>
@@ -129,9 +132,7 @@ export default function DevicesPage() {
{devices.map((d) => (
| {d.name} |
-
- {d.mqttUsername}
- |
+ {d.mqttUsername ? {d.mqttUsername} : '—'} |
{d.serialNumber ?? '—'} |
{d.isActive ? 'active' : 'disabled'} |
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'} |