Add device-certificate mTLS auth, live position tracking, and API docs
Introduces a CA/PKI module so field devices can authenticate to Mosquitto over TLS (8883) with per-device client certificates (CN = serial number) instead of a shared password, with matching Devices/MQTT-Certs UI. Adds live transmitter position tracking alongside logged points, an MQTTS transport option in the simulator for exercising the real cert-auth path, and Swagger API docs at /api/docs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
@@ -18,98 +19,120 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
// Field names here are terse (lat/lng/alt/hAcc/...) to keep MQTT payloads
|
||||
// small; this same shape is reused as the sim tool's REST request body.
|
||||
export class MqttPointDto {
|
||||
@ApiProperty({ minimum: -90, maximum: 90 })
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
lat: number;
|
||||
|
||||
@ApiProperty({ minimum: -180, maximum: 180 })
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
lng: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Altitude, meters' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
alt?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: GpsFixType })
|
||||
@IsOptional()
|
||||
@IsEnum(GpsFixType)
|
||||
fix?: GpsFixType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Horizontal accuracy, meters', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hAcc?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Meters below grade', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
depth?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: UtilityType })
|
||||
@IsOptional()
|
||||
@IsEnum(UtilityType)
|
||||
utility?: UtilityType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ordering within a locate run' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
seq?: number;
|
||||
|
||||
// GPS quality
|
||||
@ApiPropertyOptional({ description: 'Vertical accuracy, meters', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vAcc?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sats?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
hdop?: number;
|
||||
|
||||
// Locator receiver telemetry
|
||||
@ApiPropertyOptional({ description: 'Locate frequency, Hz', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
freqHz?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Signal current on the line, mA', minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
currentMa?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Signal strength, dB' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
signalDb?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Receiver gain, dB' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
gainDb?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: LocateMode })
|
||||
@IsOptional()
|
||||
@IsEnum(LocateMode)
|
||||
mode?: LocateMode;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Degrees' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
phaseDeg?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Line direction, 0-360', minimum: 0, maximum: 360 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(360)
|
||||
compassDeg?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0, maximum: 100 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
distortionPct?: number;
|
||||
|
||||
@ApiProperty({ format: 'date-time', description: 'Device GPS timestamp' })
|
||||
@IsDateString()
|
||||
ts: string;
|
||||
}
|
||||
@@ -149,9 +172,11 @@ export type MqttLogMessageType = (typeof MQTT_LOG_MESSAGE_TYPES)[number];
|
||||
// what happens to it: "log" persists a LocatePoint; "status" is an ephemeral
|
||||
// current-position update, broadcast live but never written to the DB.
|
||||
export class MqttLogMessageDto extends MqttPointDto {
|
||||
@ApiProperty({ enum: MQTT_LOG_MESSAGE_TYPES, description: '"log" persists a point; "status" is live-only' })
|
||||
@IsIn(MQTT_LOG_MESSAGE_TYPES)
|
||||
type: MqttLogMessageType;
|
||||
|
||||
@ApiProperty({ description: 'Job this reading belongs to; also supplies the organization' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
jobId: string;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device } from '@prisma/client';
|
||||
import { Device, Prisma } from '@prisma/client';
|
||||
import { DevicePositionSnapshot } from '../devices/device-position';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocatorRegistryService {
|
||||
private readonly logger = new Logger(LocatorRegistryService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
// Serial numbers are globally unique, so a bare serial identifies a locator
|
||||
// regardless of which org's data pipeline saw it first. Unknown serials are
|
||||
@@ -34,4 +39,52 @@ export class LocatorRegistryService {
|
||||
return raced;
|
||||
}
|
||||
}
|
||||
|
||||
// Records the freshest known position for a device (whether from a live
|
||||
// "status" ping or a persisted "log" point) and broadcasts it to anyone
|
||||
// watching the org's devices page, so it updates without a manual refresh.
|
||||
async recordPosition(orgId: string, deviceId: string, snapshot: DevicePositionSnapshot) {
|
||||
const lastSeenAt = new Date();
|
||||
await this.prisma.device
|
||||
.update({
|
||||
where: { id: deviceId },
|
||||
data: {
|
||||
lastPosition: snapshot as unknown as Prisma.InputJsonValue,
|
||||
lastPositionAt: new Date(snapshot.recordedAt),
|
||||
lastSeenAt,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
this.realtime.publish(`org:${orgId}:devices`, {
|
||||
type: 'device',
|
||||
orgId,
|
||||
deviceId,
|
||||
lastSeenAt: lastSeenAt.toISOString(),
|
||||
position: {
|
||||
id: `live-${deviceId}`,
|
||||
lat: snapshot.lat,
|
||||
lng: snapshot.lng,
|
||||
altitude: snapshot.altitude,
|
||||
utilityType: snapshot.utilityType,
|
||||
fixType: snapshot.fixType,
|
||||
sequence: null,
|
||||
recordedAt: snapshot.recordedAt,
|
||||
hAccuracy: snapshot.hAccuracy,
|
||||
vAccuracy: snapshot.vAccuracy,
|
||||
satellites: snapshot.satellites,
|
||||
hdop: snapshot.hdop,
|
||||
depth: snapshot.depth,
|
||||
frequencyHz: snapshot.frequencyHz,
|
||||
currentMa: snapshot.currentMa,
|
||||
signalDb: snapshot.signalDb,
|
||||
gainDb: snapshot.gainDb,
|
||||
locateMode: snapshot.locateMode,
|
||||
phaseDeg: snapshot.phaseDeg,
|
||||
compassDeg: snapshot.compassDeg,
|
||||
distortionPct: snapshot.distortionPct,
|
||||
},
|
||||
job: { id: snapshot.jobId, ticketNumber: snapshot.jobTicketNumber, title: snapshot.jobTitle },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { DevicePositionSnapshot } from '../devices/device-position';
|
||||
import { toPointDto } from '../points/points.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -43,6 +44,32 @@ export class LogIngestService {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: DevicePositionSnapshot = {
|
||||
lat: msg.lat,
|
||||
lng: msg.lng,
|
||||
altitude: msg.alt ?? null,
|
||||
fixType: msg.fix ?? 'NONE',
|
||||
utilityType: msg.utility ?? 'UNKNOWN',
|
||||
hAccuracy: msg.hAcc ?? null,
|
||||
vAccuracy: msg.vAcc ?? null,
|
||||
satellites: msg.sats ?? null,
|
||||
hdop: msg.hdop ?? null,
|
||||
depth: msg.depth ?? null,
|
||||
frequencyHz: msg.freqHz ?? null,
|
||||
currentMa: msg.currentMa ?? null,
|
||||
signalDb: msg.signalDb ?? null,
|
||||
gainDb: msg.gainDb ?? null,
|
||||
locateMode: msg.mode ?? null,
|
||||
phaseDeg: msg.phaseDeg ?? null,
|
||||
compassDeg: msg.compassDeg ?? null,
|
||||
distortionPct: msg.distortionPct ?? null,
|
||||
recordedAt: msg.ts,
|
||||
jobId: job.id,
|
||||
jobTicketNumber: job.ticketNumber,
|
||||
jobTitle: job.title,
|
||||
};
|
||||
await this.locatorRegistry.recordPosition(job.orgId, locator.id, snapshot);
|
||||
|
||||
if (msg.type === 'status') {
|
||||
this.realtime.publish(`job:${job.id}`, {
|
||||
type: 'status',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Device, Job } from '@prisma/client';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { DevicePositionSnapshot } from '../devices/device-position';
|
||||
import { toPointDto } from '../points/points.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
@@ -72,6 +73,34 @@ export class PointsIngestService {
|
||||
jobId: job.id,
|
||||
points: points.map(toPointDto),
|
||||
});
|
||||
|
||||
const latest = msg.points.reduce((a, b) => (new Date(b.ts) > new Date(a.ts) ? b : a));
|
||||
const snapshot: DevicePositionSnapshot = {
|
||||
lat: latest.lat,
|
||||
lng: latest.lng,
|
||||
altitude: latest.alt ?? null,
|
||||
fixType: latest.fix ?? 'NONE',
|
||||
utilityType: latest.utility ?? 'UNKNOWN',
|
||||
hAccuracy: latest.hAcc ?? null,
|
||||
vAccuracy: latest.vAcc ?? null,
|
||||
satellites: latest.sats ?? null,
|
||||
hdop: latest.hdop ?? null,
|
||||
depth: latest.depth ?? null,
|
||||
frequencyHz: latest.freqHz ?? null,
|
||||
currentMa: latest.currentMa ?? null,
|
||||
signalDb: latest.signalDb ?? null,
|
||||
gainDb: latest.gainDb ?? null,
|
||||
locateMode: latest.mode ?? null,
|
||||
phaseDeg: latest.phaseDeg ?? null,
|
||||
compassDeg: latest.compassDeg ?? null,
|
||||
distortionPct: latest.distortionPct ?? null,
|
||||
recordedAt: latest.ts,
|
||||
jobId: job.id,
|
||||
jobTicketNumber: job.ticketNumber,
|
||||
jobTitle: job.title,
|
||||
};
|
||||
await this.locatorRegistry.recordPosition(device.orgId, locator.id, snapshot);
|
||||
|
||||
this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user