import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; import { GpsDevice } from './entities/gps-device.entity'; import { Gt06Gps, Gt06Status } from './gt06/gt06.codec'; /** A device is considered ONLINE if seen within this window. */ const ONLINE_WINDOW_MS = 5 * 60 * 1000; @Injectable() export class GpsTrackingService { private readonly logger = new Logger(GpsTrackingService.name); constructor( private readonly devices: GpsDeviceRepository, private readonly positions: GpsPositionRepository, ) {} private isOnline(d: GpsDevice): boolean { return Boolean(d.lastSeenAt && Date.now() - new Date(d.lastSeenAt).getTime() < ONLINE_WINDOW_MS); } /** Find the device for an IMEI, auto-registering it on first contact. */ private async ensureDevice(imei: string): Promise { const existing = await this.devices.findByImei(imei); if (existing) return existing; this.logger.log(`Auto-registering new GPS tracker ${imei}`); return this.devices.create({ imei, status: 'REGISTERED', lastSeenAt: new Date() }); } // ── Ingestion (called by the TCP server) ── async handleLogin(imei: string): Promise { const device = await this.ensureDevice(imei); await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' }); } async handleHeartbeat(imei: string, status: Gt06Status): Promise { const device = await this.ensureDevice(imei); await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE', voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel, }); } async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise { const device = await this.ensureDevice(imei); const now = new Date(); await this.devices.update(device.id, { lastSeenAt: now, status: 'ONLINE', lastLat: gps.latitude, lastLng: gps.longitude, lastSpeed: gps.speed, lastCourse: gps.course, lastFixAt: new Date(gps.time), ...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}), }); await this.positions.create({ deviceId: device.id, imei, vehicleId: device.vehicleId ?? null, lat: gps.latitude, lng: gps.longitude, speed: gps.speed, course: gps.course, satellites: gps.satellites, positioned: gps.positioned, gpsTime: new Date(gps.time), alarm, }); } // ── Queries / management (REST) ── private decorate(d: GpsDevice) { return { ...d, online: this.isOnline(d) }; } async listDevices() { const rows = await this.devices.findAll({ relations: { vehicle: true }, order: { createdAt: 'DESC' } }); return rows.map((d) => this.decorate(d)); } /** Live map feed — devices that have at least one fix. */ async latest() { const rows = await this.devices.findAll({ relations: { vehicle: true } }); return rows.filter((d) => d.lastLat != null && d.lastLng != null).map((d) => this.decorate(d)); } async history(vehicleId: string, limit = 200) { return this.positions.findAll({ where: { vehicleId }, order: { gpsTime: 'DESC' }, take: Math.min(limit, 1000), }); } async registerDevice(dto: { imei: string; name?: string; vehicleId?: string | null }) { const existing = await this.devices.findByImei(dto.imei); if (existing) throw new BadRequestException(`A device with IMEI ${dto.imei} already exists`); return this.devices.create({ imei: dto.imei, name: dto.name ?? null, vehicleId: dto.vehicleId ?? null, status: 'REGISTERED', }); } async updateDevice(id: string, dto: { name?: string; vehicleId?: string | null }) { const updated = await this.devices.update(id, { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), }); if (!updated) throw new NotFoundException(`GPS device ${id} not found`); return updated; } async removeDevice(id: string): Promise { await this.devices.softDelete(id); } }