Files
ulweb/backend/src/devices/devices.service.ts
ulhub 842cb23e1f 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>
2026-07-18 01:40:12 +00:00

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;
}
}