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>
145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { toPointDto } from '../points/points.service';
|
|
import { RealtimeService } from '../realtime/realtime.service';
|
|
import { DevicePositionSnapshot } from './device-position';
|
|
import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto';
|
|
|
|
@Injectable()
|
|
export class DevicesService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly realtime: RealtimeService,
|
|
) {}
|
|
|
|
list(orgId: string) {
|
|
return this.prisma.device.findMany({
|
|
where: { orgId },
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
}
|
|
|
|
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: { serialNumber: dto.serialNumber },
|
|
});
|
|
if (existing) {
|
|
throw new ConflictException('A device with this serial number already exists');
|
|
}
|
|
}
|
|
const device = await this.prisma.device.create({
|
|
data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber },
|
|
});
|
|
return {
|
|
...device,
|
|
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.',
|
|
}
|
|
: {
|
|
logTopic: device.serialNumber ? `devices/${device.serialNumber}/log` : undefined,
|
|
note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}", or a direct-connect locator can publish single readings to devices/${device.serialNumber}/log.`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async update(orgId: string, deviceId: string, dto: UpdateDeviceDto) {
|
|
await this.get(orgId, deviceId);
|
|
const { disabledReason, ...rest } = dto;
|
|
const device = await this.prisma.device.update({
|
|
where: { id: deviceId },
|
|
data: {
|
|
...rest,
|
|
// A reason only makes sense while disabled; re-enabling always clears it.
|
|
...(dto.isActive === true && { disabledReason: null }),
|
|
...(dto.isActive === false && { disabledReason: disabledReason ?? null }),
|
|
},
|
|
});
|
|
this.realtime.publish(`org:${orgId}:devices`, {
|
|
type: 'device',
|
|
orgId,
|
|
deviceId,
|
|
isActive: device.isActive,
|
|
disabledReason: device.disabledReason,
|
|
});
|
|
return device;
|
|
}
|
|
|
|
async remove(orgId: string, deviceId: string) {
|
|
await this.get(orgId, deviceId);
|
|
await this.prisma.device.delete({ where: { id: deviceId } });
|
|
return { ok: true };
|
|
}
|
|
|
|
async getLocation(orgId: string, deviceId: string) {
|
|
const device = await this.get(orgId, deviceId);
|
|
const point = await this.prisma.locatePoint.findFirst({
|
|
where: { deviceId },
|
|
orderBy: { recordedAt: 'desc' },
|
|
include: { job: { select: { id: true, ticketNumber: true, title: true } } },
|
|
});
|
|
|
|
// A live "status" ping is never persisted as a LocatePoint, so the most
|
|
// recent position may only exist on the device's lastPosition snapshot —
|
|
// compare timestamps and use whichever source is actually newer.
|
|
const live = device.lastPosition as unknown as DevicePositionSnapshot | null;
|
|
if (live && device.lastPositionAt && (!point || device.lastPositionAt > point.recordedAt)) {
|
|
return {
|
|
point: {
|
|
id: `live-${deviceId}`,
|
|
lat: live.lat,
|
|
lng: live.lng,
|
|
altitude: live.altitude,
|
|
utilityType: live.utilityType,
|
|
fixType: live.fixType,
|
|
sequence: null,
|
|
recordedAt: live.recordedAt,
|
|
hAccuracy: live.hAccuracy,
|
|
vAccuracy: live.vAccuracy,
|
|
satellites: live.satellites,
|
|
hdop: live.hdop,
|
|
depth: live.depth,
|
|
frequencyHz: live.frequencyHz,
|
|
currentMa: live.currentMa,
|
|
signalDb: live.signalDb,
|
|
gainDb: live.gainDb,
|
|
locateMode: live.locateMode,
|
|
phaseDeg: live.phaseDeg,
|
|
compassDeg: live.compassDeg,
|
|
distortionPct: live.distortionPct,
|
|
},
|
|
job: { id: live.jobId, ticketNumber: live.jobTicketNumber, title: live.jobTitle },
|
|
};
|
|
}
|
|
|
|
if (!point) {
|
|
return { point: null, job: null };
|
|
}
|
|
const { job, ...rest } = point;
|
|
return { point: toPointDto(rest), job };
|
|
}
|
|
|
|
private async get(orgId: string, deviceId: string) {
|
|
const device = await this.prisma.device.findFirst({ where: { id: deviceId, orgId } });
|
|
if (!device) {
|
|
throw new NotFoundException('Device not found');
|
|
}
|
|
return device;
|
|
}
|
|
}
|