From 75427ea63a2e3c229f505f51d9d939c334dcfd8a Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 7 Jul 2026 06:39:02 +0000 Subject: [PATCH] fix tracker --- apps/edr-freight-api/src/app.module.ts | 2 + .../2000000000000-AddGpsTracking.ts | 69 ++ .../gps-tracking/dto/gps-device.dto.ts | 24 + .../entities/gps-device.entity.ts | 55 ++ .../entities/gps-position.entity.ts | 43 ++ .../gps-tracking/gps-tracking.controller.ts | 66 ++ .../gps-tracking/gps-tracking.module.ts | 17 + .../gps-tracking/gps-tracking.repository.ts | 29 + .../gps-tracking/gps-tracking.service.ts | 124 ++++ .../modules/gps-tracking/gt06/gt06.codec.ts | 207 ++++++ .../modules/gps-tracking/gt06/gt06.server.ts | 96 +++ apps/edr-freight-web/backoffice/package.json | 2 + .../src/pages/fleet/TrackingPage.tsx | 697 +++++++++--------- .../src/services/gps-tracking.service.ts | 48 ++ pnpm-lock.yaml | 6 + 15 files changed, 1148 insertions(+), 337 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts create mode 100644 apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts create mode 100644 apps/edr-freight-web/backoffice/src/services/gps-tracking.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index efd3287d8..e10fb7e47 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -83,6 +83,7 @@ import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module"; import { IncidentsModule } from "./modules/incidents/incidents.module"; import { ProcurementModule } from "./modules/procurement/procurement.module"; +import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; @@ -154,6 +155,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ComplianceModule, IncidentsModule, ProcurementModule, + GpsTrackingModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts new file mode 100644 index 000000000..3d440c678 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2000000000000-AddGpsTracking.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * GPS tracking: physical trackers (gps_devices, one denormalized latest fix per + * device for the live map) + append-only fix history (gps_positions). + */ +export class AddGpsTracking2000000000000 implements MigrationInterface { + name = "AddGpsTracking2000000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_devices ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + imei varchar(20) NOT NULL UNIQUE, + name varchar, + vehicle_id uuid REFERENCES freight.vehicles(id), + status varchar(16) NOT NULL DEFAULT 'REGISTERED', + last_seen_at timestamptz, + last_lat numeric(10,6), + last_lng numeric(10,6), + last_speed numeric(6,2), + last_course int, + last_fix_at timestamptz, + voltage_level int, + gsm_level int, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_DEVICES_VEHICLE" + ON freight.gps_devices (vehicle_id) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.gps_positions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + device_id uuid NOT NULL, + imei varchar(20) NOT NULL, + vehicle_id uuid, + lat numeric(10,6) NOT NULL, + lng numeric(10,6) NOT NULL, + speed numeric(6,2) NOT NULL DEFAULT 0, + course int NOT NULL DEFAULT 0, + satellites int NOT NULL DEFAULT 0, + positioned boolean NOT NULL DEFAULT false, + gps_time timestamptz NOT NULL, + alarm int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_DEVICE_TIME" + ON freight.gps_positions (device_id, gps_time) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_GPS_POSITIONS_VEHICLE_TIME" + ON freight.gps_positions (vehicle_id, gps_time) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts new file mode 100644 index 000000000..933699f0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/dto/gps-device.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, IsUUID } from 'class-validator'; + +export class RegisterDeviceDto { + @IsString() + imei!: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string; +} + +export class UpdateDeviceDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsUUID() + vehicleId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts new file mode 100644 index 000000000..ac5f7dbc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-device.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +/** + * A physical GPS tracker (GT06). Identified by IMEI, optionally bound to a + * vehicle. Carries the denormalized latest fix so the live map reads one row + * per device without scanning position history. + */ +@Entity({ name: 'gps_devices', schema: 'freight' }) +@Index(['vehicleId']) +export class GpsDevice extends BaseEntity { + @Column({ name: 'imei', type: 'varchar', length: 20, unique: true }) + imei!: string; + + @Column({ name: 'name', type: 'varchar', nullable: true }) + name?: string | null; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @ManyToOne(() => Vehicle, { nullable: true, eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle?: Vehicle | null; + + /** ONLINE once a packet arrives; OFFLINE when stale (derived on read). */ + @Column({ name: 'status', type: 'varchar', length: 16, default: 'REGISTERED' }) + status!: string; + + @Column({ name: 'last_seen_at', type: 'timestamptz', nullable: true }) + lastSeenAt?: Date | null; + + // ── Denormalized latest fix ── + @Column({ name: 'last_lat', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLat?: number | null; + + @Column({ name: 'last_lng', type: 'numeric', precision: 10, scale: 6, nullable: true }) + lastLng?: number | null; + + @Column({ name: 'last_speed', type: 'numeric', precision: 6, scale: 2, nullable: true }) + lastSpeed?: number | null; + + @Column({ name: 'last_course', type: 'int', nullable: true }) + lastCourse?: number | null; + + @Column({ name: 'last_fix_at', type: 'timestamptz', nullable: true }) + lastFixAt?: Date | null; + + @Column({ name: 'voltage_level', type: 'int', nullable: true }) + voltageLevel?: number | null; + + @Column({ name: 'gsm_level', type: 'int', nullable: true }) + gsmLevel?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts new file mode 100644 index 000000000..8c63bb78f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/entities/gps-position.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +/** One GPS fix from a tracker (append-only history). */ +@Entity({ name: 'gps_positions', schema: 'freight' }) +@Index(['deviceId', 'gpsTime']) +@Index(['vehicleId', 'gpsTime']) +export class GpsPosition extends BaseEntity { + @Column({ name: 'device_id', type: 'uuid' }) + deviceId!: string; + + @Column({ name: 'imei', type: 'varchar', length: 20 }) + imei!: string; + + @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) + vehicleId?: string | null; + + @Column({ name: 'lat', type: 'numeric', precision: 10, scale: 6 }) + lat!: number; + + @Column({ name: 'lng', type: 'numeric', precision: 10, scale: 6 }) + lng!: number; + + @Column({ name: 'speed', type: 'numeric', precision: 6, scale: 2, default: 0 }) + speed!: number; + + @Column({ name: 'course', type: 'int', default: 0 }) + course!: number; + + @Column({ name: 'satellites', type: 'int', default: 0 }) + satellites!: number; + + @Column({ name: 'positioned', type: 'boolean', default: false }) + positioned!: boolean; + + /** Fix time reported by the device (UTC). */ + @Column({ name: 'gps_time', type: 'timestamptz' }) + gpsTime!: Date; + + /** Non-zero when the fix came in via an alarm packet. */ + @Column({ name: 'alarm', type: 'int', default: 0 }) + alarm!: number; +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts new file mode 100644 index 000000000..e380e541e --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FleetManage, FleetView } from '../../common/booking-guards'; +import { GpsTrackingService } from './gps-tracking.service'; +import { RegisterDeviceDto, UpdateDeviceDto } from './dto/gps-device.dto'; + +@ApiTags('gps-tracking') +@ApiBearerAuth() +@Controller('gps') +@FleetView() +export class GpsTrackingController { + constructor(private readonly gps: GpsTrackingService) {} + + @Get('positions/latest') + @ApiOperation({ summary: 'Latest fix per device (live map feed)' }) + latest() { + return this.gps.latest(); + } + + @Get('positions/:vehicleId/history') + @ApiOperation({ summary: 'Position history for a vehicle' }) + history( + @Param('vehicleId', ParseUUIDPipe) vehicleId: string, + @Query('limit') limit?: string, + ) { + return this.gps.history(vehicleId, limit ? parseInt(limit, 10) : undefined); + } + + @Get('devices') + @ApiOperation({ summary: 'List GPS trackers' }) + listDevices() { + return this.gps.listDevices(); + } + + @Post('devices') + @FleetManage() + @ApiOperation({ summary: 'Register a GPS tracker' }) + register(@Body() dto: RegisterDeviceDto) { + return this.gps.registerDevice(dto); + } + + @Patch('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Update a GPS tracker (name / assigned vehicle)' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateDeviceDto) { + return this.gps.updateDevice(id, dto); + } + + @Delete('devices/:id') + @FleetManage() + @ApiOperation({ summary: 'Delete a GPS tracker' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.gps.removeDevice(id); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts new file mode 100644 index 000000000..da527fff0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; +import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; +import { GpsTrackingService } from './gps-tracking.service'; +import { GpsTrackingController } from './gps-tracking.controller'; +import { Gt06Server } from './gt06/gt06.server'; + +@Module({ + imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])], + controllers: [GpsTrackingController], + providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server], + exports: [GpsTrackingService], +}) +export class GpsTrackingModule {} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts new file mode 100644 index 000000000..326ef66ac --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { GpsDevice } from './entities/gps-device.entity'; +import { GpsPosition } from './entities/gps-position.entity'; + +@Injectable() +export class GpsDeviceRepository extends BaseRepository { + constructor( + @InjectRepository(GpsDevice) repository: Repository, + ) { + super(repository); + } + + findByImei(imei: string): Promise { + return this.repository.findOne({ where: { imei } }); + } +} + +@Injectable() +export class GpsPositionRepository extends BaseRepository { + constructor( + @InjectRepository(GpsPosition) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts new file mode 100644 index 000000000..b5bea3a3f --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.service.ts @@ -0,0 +1,124 @@ +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); + } +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts @@ -0,0 +1,207 @@ +/** + * GT06 GPS-tracker protocol codec. + * + * Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A + * `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over + * len..serial (inclusive) and equals the 2 crc bytes. + */ + +const START = 0x7878; +const STOP = 0x0d0a; + +export const GT06_PROTOCOL = { + LOGIN: 0x01, + LOCATION: 0x12, + HEARTBEAT: 0x13, + STRING: 0x15, + ALARM: 0x16, + ADDRESS_BY_PHONE: 0x1a, + SERVER_COMMAND: 0x80, +} as const; + +/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */ +export function crcItu(bytes: Buffer): number { + let fcs = 0xffff; + for (const b of bytes) { + fcs ^= b; + for (let i = 0; i < 8; i++) { + fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1; + } + } + return (~fcs) & 0xffff; +} + +export interface Gt06Gps { + time: string; // ISO (UTC) + satellites: number; + latitude: number; + longitude: number; + speed: number; // km/h + course: number; // 0-360 + positioned: boolean; +} + +export interface Gt06Lbs { + mcc: number; + mnc: number; + lac: number; + cellId: number; +} + +export interface Gt06Status { + terminalInfo: number; + voltageLevel: number; + gsmLevel: number; + alarm: number; // former byte of alarm/language + charging: boolean; + accOn: boolean; + gpsTracking: boolean; + oilCut: boolean; +} + +export type Gt06Packet = + | { type: 'login'; protocol: number; serial: number; imei: string } + | { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs } + | { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status } + | { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status } + | { type: 'unknown'; protocol: number; serial: number }; + +/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */ +function decodeImei(buf: Buffer): string { + return buf.toString('hex').replace(/^0/, ''); +} + +function decodeDateTime(buf: Buffer, off: number): string { + const year = 2000 + buf[off]; + const month = buf[off + 1]; + const day = buf[off + 2]; + const hour = buf[off + 3]; + const min = buf[off + 4]; + const sec = buf[off + 5]; + return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString(); +} + +/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */ +function rawToDegrees(raw: number): number { + return raw / 30000 / 60; +} + +function decodeGps(buf: Buffer, off: number): Gt06Gps { + const time = decodeDateTime(buf, off); + const lenSat = buf[off + 6]; + const satellites = lenSat & 0x0f; + const latRaw = buf.readUInt32BE(off + 7); + const lonRaw = buf.readUInt32BE(off + 11); + const speed = buf[off + 15]; + const cs = buf.readUInt16BE(off + 16); + const hi = (cs >> 8) & 0xff; + const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4 + const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West) + const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North) + const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2 + let latitude = rawToDegrees(latRaw); + let longitude = rawToDegrees(lonRaw); + if (!isNorth) latitude = -latitude; + if (isWest) longitude = -longitude; + return { time, satellites, latitude, longitude, speed, course, positioned }; +} + +function decodeStatus(buf: Buffer, off: number): Gt06Status { + const terminalInfo = buf[off]; + const voltageLevel = buf[off + 1]; + const gsmLevel = buf[off + 2]; + const alarm = buf[off + 3]; // alarm/language former byte + return { + terminalInfo, + voltageLevel, + gsmLevel, + alarm, + oilCut: Boolean(terminalInfo & 0x80), + gpsTracking: Boolean(terminalInfo & 0x40), + charging: Boolean(terminalInfo & 0x04), + accOn: Boolean(terminalInfo & 0x02), + }; +} + +function decodeLbs(buf: Buffer, off: number): Gt06Lbs { + return { + mcc: buf.readUInt16BE(off), + mnc: buf[off + 2], + lac: buf.readUInt16BE(off + 3), + cellId: buf.readUIntBE(off + 5, 3), + }; +} + +function decodeFrame(frame: Buffer): Gt06Packet | null { + // frame = 78 78 len ...content... serial(2) crc(2) 0D 0A + const len = frame[2]; + const protocol = frame[3]; + const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2) + const serial = frame.readUInt16BE(serialOff); + const contentOff = 4; // start of content (after protocol) + + switch (protocol) { + case GT06_PROTOCOL.LOGIN: + return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) }; + case GT06_PROTOCOL.LOCATION: + return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) }; + case GT06_PROTOCOL.HEARTBEAT: + return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) }; + case GT06_PROTOCOL.ALARM: { + const gps = decodeGps(frame, contentOff); + // content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2) + const lbs = decodeLbs(frame, contentOff + 18 + 1); + const status = decodeStatus(frame, contentOff + 18 + 1 + 8); + return { type: 'alarm', protocol, serial, gps, lbs, status }; + } + default: + return { type: 'unknown', protocol, serial }; + } +} + +/** + * Pull all complete frames out of a stream buffer. Returns the decoded packets + * (skipping CRC-failed ones) and the trailing bytes that form a partial frame. + */ +export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } { + const packets: Gt06Packet[] = []; + let i = 0; + while (i + 5 <= buffer.length) { + if (buffer.readUInt16BE(i) !== START) { + i += 1; // resync + continue; + } + const len = buffer[i + 2]; + const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop + if (i + frameLen > buffer.length) break; // incomplete + const frame = buffer.subarray(i, i + frameLen); + if (frame.readUInt16BE(frameLen - 2) === STOP) { + // CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3. + const crcCalc = crcItu(frame.subarray(2, frameLen - 4)); + const crcRecv = frame.readUInt16BE(frameLen - 4); + if (crcCalc === crcRecv) { + const pkt = decodeFrame(frame); + if (pkt) packets.push(pkt); + } + i += frameLen; + } else { + i += 1; // bad frame, resync + } + } + return { packets, rest: buffer.subarray(i) }; +} + +/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */ +export function buildAck(protocol: number, serial: number): Buffer { + const body = Buffer.alloc(3); // protocol + serial(2) + body[0] = protocol; + body.writeUInt16BE(serial, 1); + const len = body.length + 2; // + crc(2) + const forCrc = Buffer.concat([Buffer.from([len]), body]); + const crc = crcItu(forCrc); + return Buffer.concat([ + Buffer.from([0x78, 0x78, len]), + body, + Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]), + ]); +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts new file mode 100644 index 000000000..245bfcfcf --- /dev/null +++ b/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts @@ -0,0 +1,96 @@ +import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; +import * as net from 'net'; + +import { GpsTrackingService } from '../gps-tracking.service'; +import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec'; + +interface Session { + buffer: Buffer; + imei: string | null; +} + +const MAX_BUFFER = 64 * 1024; + +/** + * Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login + * (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via + * {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps + * the connection alive. Disabled when GT06_TCP_PORT=0. + */ +@Injectable() +export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { + private readonly logger = new Logger(Gt06Server.name); + private server?: net.Server; + private readonly sessions = new Map(); + + constructor(private readonly gps: GpsTrackingService) {} + + onApplicationBootstrap(): void { + const port = Number(process.env.GT06_TCP_PORT ?? 5023); + if (!port) { + this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)'); + return; + } + this.server = net.createServer((socket) => this.onConnection(socket)); + this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`)); + this.server.listen(port, () => this.logger.log(`GT06 GPS tracker listener on tcp/${port}`)); + } + + onModuleDestroy(): void { + for (const socket of this.sessions.keys()) socket.destroy(); + this.sessions.clear(); + this.server?.close(); + } + + private onConnection(socket: net.Socket): void { + this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null }); + socket.on('data', (chunk) => void this.onData(socket, chunk)); + socket.on('error', () => this.sessions.delete(socket)); + socket.on('close', () => this.sessions.delete(socket)); + } + + private async onData(socket: net.Socket, chunk: Buffer): Promise { + const session = this.sessions.get(socket); + if (!session) return; + session.buffer = Buffer.concat([session.buffer, chunk]); + if (session.buffer.length > MAX_BUFFER) session.buffer = Buffer.alloc(0); // drop garbage + + const { packets, rest } = parseStream(session.buffer); + session.buffer = rest; + + for (const pkt of packets) { + try { + await this.handle(socket, session, pkt); + } catch (err) { + this.logger.error(`Failed to handle GT06 packet (${pkt.type}): ${String(err)}`); + } + } + } + + private async handle( + socket: net.Socket, + session: Session, + pkt: ReturnType['packets'][number], + ): Promise { + switch (pkt.type) { + case 'login': + session.imei = pkt.imei; + await this.gps.handleLogin(pkt.imei); + socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial)); + break; + case 'heartbeat': + if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial)); + break; + case 'location': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps); + break; + case 'alarm': + if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status); + socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial)); + break; + default: + break; + } + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 4efc5c2d4..b89807b72 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -22,6 +22,7 @@ "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", "@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz", + "@vis.gl/react-google-maps": "^1.8.3", "axios": "^1.7.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -44,6 +45,7 @@ "@edr/eslint-config": "workspace:*", "@edr/tsconfig": "workspace:*", "@tailwindcss/vite": "^4.3.0", + "@types/google.maps": "^3.65.2", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index f60c7f72b..a70a75bbf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -1,368 +1,391 @@ -import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Container, Grid, Card, Stack, Group, Select, Text, Badge, Button, Box, Table, SimpleGrid } from '@mantine/core'; -import { MapPin, Navigation, Radio, Activity } from 'lucide-react'; -import Breadcrumbs from '@/components/ui/Breadcrumbs'; -import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; -import { vehiclesService } from '@/services/vehicles.service'; -import { freightBrand } from '@/theme/freight-brand'; +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Container, + Grid, + Group, + Modal, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, +} from "@mantine/core"; +import { + APIProvider, + Map as GoogleMap, + Marker, + useMap, +} from "@vis.gl/react-google-maps"; +import { Activity, Plus, Radio, Trash2 } from "lucide-react"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { useToast } from "@/hooks/use-toast"; +import { vehiclesService } from "@/services/vehicles.service"; +import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service"; +import { freightBrand } from "@/theme/freight-brand"; -interface Vehicle { - id: string; - registrationNumber: string; - plateNumber: string; - manufacturer: string; - model: string; - status?: string; +// Same default key + env override the portal's LocationPicker uses. +const GOOGLE_MAPS_API_KEY = + import.meta.env.VITE_GOOGLE_MAPS_API_KEY || + "AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI"; +const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa + +const toNum = (v: number | string | null | undefined): number | null => + v == null || v === "" ? null : Number(v); + +const deviceLabel = (d: GpsDevice) => + d.vehicle + ? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ") + : d.name || d.imei; + +const fmtTime = (iso?: string | null) => { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); +}; + +const StatBox = ({ label, value }: { label: string; value: string }) => ( + + {label} + {value} + +); + +type LatLng = { lat: number; lng: number }; + +/** Fit the map to the current markers (or center on a single one). */ +function FitBounds({ points }: { points: LatLng[] }) { + const map = useMap(); + useEffect(() => { + if (!map || points.length === 0 || typeof google === "undefined") return; + if (points.length === 1) { + map.setCenter(points[0]); + map.setZoom(14); + return; + } + const b = new google.maps.LatLngBounds(); + points.forEach((p) => b.extend(p)); + map.fitBounds(b, 60); + }, [map, points]); + return null; } -interface GPSLocation { - lat: number; - lng: number; - speed?: number; - heading?: number; - lastUpdate?: string; +/** Draw the selected vehicle's recent path as a polyline. */ +function RouteTrail({ path }: { path: LatLng[] }) { + const map = useMap(); + useEffect(() => { + if (!map || path.length < 2 || typeof google === "undefined") return; + const line = new google.maps.Polyline({ + path, + strokeColor: freightBrand.primary, + strokeOpacity: 0.85, + strokeWeight: 4, + }); + line.setMap(map); + return () => line.setMap(null); + }, [map, path]); + return null; } -// Mock GPS data for demo (no real GPS backend exists — these are simulated values) -const generateMockGPS = (): GPSLocation => ({ - lat: 9.0 + Math.random() * 0.5, - lng: 38.7 + Math.random() * 0.5, - speed: Math.floor(Math.random() * 120), - heading: Math.floor(Math.random() * 360), - lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(), -}); - export function TrackingPage() { - const [selectedVehicleId, setSelectedVehicleId] = useState(null); - const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); + const { toast } = useToast(); + const qc = useQueryClient(); + const [selectedId, setSelectedId] = useState(null); + const [registerOpen, setRegisterOpen] = useState(false); + const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" }); - const { data: vehicles = [] } = useQuery({ - queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: async () => { - const res = await vehiclesService.getAll({ limit: 1000 }); - return res.data || []; + // Poll every 10s so the map tracks live movement. + const { data: devices = [] } = useQuery({ + queryKey: ["gps", "devices"], + queryFn: async () => (await gpsTrackingService.listDevices()).data ?? [], + refetchInterval: 10_000, + }); + + const { data: vehiclesData } = useQuery({ + queryKey: ["vehicles", "all"], + queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [], + }); + const vehicleOptions = useMemo( + () => + (vehiclesData ?? []).map((v) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })), + [vehiclesData], + ); + + const positioned = useMemo( + () => + devices + .map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) })) + .filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null), + [devices], + ); + + const selected = devices.find((d) => d.id === selectedId) ?? null; + const onlineCount = devices.filter((d) => d.online).length; + + // Route history for the selected device's vehicle (chronological trail). + const { data: history = [] } = useQuery({ + queryKey: ["gps", "history", selected?.vehicleId], + queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [], + enabled: Boolean(selected?.vehicleId), + }); + const trail = useMemo( + () => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })), + [history], + ); + + const markerIcon = (d: GpsDevice, selectedFlag: boolean) => { + if (typeof google === "undefined") return undefined; + return { + path: "M 0,-9 L 6,9 L 0,4 L -6,9 Z", // arrow + rotation: d.lastCourse ?? 0, + fillColor: selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6", + fillOpacity: 1, + strokeColor: "#ffffff", + strokeWeight: 1.5, + scale: 1.4, + } as google.maps.Symbol; + }; + + const registerMutation = useMutation({ + mutationFn: () => + gpsTrackingService.register({ + imei: form.imei.trim(), + name: form.name.trim() || undefined, + vehicleId: form.vehicleId || null, + }), + onSuccess: () => { + toast({ title: "Tracker registered" }); + setRegisterOpen(false); + setForm({ imei: "", name: "", vehicleId: "" }); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: (err: unknown) => { + const description = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed"; + toast({ title: "Registration failed", description, variant: "destructive" }); }, }); - // Generate mock GPS data for each vehicle - const vehiclesWithGPS = useMemo(() => { - return (vehicles as Vehicle[]).map((v) => ({ - ...v, - gps: generateMockGPS(), - })); - }, [vehicles]); + const assignMutation = useMutation({ + mutationFn: ({ id, vehicleId }: { id: string; vehicleId: string | null }) => + gpsTrackingService.update(id, { vehicleId }), + onSuccess: () => { + toast({ title: "Tracker updated" }); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: () => toast({ title: "Update failed", variant: "destructive" }), + }); - // For demo: show all vehicles as trackable (or filter by ACTIVE if status data available) - const trackableVehicles = useMemo( - () => vehiclesWithGPS.slice(0, 10), // Limit to first 10 for demo - [vehiclesWithGPS] - ); - - const selectedVehicle = trackableVehicles.find(v => v.id === selectedVehicleId); - const vehicleOptions = useMemo( - () => trackableVehicles.map(v => ({ label: v.registrationNumber, value: v.id })), - [trackableVehicles] - ); - - // Map dimensions - const mapWidth = 800; - const mapHeight = 500; - const pixelsPerLat = mapHeight / 0.6; - const pixelsPerLng = mapWidth / 0.6; - - const getMapCoords = (lat: number, lng: number) => ({ - x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng), - y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat), + const deleteMutation = useMutation({ + mutationFn: (id: string) => gpsTrackingService.remove(id), + onSuccess: () => { + toast({ title: "Tracker removed" }); + setSelectedId(null); + void qc.invalidateQueries({ queryKey: ["gps", "devices"] }); + }, + onError: () => toast({ title: "Delete failed", variant: "destructive" }), }); return ( - + - - -
- - - Real-Time Vehicle Tracking - - - Simulated GPS - - - - Monitor vehicle locations, speed, and status - -
-
+ +
+ Real-Time Vehicle Tracking + Live GPS positions from GT06 trackers +
+ +
- - {/* Map Section */} - - - - - Map View - - }> - {trackableVehicles.length} Tracked - - - - - - - - + {/* Map */} + + + + + Live Map + }> + {onlineCount} online · {positioned.length} located + + + + + + + - {/* Grid background */} - - {/* Latitude lines */} - {[0, 1, 2, 3, 4, 5, 6].map(i => ( - ( + setSelectedId(d.id)} /> ))} - {/* Longitude lines */} - {[0, 1, 2, 3, 4, 5, 6].map(i => ( - - ))} - - - {/* Vehicle markers */} - {trackableVehicles.map((vehicle) => { - const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng); - const isSelected = vehicle.id === selectedVehicleId; - - return ( - setSelectedVehicleId(vehicle.id)} - title={vehicle.registrationNumber} - > - - - - - ); - })} - - {/* Map labels */} - - - 📍 Addis Ababa, Ethiopia - - - - - - Simulated map — coordinates, speed, and heading are demo values, not live GPS. + ({ lat: p.lat, lng: p.lng }))} /> + {selected?.vehicleId && trail.length > 1 && } + + + + {positioned.length === 0 && ( + + No located trackers yet — waiting for GPS fixes. - - - - - {/* Sidebar */} - - - {/* Vehicle Selector */} - - - assignMutation.mutate({ id: selected.id, vehicleId: v })} + searchable + clearable + /> - - - -
+ )} + + + + Trackers ({devices.length}) +
+ + + {devices.map((d) => ( + setSelectedId(d.id)} + > + + + {deviceLabel(d)} + {toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)} + + + + {d.online ? "Live" : "Offline"} + + + ))} + {devices.length === 0 && ( + + + No trackers registered yet. + + + )} + +
+
+
+
+ + + + + {/* Register modal */} + setRegisterOpen(false)} title="Register GPS tracker" radius="lg" centered> + + setForm({ ...form, imei: e.currentTarget.value })} + /> + setForm({ ...form, name: e.currentTarget.value })} + /> +