mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix tracker
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_positions`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.gps_devices`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<GpsDevice> {
|
||||
constructor(
|
||||
@InjectRepository(GpsDevice) repository: Repository<GpsDevice>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByImei(imei: string): Promise<GpsDevice | null> {
|
||||
return this.repository.findOne({ where: { imei } });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GpsPositionRepository extends BaseRepository<GpsPosition> {
|
||||
constructor(
|
||||
@InjectRepository(GpsPosition) repository: Repository<GpsPosition>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -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<GpsDevice> {
|
||||
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<void> {
|
||||
const device = await this.ensureDevice(imei);
|
||||
await this.devices.update(device.id, { lastSeenAt: new Date(), status: 'ONLINE' });
|
||||
}
|
||||
|
||||
async handleHeartbeat(imei: string, status: Gt06Status): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.devices.softDelete(id);
|
||||
}
|
||||
}
|
||||
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal file
207
apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.codec.ts
Normal file
@@ -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]),
|
||||
]);
|
||||
}
|
||||
@@ -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<net.Socket, Session>();
|
||||
|
||||
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<void> {
|
||||
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<typeof parseStream>['packets'][number],
|
||||
): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) => (
|
||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||
<Text size="xs" c="dimmed">{label}</Text>
|
||||
<Text fw={600} size="sm">{value}</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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<string | null>(null);
|
||||
const [mapCenter] = useState({ lat: 9.0, lng: 38.8 });
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: 'Fleet' }, { label: 'Vehicle Tracking' }]} />
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
|
||||
|
||||
<Stack gap="xl">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={700} size="xl">
|
||||
Real-Time Vehicle Tracking
|
||||
</Text>
|
||||
<Badge color="yellow" variant="light">
|
||||
Simulated GPS
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text c="dimmed" size="sm">
|
||||
Monitor vehicle locations, speed, and status
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={() => setRegisterOpen(true)}>
|
||||
Register tracker
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Grid>
|
||||
{/* Map Section */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Card withBorder p="lg">
|
||||
<Card.Section p="md" withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Map View</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color="edr-green" leftSection={<Radio size={12} />}>
|
||||
{trackableVehicles.length} Tracked
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section p="md">
|
||||
<Box style={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
width: mapWidth,
|
||||
height: mapHeight,
|
||||
backgroundColor: '#f0f8f7',
|
||||
border: `2px solid ${freightBrand.primary}`,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
<Grid>
|
||||
{/* Map */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Card withBorder p="lg">
|
||||
<Card.Section p="md" withBorder>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>Live Map</Text>
|
||||
<Badge color="edr-green" leftSection={<Radio size={12} />}>
|
||||
{onlineCount} online · {positioned.length} located
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={DEFAULT_CENTER}
|
||||
defaultZoom={7}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg
|
||||
width={mapWidth}
|
||||
height={mapHeight}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
>
|
||||
{/* Latitude lines */}
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(i => (
|
||||
<line
|
||||
key={`lat-${i}`}
|
||||
x1={0}
|
||||
y1={(i / 6) * mapHeight}
|
||||
x2={mapWidth}
|
||||
y2={(i / 6) * mapHeight}
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth={1}
|
||||
{positioned.map(({ d, lat, lng }) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={{ lat, lng }}
|
||||
title={deviceLabel(d)}
|
||||
icon={markerIcon(d, d.id === selectedId)}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
/>
|
||||
))}
|
||||
{/* Longitude lines */}
|
||||
{[0, 1, 2, 3, 4, 5, 6].map(i => (
|
||||
<line
|
||||
key={`lng-${i}`}
|
||||
x1={(i / 6) * mapWidth}
|
||||
y1={0}
|
||||
x2={(i / 6) * mapWidth}
|
||||
y2={mapHeight}
|
||||
stroke="#e0e0e0"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Vehicle markers */}
|
||||
{trackableVehicles.map((vehicle) => {
|
||||
const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng);
|
||||
const isSelected = vehicle.id === selectedVehicleId;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={vehicle.id}
|
||||
pos="absolute"
|
||||
style={{
|
||||
left: coords.x - 15,
|
||||
top: coords.y - 15,
|
||||
width: 30,
|
||||
height: 30,
|
||||
cursor: 'pointer',
|
||||
zIndex: isSelected ? 100 : 10,
|
||||
}}
|
||||
onClick={() => setSelectedVehicleId(vehicle.id)}
|
||||
title={vehicle.registrationNumber}
|
||||
>
|
||||
<Box
|
||||
pos="absolute"
|
||||
inset={0}
|
||||
style={{
|
||||
backgroundColor: isSelected ? freightBrand.primary : '#3498db',
|
||||
borderRadius: '50%',
|
||||
border: isSelected ? `3px solid ${freightBrand.primaryDark}` : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
boxShadow: isSelected ? `0 0 0 8px ${freightBrand.ring}` : 'none',
|
||||
}}
|
||||
>
|
||||
<Navigation size={16} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Map labels */}
|
||||
<Box pos="absolute" bottom={8} left={8} style={{ zIndex: 50 }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
📍 Addis Ababa, Ethiopia
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
Simulated map — coordinates, speed, and heading are demo values, not live GPS.
|
||||
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
||||
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
</Box>
|
||||
{positioned.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
||||
No located trackers yet — waiting for GPS fixes.
|
||||
</Text>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Sidebar */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
{/* Vehicle Selector */}
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Select
|
||||
label="Track Vehicle"
|
||||
placeholder="Select a vehicle to track"
|
||||
data={vehicleOptions}
|
||||
value={selectedVehicleId}
|
||||
onChange={setSelectedVehicleId}
|
||||
searchable
|
||||
/>
|
||||
{selectedVehicle && (
|
||||
<Box p="md" style={{ backgroundColor: freightBrand.mutedBg, borderRadius: '8px' }}>
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Registration
|
||||
</Text>
|
||||
<Text fw={600}>{selectedVehicle.registrationNumber}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Vehicle
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{selectedVehicle.manufacturer} {selectedVehicle.model}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Status
|
||||
</Text>
|
||||
<Badge color={selectedVehicle.status === 'ACTIVE' ? 'edr-green' : 'gray'}>
|
||||
{selectedVehicle.status || 'Unknown'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* GPS Details */}
|
||||
{selectedVehicle && (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>GPS Location</Text>
|
||||
<Badge color="edr-green" leftSection={<Activity size={12} />}>
|
||||
Live
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Latitude
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{selectedVehicle.gps.lat.toFixed(4)}°
|
||||
</Text>
|
||||
</Box>
|
||||
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Longitude
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{selectedVehicle.gps.lng.toFixed(4)}°
|
||||
</Text>
|
||||
</Box>
|
||||
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Speed
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{selectedVehicle.gps.speed} km/h
|
||||
</Text>
|
||||
</Box>
|
||||
<Box p="sm" style={{ backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Heading
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{selectedVehicle.gps.heading}°
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
Last Update
|
||||
</Text>
|
||||
<Text fw={500}>{selectedVehicle.gps.lastUpdate}</Text>
|
||||
</div>
|
||||
|
||||
<Button color="edr-green" fullWidth leftSection={<MapPin size={16} />}>
|
||||
View Full History
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Tracked Vehicles List */}
|
||||
{/* Sidebar */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Stack gap="md">
|
||||
{selected && (
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Text fw={500}>Tracked Vehicles ({trackableVehicles.length})</Text>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto' }}>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{trackableVehicles.map(v => (
|
||||
<Table.Tr
|
||||
key={v.id}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: v.id === selectedVehicleId ? freightBrand.mutedBg : 'transparent',
|
||||
}}
|
||||
onClick={() => setSelectedVehicleId(v.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{v.registrationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{v.gps.speed} km/h
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Badge
|
||||
color={v.status === 'ACTIVE' ? 'edr-green' : 'gray'}
|
||||
size="sm"
|
||||
>
|
||||
{v.status || 'N/A'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{deviceLabel(selected)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
{selected.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
|
||||
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
|
||||
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
|
||||
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">IMEI</Text>
|
||||
<Text fw={500} size="sm">{selected.imei}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Last fix</Text>
|
||||
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
|
||||
</div>
|
||||
{selected.vehicleId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Showing last {trail.length} fixes as a route trail.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Select
|
||||
label="Assigned vehicle"
|
||||
placeholder="Unassigned"
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Card withBorder p="lg">
|
||||
<Stack gap="md">
|
||||
<Text fw={500}>Trackers ({devices.length})</Text>
|
||||
<div style={{ maxHeight: 340, overflowY: "auto" }}>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{devices.map((d) => (
|
||||
<Table.Tr
|
||||
key={d.id}
|
||||
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
|
||||
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{devices.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Register modal */}
|
||||
<Modal opened={registerOpen} onClose={() => setRegisterOpen(false)} title="Register GPS tracker" radius="lg" centered>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="IMEI"
|
||||
placeholder="15-digit device IMEI"
|
||||
required
|
||||
value={form.imei}
|
||||
onChange={(e) => setForm({ ...form, imei: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Optional label"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.currentTarget.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Assign to a vehicle (optional)"
|
||||
data={vehicleOptions}
|
||||
value={form.vehicleId || null}
|
||||
onChange={(v) => setForm({ ...form, vehicleId: v ?? "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setRegisterOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
loading={registerMutation.isPending}
|
||||
disabled={!form.imei.trim()}
|
||||
onClick={() => registerMutation.mutate()}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrackingPage;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
export interface GpsDevice {
|
||||
id: string;
|
||||
imei: string;
|
||||
name?: string | null;
|
||||
vehicleId?: string | null;
|
||||
vehicle?: {
|
||||
id: string;
|
||||
plateNumber?: string;
|
||||
code?: string | null;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
} | null;
|
||||
status: string;
|
||||
online: boolean;
|
||||
lastSeenAt?: string | null;
|
||||
lastLat?: number | string | null;
|
||||
lastLng?: number | string | null;
|
||||
lastSpeed?: number | string | null;
|
||||
lastCourse?: number | null;
|
||||
lastFixAt?: string | null;
|
||||
voltageLevel?: number | null;
|
||||
gsmLevel?: number | null;
|
||||
}
|
||||
|
||||
export interface GpsPosition {
|
||||
id: string;
|
||||
lat: number | string;
|
||||
lng: number | string;
|
||||
speed: number | string;
|
||||
course: number;
|
||||
satellites: number;
|
||||
gpsTime: string;
|
||||
alarm: number;
|
||||
}
|
||||
|
||||
export const gpsTrackingService = {
|
||||
latest: () => api.get<GpsDevice[]>("/gps/positions/latest"),
|
||||
listDevices: () => api.get<GpsDevice[]>("/gps/devices"),
|
||||
history: (vehicleId: string, limit = 200) =>
|
||||
api.get<GpsPosition[]>(`/gps/positions/${vehicleId}/history?limit=${limit}`),
|
||||
register: (data: { imei: string; name?: string; vehicleId?: string | null }) =>
|
||||
api.post<GpsDevice>("/gps/devices", data),
|
||||
update: (id: string, data: { name?: string; vehicleId?: string | null }) =>
|
||||
api.patch<GpsDevice>(`/gps/devices/${id}`, data),
|
||||
remove: (id: string) => api.delete<void>(`/gps/devices/${id}`),
|
||||
};
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -244,6 +244,9 @@ importers:
|
||||
'@tria-plc/iamui':
|
||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||
'@vis.gl/react-google-maps':
|
||||
specifier: ^1.8.3
|
||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.17.0
|
||||
@@ -305,6 +308,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
'@types/google.maps':
|
||||
specifier: ^3.65.2
|
||||
version: 3.65.2
|
||||
'@types/react':
|
||||
specifier: ^18.3.11
|
||||
version: 18.3.31
|
||||
|
||||
Reference in New Issue
Block a user