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>
206 lines
4.7 KiB
TypeScript
206 lines
4.7 KiB
TypeScript
import { GpsFixType, LocateMode, UtilityType } from '@prisma/client';
|
|
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
|
import { Type } from 'class-transformer';
|
|
import {
|
|
ArrayMaxSize,
|
|
ArrayMinSize,
|
|
IsArray,
|
|
IsDateString,
|
|
IsEnum,
|
|
IsIn,
|
|
IsInt,
|
|
IsNotEmpty,
|
|
IsNumber,
|
|
IsOptional,
|
|
IsString,
|
|
Max,
|
|
MaxLength,
|
|
Min,
|
|
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;
|
|
}
|
|
|
|
export class MqttPointsMessageDto {
|
|
// Job is resolved by jobId when present, else by (device org, ticket)
|
|
@IsOptional()
|
|
@IsString()
|
|
jobId?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@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)
|
|
@ValidateNested({ each: true })
|
|
@Type(() => MqttPointDto)
|
|
points: MqttPointDto[];
|
|
}
|
|
|
|
export const MQTT_LOG_MESSAGE_TYPES = ['log', 'status'] as const;
|
|
export type MqttLogMessageType = (typeof MQTT_LOG_MESSAGE_TYPES)[number];
|
|
|
|
// Single reading for a locator that publishes directly (or is relayed) to
|
|
// devices/<serial>/log — the serial comes from the topic itself, so identity
|
|
// is job-anchored rather than publisher-credential-anchored. `type` decides
|
|
// 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;
|
|
}
|
|
|
|
export class MqttJobMessageDto {
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
@MaxLength(64)
|
|
ticket: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(200)
|
|
title?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(4000)
|
|
description?: string;
|
|
|
|
@IsOptional()
|
|
@IsString()
|
|
@MaxLength(400)
|
|
address?: string;
|
|
}
|