diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql b/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql new file mode 100644 index 0000000..35ce8ca --- /dev/null +++ b/backend/prisma/migrations/20260714130000_locator_telemetry/migration.sql @@ -0,0 +1,25 @@ +-- Locator receiver telemetry + device identity by serial number. +-- NOTE: hand-edited — Prisma's diff wanted to drop the geom GIST index and the +-- generated-column expression on locate_points.geom; those statements were removed. + +-- CreateEnum +CREATE TYPE "LocateMode" AS ENUM ('PEAK', 'NULL', 'BROAD_PEAK', 'SONDE'); + +-- AlterTable +ALTER TABLE "devices" ALTER COLUMN "mqttUsername" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "locate_points" ADD COLUMN "compassDeg" DECIMAL(5,2), +ADD COLUMN "currentMa" DECIMAL(9,3), +ADD COLUMN "distortionPct" DECIMAL(5,2), +ADD COLUMN "frequencyHz" INTEGER, +ADD COLUMN "gainDb" DECIMAL(6,2), +ADD COLUMN "hdop" DECIMAL(4,2), +ADD COLUMN "locateMode" "LocateMode", +ADD COLUMN "phaseDeg" DECIMAL(6,2), +ADD COLUMN "satellites" INTEGER, +ADD COLUMN "signalDb" DECIMAL(6,2), +ADD COLUMN "vAccuracy" DECIMAL(7,3); + +-- CreateIndex +CREATE UNIQUE INDEX "devices_orgId_serialNumber_key" ON "devices"("orgId", "serialNumber"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3b12e22..c7fc54e 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -45,6 +45,14 @@ enum GpsFixType { FIXED_RTK } +// EM locator receiver antenna mode used when the point was captured +enum LocateMode { + PEAK + NULL + BROAD_PEAK + SONDE +} + model Organization { id String @id @default(cuid()) name String @@ -116,12 +124,16 @@ model Job { @@map("jobs") } +// A device is either a locator receiver (identified by serialNumber, usually +// auto-registered from incoming data) or an MQTT publisher (app/gateway with +// broker credentials, identified by mqttUsername) — or both, when a locator +// connects to the broker directly. model Device { id String @id @default(cuid()) orgId String name String serialNumber String? - mqttUsername String @unique + mqttUsername String? @unique isActive Boolean @default(true) lastSeenAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) @@ -130,6 +142,7 @@ model Device { org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) points LocatePoint[] + @@unique([orgId, serialNumber]) @@map("devices") } @@ -140,15 +153,31 @@ model LocatePoint { lat Decimal @db.Decimal(10, 8) lng Decimal @db.Decimal(11, 8) altitude Decimal? @db.Decimal(8, 3) - fixType GpsFixType @default(NONE) - hAccuracy Decimal? @db.Decimal(7, 3) - depth Decimal? @db.Decimal(6, 3) utilityType UtilityType @default(UNKNOWN) sequence Int? - recordedAt DateTime @db.Timestamptz(6) - receivedAt DateTime @default(now()) @db.Timestamptz(6) - raw Json? - geom Unsupported("geometry(Point, 4326)")? + + // GPS quality + fixType GpsFixType @default(NONE) + hAccuracy Decimal? @db.Decimal(7, 3) // meters + vAccuracy Decimal? @db.Decimal(7, 3) // meters + satellites Int? + hdop Decimal? @db.Decimal(4, 2) + + // Locator receiver telemetry + depth Decimal? @db.Decimal(6, 3) // meters below grade + frequencyHz Int? // active/passive locate frequency + currentMa Decimal? @db.Decimal(9, 3) // signal current on the line + signalDb Decimal? @db.Decimal(6, 2) // signal strength + gainDb Decimal? @db.Decimal(6, 2) // receiver gain + locateMode LocateMode? + phaseDeg Decimal? @db.Decimal(6, 2) + compassDeg Decimal? @db.Decimal(5, 2) // line direction, 0-360 + distortionPct Decimal? @db.Decimal(5, 2) + + recordedAt DateTime @db.Timestamptz(6) + receivedAt DateTime @default(now()) @db.Timestamptz(6) + raw Json? + geom Unsupported("geometry(Point, 4326)")? job Job @relation(fields: [jobId], references: [id], onDelete: Cascade) device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index eb367e4..e1855c0 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,4 +1,4 @@ -import { PrismaClient, GpsFixType, JobStatus, UtilityType } from '@prisma/client'; +import { PrismaClient, GpsFixType, JobStatus, LocateMode, UtilityType } from '@prisma/client'; import * as bcrypt from 'bcryptjs'; const prisma = new PrismaClient(); @@ -68,7 +68,17 @@ async function main() { altitude: 187.4 + i * 0.02, fixType: GpsFixType.FIXED_RTK, hAccuracy: 0.014, + vAccuracy: 0.021, + satellites: 22, + hdop: 0.7, depth: 1.2, + frequencyHz: 33000, + currentMa: 48.5 - i * 0.4, + signalDb: 62.1 - i * 0.3, + gainDb: 40, + locateMode: LocateMode.PEAK, + compassDeg: 17.5, + distortionPct: 4.2, utilityType: UtilityType.GAS, sequence: i + 1, recordedAt: new Date(Date.now() - (12 - i) * 5000), diff --git a/backend/src/devices/devices.service.ts b/backend/src/devices/devices.service.ts index 026764c..0e29f1b 100644 --- a/backend/src/devices/devices.service.ts +++ b/backend/src/devices/devices.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateDeviceDto, UpdateDeviceDto } from './dto/devices.dto'; @@ -14,23 +14,40 @@ export class DevicesService { } async create(orgId: string, dto: CreateDeviceDto) { - 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.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: { orgId_serialNumber: { orgId, serialNumber: dto.serialNumber } }, + }); + if (existing) { + throw new ConflictException('A device with this serial number already exists in this organization'); + } } const device = await this.prisma.device.create({ data: { orgId, name: dto.name, mqttUsername: dto.mqttUsername, serialNumber: dto.serialNumber }, }); return { ...device, - provisioning: { - 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.', - }, + 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.', + } + : { + note: `Relayed locator: points published by a gateway should carry "serial": "${device.serialNumber}".`, + }, }; } diff --git a/backend/src/devices/dto/devices.dto.ts b/backend/src/devices/dto/devices.dto.ts index d02939a..885b109 100644 --- a/backend/src/devices/dto/devices.dto.ts +++ b/backend/src/devices/dto/devices.dto.ts @@ -6,16 +6,18 @@ export class CreateDeviceDto { @MaxLength(120) name: string; - // Must match the broker username (or future TLS cert CN) the device connects with + // Broker username (or future TLS cert CN) if this device connects to MQTT + // itself; locators relayed by a gateway need only a serial number. + @IsOptional() @IsString() @Matches(/^[a-zA-Z0-9._-]{3,64}$/, { message: 'mqttUsername must be 3-64 chars of letters, digits, dot, dash, underscore', }) - mqttUsername: string; + mqttUsername?: string; @IsOptional() @IsString() - @MaxLength(120) + @MaxLength(64) serialNumber?: string; } diff --git a/backend/src/ingest/dto/mqtt-messages.dto.ts b/backend/src/ingest/dto/mqtt-messages.dto.ts index 5d2817f..c77bcfc 100644 --- a/backend/src/ingest/dto/mqtt-messages.dto.ts +++ b/backend/src/ingest/dto/mqtt-messages.dto.ts @@ -1,4 +1,4 @@ -import { GpsFixType, UtilityType } from '@prisma/client'; +import { GpsFixType, LocateMode, UtilityType } from '@prisma/client'; import { Type } from 'class-transformer'; import { ArrayMaxSize, @@ -54,6 +54,61 @@ export class MqttPointDto { @IsInt() seq?: number; + // GPS quality + @IsOptional() + @IsNumber() + @Min(0) + vAcc?: number; + + @IsOptional() + @IsInt() + @Min(0) + sats?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hdop?: number; + + // Locator receiver telemetry + @IsOptional() + @IsInt() + @Min(0) + freqHz?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentMa?: number; + + @IsOptional() + @IsNumber() + signalDb?: number; + + @IsOptional() + @IsNumber() + gainDb?: number; + + @IsOptional() + @IsEnum(LocateMode) + mode?: LocateMode; + + @IsOptional() + @IsNumber() + phaseDeg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(360) + compassDeg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + distortionPct?: number; + @IsDateString() ts: string; } @@ -69,6 +124,13 @@ export class MqttPointsMessageDto { @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) diff --git a/backend/src/ingest/points-ingest.service.ts b/backend/src/ingest/points-ingest.service.ts index aa1fcd2..e715f5b 100644 --- a/backend/src/ingest/points-ingest.service.ts +++ b/backend/src/ingest/points-ingest.service.ts @@ -33,18 +33,31 @@ export class PointsIngestService { return; } + const locator = await this.resolveLocator(device, msg.serial); + const points = await this.prisma.locatePoint.createManyAndReturn({ data: msg.points.map((p) => ({ jobId: job.id, - deviceId: device.id, + deviceId: locator.id, lat: p.lat, lng: p.lng, altitude: p.alt, - fixType: p.fix, - hAccuracy: p.hAcc, - depth: p.depth, utilityType: p.utility, sequence: p.seq, + fixType: p.fix, + hAccuracy: p.hAcc, + vAccuracy: p.vAcc, + satellites: p.sats, + hdop: p.hdop, + depth: p.depth, + frequencyHz: p.freqHz, + currentMa: p.currentMa, + signalDb: p.signalDb, + gainDb: p.gainDb, + locateMode: p.mode, + phaseDeg: p.phaseDeg, + compassDeg: p.compassDeg, + distortionPct: p.distortionPct, recordedAt: new Date(p.ts), raw: p as object, })), @@ -58,6 +71,42 @@ export class PointsIngestService { this.logger.debug(`Stored ${points.length} points for job ${job.ticketNumber}`); } + // Points are attributed to the locator receiver named by its serial number. + // The publishing MQTT credential (a phone/gateway, possibly relaying for + // several locators) only establishes the org; unknown serials are + // auto-registered so field data is never dropped. + private async resolveLocator(publisher: Device, serial: string | undefined): Promise { + if (!serial || serial === publisher.serialNumber) { + return publisher; + } + const existing = await this.prisma.device.findUnique({ + where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } }, + }); + if (existing) { + this.prisma.device + .update({ where: { id: existing.id }, data: { lastSeenAt: new Date() } }) + .catch(() => undefined); + return existing; + } + this.logger.log(`Auto-registering locator serial ${serial} (via ${publisher.mqttUsername})`); + try { + return await this.prisma.device.create({ + data: { + orgId: publisher.orgId, + name: `Locator ${serial}`, + serialNumber: serial, + lastSeenAt: new Date(), + }, + }); + } catch { + // lost a concurrent-registration race; the row exists now + const raced = await this.prisma.device.findUnique({ + where: { orgId_serialNumber: { orgId: publisher.orgId, serialNumber: serial } }, + }); + return raced ?? publisher; + } + } + private async resolveJob(device: Device, msg: MqttPointsMessageDto): Promise { if (msg.jobId) { const job = await this.prisma.job.findFirst({ diff --git a/backend/src/points/dto/points.dto.ts b/backend/src/points/dto/points.dto.ts index c82dacb..2715fe7 100644 --- a/backend/src/points/dto/points.dto.ts +++ b/backend/src/points/dto/points.dto.ts @@ -1,4 +1,4 @@ -import { GpsFixType, UtilityType } from '@prisma/client'; +import { GpsFixType, LocateMode, UtilityType } from '@prisma/client'; import { Type } from 'class-transformer'; import { IsDateString, @@ -49,6 +49,61 @@ export class CreatePointDto { @IsInt() sequence?: number; + // GPS quality + @IsOptional() + @IsNumber() + @Min(0) + vAccuracy?: number; + + @IsOptional() + @IsInt() + @Min(0) + satellites?: number; + + @IsOptional() + @IsNumber() + @Min(0) + hdop?: number; + + // Locator receiver telemetry + @IsOptional() + @IsInt() + @Min(0) + frequencyHz?: number; + + @IsOptional() + @IsNumber() + @Min(0) + currentMa?: number; + + @IsOptional() + @IsNumber() + signalDb?: number; + + @IsOptional() + @IsNumber() + gainDb?: number; + + @IsOptional() + @IsEnum(LocateMode) + locateMode?: LocateMode; + + @IsOptional() + @IsNumber() + phaseDeg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(360) + compassDeg?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + distortionPct?: number; + @IsDateString() recordedAt: string; } diff --git a/backend/src/points/points.service.ts b/backend/src/points/points.service.ts index 3d29885..3c21eb0 100644 --- a/backend/src/points/points.service.ts +++ b/backend/src/points/points.service.ts @@ -11,15 +11,30 @@ export interface PointDto { lat: number; lng: number; altitude: number | null; - fixType: string; - hAccuracy: number | null; - depth: number | null; utilityType: string; sequence: number | null; + fixType: string; + hAccuracy: number | null; + vAccuracy: number | null; + satellites: number | null; + hdop: number | null; + depth: number | null; + frequencyHz: number | null; + currentMa: number | null; + signalDb: number | null; + gainDb: number | null; + locateMode: string | null; + phaseDeg: number | null; + compassDeg: number | null; + distortionPct: number | null; recordedAt: Date; receivedAt: Date; } +function num(value: unknown): number | null { + return value === null || value === undefined ? null : Number(value); +} + export function toPointDto(p: LocatePoint): PointDto { return { id: p.id.toString(), @@ -27,12 +42,23 @@ export function toPointDto(p: LocatePoint): PointDto { deviceId: p.deviceId, lat: Number(p.lat), lng: Number(p.lng), - altitude: p.altitude === null ? null : Number(p.altitude), - fixType: p.fixType, - hAccuracy: p.hAccuracy === null ? null : Number(p.hAccuracy), - depth: p.depth === null ? null : Number(p.depth), + altitude: num(p.altitude), utilityType: p.utilityType, sequence: p.sequence, + fixType: p.fixType, + hAccuracy: num(p.hAccuracy), + vAccuracy: num(p.vAccuracy), + satellites: p.satellites, + hdop: num(p.hdop), + depth: num(p.depth), + frequencyHz: p.frequencyHz, + currentMa: num(p.currentMa), + signalDb: num(p.signalDb), + gainDb: num(p.gainDb), + locateMode: p.locateMode, + phaseDeg: num(p.phaseDeg), + compassDeg: num(p.compassDeg), + distortionPct: num(p.distortionPct), recordedAt: p.recordedAt, receivedAt: p.receivedAt, }; @@ -64,19 +90,13 @@ export class PointsService { if (!job) { throw new NotFoundException('Job not found'); } + const { recordedAt, ...fields } = dto; const point = await this.prisma.locatePoint.create({ data: { jobId, deviceId, - lat: dto.lat, - lng: dto.lng, - altitude: dto.altitude, - fixType: dto.fixType, - hAccuracy: dto.hAccuracy, - depth: dto.depth, - utilityType: dto.utilityType, - sequence: dto.sequence, - recordedAt: new Date(dto.recordedAt), + ...fields, + recordedAt: new Date(recordedAt), }, }); return toPointDto(point); diff --git a/test/publish_sample.py b/test/publish_sample.py index fda0230..3caa6e9 100644 --- a/test/publish_sample.py +++ b/test/publish_sample.py @@ -3,11 +3,16 @@ The backend subscribes to devices/# and expects points at devices/{mqttUsername}/points with a JSON body: - {"ticket": "TKT-...", "points": [{"lat": .., "lng": .., "ts": "ISO8601", ...}]} + {"serial": "...", "ticket": "TKT-...", + "points": [{"lat": .., "lng": .., "ts": "ISO8601", ...}]} -Points for an unknown ticket auto-create a stub job (source=DEVICE). +The MQTT username identifies the publisher (app/gateway) and its org; "serial" +names the locator receiver the readings came from (auto-registered on first +sight). Points for an unknown ticket auto-create a stub job (source=DEVICE). """ +import random + import json import os import time @@ -19,6 +24,7 @@ BROKER_HOST = os.getenv("MQTT_HOST", "127.0.0.1") BROKER_PORT = int(os.getenv("MQTT_PORT", "1883")) USERNAME = os.getenv("MQTT_USERNAME", "testuser") PASSWORD = os.getenv("MQTT_PASSWORD", "testpass") +SERIAL = os.getenv("SERIAL", "DEMO-0001") TICKET = os.getenv("TICKET", "TKT-2026-0001") INTERVAL = float(os.getenv("MQTT_INTERVAL", "2")) @@ -37,6 +43,7 @@ try: seq = 0 while True: payload = { + "serial": SERIAL, "ticket": TICKET, "points": [ { @@ -45,7 +52,17 @@ try: "alt": 187.4 + seq * 0.02, "fix": "FIXED_RTK", "hAcc": 0.014, - "depth": 1.2, + "vAcc": 0.021, + "sats": random.randint(18, 26), + "hdop": 0.7, + "depth": round(1.1 + random.uniform(0, 0.3), 2), + "freqHz": 33000, + "currentMa": round(50 - seq * 0.4 + random.uniform(-1, 1), 1), + "signalDb": round(62 - seq * 0.2 + random.uniform(-0.5, 0.5), 1), + "gainDb": 40, + "mode": "PEAK", + "compassDeg": round(17.5 + random.uniform(-3, 3), 1), + "distortionPct": round(random.uniform(2, 8), 1), "utility": "GAS", "seq": seq + 1, "ts": datetime.now(timezone.utc).isoformat(), diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..b90a368 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/web/components/map/google/GoogleJobMap.tsx b/web/components/map/google/GoogleJobMap.tsx index 60152b0..4678521 100644 --- a/web/components/map/google/GoogleJobMap.tsx +++ b/web/components/map/google/GoogleJobMap.tsx @@ -1,7 +1,7 @@ /// import { APIProvider, Map as GoogleMap, useMap } from '@vis.gl/react-google-maps'; import { useEffect, useRef } from 'react'; -import { JobMapProps, MapPoint, UTILITY_COLORS } from '../types'; +import { JobMapProps, MapPoint, pointSummary, UTILITY_COLORS } from '../types'; const API_KEY = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY || ''; @@ -67,7 +67,7 @@ function PointsLayer({ points, fitBounds }: { points: MapPoint[]; fitBounds: boo strokeColor: '#ffffff', strokeWeight: 1.5, }, - title: `${p.utilityType} · ${p.fixType}${p.depth != null ? ` · depth ${p.depth}m` : ''}\n${new Date(p.recordedAt).toLocaleString()}`, + title: `${pointSummary(p)}\n${new Date(p.recordedAt).toLocaleString()}`, }); overlaysRef.current.push(marker); } diff --git a/web/components/map/types.ts b/web/components/map/types.ts index 4b77ff3..16822f0 100644 --- a/web/components/map/types.ts +++ b/web/components/map/types.ts @@ -8,9 +8,27 @@ export interface MapPoint { lng: number; utilityType: string; fixType: string; - depth: number | null; sequence: number | null; recordedAt: string; + // Locator receiver telemetry (all optional — depends on the instrument) + depth: number | null; + frequencyHz: number | null; + currentMa: number | null; + signalDb: number | null; + gainDb: number | null; + locateMode: string | null; + compassDeg: number | null; + distortionPct: number | null; +} + +export function pointSummary(p: MapPoint): string { + const parts = [`${p.utilityType} · ${p.fixType}`]; + if (p.depth != null) parts.push(`depth ${p.depth}m`); + if (p.frequencyHz != null) + parts.push(p.frequencyHz >= 1000 ? `${p.frequencyHz / 1000}kHz` : `${p.frequencyHz}Hz`); + if (p.currentMa != null) parts.push(`${p.currentMa}mA`); + if (p.locateMode) parts.push(p.locateMode); + return parts.join(' · '); } export interface JobMapProps { diff --git a/web/pages/jobs/[jobId].tsx b/web/pages/jobs/[jobId].tsx index f278954..c7b9674 100644 --- a/web/pages/jobs/[jobId].tsx +++ b/web/pages/jobs/[jobId].tsx @@ -2,7 +2,7 @@ import { useRouter } from 'next/router'; import { useCallback, useEffect, useState } from 'react'; import Layout from '../../components/Layout'; import JobMap from '../../components/map/JobMap'; -import type { MapPoint } from '../../components/map/types'; +import { pointSummary, type MapPoint } from '../../components/map/types'; import { api } from '../../lib/api'; import { useRequireAuth } from '../../lib/auth-context'; import { useJobStream } from '../../lib/use-job-stream'; @@ -98,13 +98,16 @@ export default function JobDetailPage() {

