Tie points to locator serial numbers and store receiver telemetry

Adds devices/<serial>/log as a job-anchored MQTT ingest path: a locator
identifies itself by serial in the topic (auto-registered on first
sight, org resolved from the job in the payload) rather than by a
pre-provisioned broker credential. Device.serialNumber is now globally
unique so the bare topic segment is enough to resolve identity.
Locator lookup/auto-register logic is shared (LocatorRegistryService)
between this and the existing devices/<mqttUsername>/points path.

New /sim page (own header, outside the main app nav) simulates a
transmitter: pick an open job or create one, set a serial number and
telemetry defaults, and send points one at a time or on an interval
along a simulated walking path. It calls a new authenticated backend
endpoint (POST /orgs/:orgId/sim/publish) that publishes onto the real
broker rather than writing the DB directly, so the simulator exercises
the actual ingest pipeline end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ulhub
2026-07-14 19:41:58 +00:00
parent b7479fa68f
commit e91c91e037
16 changed files with 672 additions and 50 deletions

View File

@@ -1,6 +1,7 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { JobsIngestService } from './jobs-ingest.service';
import { LogIngestService } from './log-ingest.service';
import { MqttClientService } from './mqtt-client.service';
import { PointsIngestService } from './points-ingest.service';
@@ -13,6 +14,7 @@ export class IngestRouterService implements OnModuleInit {
private readonly mqttClient: MqttClientService,
private readonly pointsIngest: PointsIngestService,
private readonly jobsIngest: JobsIngestService,
private readonly logIngest: LogIngestService,
) {}
onModuleInit() {
@@ -27,19 +29,26 @@ export class IngestRouterService implements OnModuleInit {
// Raw log captures every message on devices/#, matched or not (audit/debug trail)
await this.prisma.deviceEvent.create({ data: { topic, payload } });
const [root, username, ...rest] = topic.split('/');
const [root, idSegment, ...rest] = topic.split('/');
const subtopic = rest.join('/');
if (root !== 'devices' || !username || subtopic === 'jobs/ack') {
if (root !== 'devices' || !idSegment || subtopic === 'jobs/ack') {
return; // not device traffic, or our own ack echoed back
}
const device = await this.prisma.device.findUnique({ where: { mqttUsername: username } });
// devices/<serial>/log identifies the locator by serial in the topic
// itself (job in the payload supplies org) — no publisher device lookup.
if (subtopic === 'log') {
await this.logIngest.handle(idSegment, payload);
return;
}
const device = await this.prisma.device.findUnique({ where: { mqttUsername: idSegment } });
if (!device) {
this.logger.warn(`Message from unregistered device username "${username}" (raw-logged only)`);
this.logger.warn(`Message from unregistered device username "${idSegment}" (raw-logged only)`);
return;
}
if (!device.isActive) {
this.logger.warn(`Message from deactivated device "${username}" ignored`);
this.logger.warn(`Message from deactivated device "${idSegment}" ignored`);
return;
}
@@ -57,7 +66,7 @@ export class IngestRouterService implements OnModuleInit {
case 'status':
break; // lastSeenAt already stamped above
default:
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${username}`);
this.logger.debug(`Unhandled subtopic "${subtopic}" from ${idSegment}`);
}
}
}