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
|
||||
}
|
||||
|
||||
// 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,11 +153,27 @@ 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?
|
||||
|
||||
// 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?
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,22 +14,39 @@ export class DevicesService {
|
||||
}
|
||||
|
||||
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({
|
||||
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: {
|
||||
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}".`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
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" />
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
</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>
|
||||
<strong>{points.length}</strong> points
|
||||
</span>
|
||||
<span style={{ color: live > 0 ? '#388e3c' : '#999' }}>
|
||||
● live{live > 0 ? ` (+${live} this session)` : ''}
|
||||
</span>
|
||||
{points.length > 0 && (
|
||||
<span style={{ color: '#777' }}>latest: {pointSummary(points[points.length - 1])}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<JobMap points={points} heightPx={520} />
|
||||
|
||||
@@ -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<DeviceRow & { provisioning: { pointsTopic: string } }>(
|
||||
const created = await api.post<DeviceRow & { provisioning: { pointsTopic?: string; note: string } }>(
|
||||
`/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' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="mqtt username"
|
||||
value={form.mqttUsername}
|
||||
onChange={(e) => 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' }}
|
||||
/>
|
||||
<input
|
||||
placeholder="serial number (optional)"
|
||||
value={form.serialNumber}
|
||||
onChange={(e) => 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' }}
|
||||
/>
|
||||
<button type="submit">Add device</button>
|
||||
@@ -129,9 +132,7 @@ export default function DevicesPage() {
|
||||
{devices.map((d) => (
|
||||
<tr key={d.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||
<td style={{ padding: '0.5rem' }}>{d.name}</td>
|
||||
<td>
|
||||
<code>{d.mqttUsername}</code>
|
||||
</td>
|
||||
<td>{d.mqttUsername ? <code>{d.mqttUsername}</code> : '—'}</td>
|
||||
<td>{d.serialNumber ?? '—'}</td>
|
||||
<td style={{ color: d.isActive ? '#388e3c' : '#9e9e9e' }}>{d.isActive ? 'active' : 'disabled'}</td>
|
||||
<td>{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}</td>
|
||||
|
||||
Reference in New Issue
Block a user