{job.description &&

{job.description}

} -
+
{points.length} points 0 ? '#388e3c' : '#999' }}> ● live{live > 0 ? ` (+${live} this session)` : ''} + {points.length > 0 && ( + latest: {pointSummary(points[points.length - 1])} + )}
diff --git a/web/pages/settings/devices.tsx b/web/pages/settings/devices.tsx index 0e10990..a780dc3 100644 --- a/web/pages/settings/devices.tsx +++ b/web/pages/settings/devices.tsx @@ -7,7 +7,7 @@ interface DeviceRow { id: string; name: string; serialNumber: string | null; - mqttUsername: string; + mqttUsername: string | null; isActive: boolean; lastSeenAt: string | null; } @@ -40,15 +40,19 @@ export default function DevicesPage() { const addDevice = async (e: FormEvent) => { e.preventDefault(); try { - const created = await api.post( + const created = await api.post( `/api/orgs/${orgId}/devices`, { name: form.name, - mqttUsername: form.mqttUsername, + mqttUsername: form.mqttUsername || undefined, serialNumber: form.serialNumber || undefined, }, ); - setNotice(`Device created. It should publish points to ${created.provisioning.pointsTopic}`); + setNotice( + created.provisioning.pointsTopic + ? `Device created. It should publish points to ${created.provisioning.pointsTopic}` + : created.provisioning.note, + ); setForm({ name: '', mqttUsername: '', serialNumber: '' }); reload(); } catch (err: any) { @@ -98,16 +102,15 @@ export default function DevicesPage() { style={{ padding: '0.4rem' }} /> setForm((f) => ({ ...f, mqttUsername: e.target.value }))} - required + placeholder="serial number" + value={form.serialNumber} + onChange={(e) => setForm((f) => ({ ...f, serialNumber: e.target.value }))} style={{ padding: '0.4rem' }} /> setForm((f) => ({ ...f, serialNumber: e.target.value }))} + placeholder="mqtt username (only if it connects itself)" + value={form.mqttUsername} + onChange={(e) => setForm((f) => ({ ...f, mqttUsername: e.target.value }))} style={{ padding: '0.4rem' }} /> @@ -129,9 +132,7 @@ export default function DevicesPage() { {devices.map((d) => ( {d.name} - - {d.mqttUsername} - + {d.mqttUsername ? {d.mqttUsername} : '—'} {d.serialNumber ?? '—'} {d.isActive ? 'active' : 'disabled'} {d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'never'}