mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
gps
This commit is contained in:
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@@ -31,6 +31,7 @@ jobs:
|
||||
"freight-api"
|
||||
"freight-portal"
|
||||
"freight-backoffice"
|
||||
"gps-tracker"
|
||||
"passenger-api"
|
||||
"passenger-portal"
|
||||
"passenger-backoffice"
|
||||
@@ -71,6 +72,7 @@ jobs:
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-gps-tracker/" && SERVICES+=("gps-tracker")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal")
|
||||
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
|
||||
@@ -109,7 +111,7 @@ jobs:
|
||||
- name: Resolve project and build env file
|
||||
run: |
|
||||
case "${{ matrix.service }}" in
|
||||
freight-api|freight-portal|freight-backoffice)
|
||||
freight-api|freight-portal|freight-backoffice|gps-tracker)
|
||||
echo "PROJECT=edr-freight" >> "$GITHUB_ENV"
|
||||
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV"
|
||||
;;
|
||||
|
||||
@@ -6,12 +6,15 @@ 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';
|
||||
|
||||
// NOTE: the GT06 TCP listener now lives in the standalone @edr/gps-tracker app.
|
||||
// This module is REST-only — it reads gps_devices / gps_positions that the
|
||||
// tracker app writes to the shared DB. Do not re-add Gt06Server here, or two
|
||||
// processes would fight for the tracker socket.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
|
||||
controllers: [GpsTrackingController],
|
||||
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server],
|
||||
providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService],
|
||||
exports: [GpsTrackingService],
|
||||
})
|
||||
export class GpsTrackingModule {}
|
||||
|
||||
42
apps/edr-gps-tracker/Dockerfile
Normal file
42
apps/edr-gps-tracker/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Build from monorepo root: docker build -f apps/edr-gps-tracker/Dockerfile .
|
||||
|
||||
FROM node:24.15.0-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/gps-tracker" --docker
|
||||
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||
--mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
RUN pnpm turbo build --filter="@edr/gps-tracker..."
|
||||
|
||||
FROM base AS deployer
|
||||
COPY --from=builder /app/ .
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
pnpm deploy --filter="@edr/gps-tracker" --prod --legacy /deploy
|
||||
|
||||
FROM node:24.15.0-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
||||
EXPOSE 5023
|
||||
CMD ["node", "dist/main.js"]
|
||||
8
apps/edr-gps-tracker/nest-cli.json
Normal file
8
apps/edr-gps-tracker/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false
|
||||
}
|
||||
}
|
||||
39
apps/edr-gps-tracker/package.json
Normal file
39
apps/edr-gps-tracker/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@edr/gps-tracker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Standalone GT06 GPS tracker TCP ingester. Listens for tracker sockets and writes fixes to the shared freight DB. No HTTP; the /gps REST API stays in @edr/freight-api.",
|
||||
"scripts": {
|
||||
"clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"",
|
||||
"predev": "pnpm run clean",
|
||||
"dev": "nest start --watch --clearScreen false",
|
||||
"prebuild": "pnpm run clean",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint src",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@edr/eslint-config": "workspace:*",
|
||||
"@edr/tsconfig": "workspace:*",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pg": "^8.6.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
19
apps/edr-gps-tracker/src/app.module.ts
Normal file
19
apps/edr-gps-tracker/src/app.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
|
||||
import databaseConfig from "./config/database.config";
|
||||
import { GpsModule } from "./gps/gps.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
config.get<TypeOrmModuleOptions>("database")!,
|
||||
}),
|
||||
GpsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
28
apps/edr-gps-tracker/src/config/database.config.ts
Normal file
28
apps/edr-gps-tracker/src/config/database.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { registerAs } from "@nestjs/config";
|
||||
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||
|
||||
import { GpsDevice } from "../gps/entities/gps-device.entity";
|
||||
import { GpsPosition } from "../gps/entities/gps-position.entity";
|
||||
|
||||
/**
|
||||
* DB config for the GPS ingester. Points at the SAME database as
|
||||
* @edr/freight-api and touches only the two GPS tables, which are explicitly
|
||||
* schema-qualified to `freight` on the entities — so no search_path handler is
|
||||
* needed here. This app NEVER runs migrations or synchronize: @edr/freight-api
|
||||
* owns the schema (the AddGpsTracking migration creates these tables).
|
||||
*/
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||
username: process.env.DB_USER ?? "postgres",
|
||||
password: process.env.DB_PASSWORD ?? "",
|
||||
database: process.env.DB_NAME ?? "edr_freight",
|
||||
entities: [GpsDevice, GpsPosition],
|
||||
migrations: [],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
logging: process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
|
||||
};
|
||||
});
|
||||
48
apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts
Normal file
48
apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* A physical GPS tracker (GT06), keyed by IMEI. Same table as
|
||||
* @edr/freight-api's GpsDevice; the Vehicle relation is intentionally dropped
|
||||
* here — the ingester only needs the `vehicleId` column to stamp positions, not
|
||||
* the Vehicle entity graph.
|
||||
*/
|
||||
@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;
|
||||
|
||||
@Column({ name: "status", type: "varchar", length: 16, default: "REGISTERED" })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: "last_seen_at", type: "timestamptz", nullable: true })
|
||||
lastSeenAt?: Date | null;
|
||||
|
||||
@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;
|
||||
}
|
||||
41
apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts
Normal file
41
apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/** One GPS fix from a tracker (append-only history). Same table as freight-api. */
|
||||
@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;
|
||||
|
||||
@Column({ name: "gps_time", type: "timestamptz" })
|
||||
gpsTime!: Date;
|
||||
|
||||
@Column({ name: "alarm", type: "int", default: 0 })
|
||||
alarm!: number;
|
||||
}
|
||||
72
apps/edr-gps-tracker/src/gps/gps-ingest.service.ts
Normal file
72
apps/edr-gps-tracker/src/gps/gps-ingest.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
|
||||
import { GpsDeviceRepository, GpsPositionRepository } from "./gps.repository";
|
||||
import { GpsDevice } from "./entities/gps-device.entity";
|
||||
import { Gt06Gps, Gt06Status } from "./gt06/gt06.codec";
|
||||
|
||||
/**
|
||||
* Write path for GT06 ingestion: upserts device state and appends position
|
||||
* history. Mirrors the ingestion half of @edr/freight-api's GpsTrackingService
|
||||
* (the REST/query half stays in freight-api). Auto-registers unknown IMEIs on
|
||||
* first contact.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GpsIngestService {
|
||||
private readonly logger = new Logger(GpsIngestService.name);
|
||||
|
||||
constructor(
|
||||
private readonly devices: GpsDeviceRepository,
|
||||
private readonly positions: GpsPositionRepository,
|
||||
) {}
|
||||
|
||||
/** 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() });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
14
apps/edr-gps-tracker/src/gps/gps.module.ts
Normal file
14
apps/edr-gps-tracker/src/gps/gps.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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.repository";
|
||||
import { GpsIngestService } from "./gps-ingest.service";
|
||||
import { Gt06Server } from "./gt06/gt06.server";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])],
|
||||
providers: [GpsDeviceRepository, GpsPositionRepository, GpsIngestService, Gt06Server],
|
||||
})
|
||||
export class GpsModule {}
|
||||
25
apps/edr-gps-tracker/src/gps/gps.repository.ts
Normal file
25
apps/edr-gps-tracker/src/gps/gps.repository.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
207
apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts
Normal file
207
apps/edr-gps-tracker/src/gps/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]),
|
||||
]);
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import * as net from 'net';
|
||||
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';
|
||||
import { GpsIngestService } from "../gps-ingest.service";
|
||||
import { buildAck, GT06_PROTOCOL, parseStream } from "./gt06.codec";
|
||||
|
||||
interface Session {
|
||||
buffer: Buffer;
|
||||
@@ -14,7 +19,7 @@ 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
|
||||
* {@link GpsIngestService}, and ACK login/heartbeat/alarm so the device keeps
|
||||
* the connection alive. Disabled when GT06_TCP_PORT=0.
|
||||
*/
|
||||
@Injectable()
|
||||
@@ -23,18 +28,20 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private server?: net.Server;
|
||||
private readonly sessions = new Map<net.Socket, Session>();
|
||||
|
||||
constructor(private readonly gps: GpsTrackingService) {}
|
||||
constructor(private readonly gps: GpsIngestService) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
|
||||
if (!port) {
|
||||
this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)');
|
||||
this.logger.log("GT06 TCP listener disabled (GT06_TCP_PORT=0)");
|
||||
return;
|
||||
}
|
||||
const host = process.env.GT06_TCP_HOST ?? '0.0.0.0';
|
||||
const host = process.env.GT06_TCP_HOST ?? "0.0.0.0";
|
||||
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, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`));
|
||||
this.server.on("error", (err) => this.logger.error(`GT06 server error: ${String(err)}`));
|
||||
this.server.listen(port, host, () =>
|
||||
this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`),
|
||||
);
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
@@ -45,9 +52,9 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
|
||||
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));
|
||||
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> {
|
||||
@@ -71,23 +78,24 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy {
|
||||
private async handle(
|
||||
socket: net.Socket,
|
||||
session: Session,
|
||||
pkt: ReturnType<typeof parseStream>['packets'][number],
|
||||
pkt: ReturnType<typeof parseStream>["packets"][number],
|
||||
): Promise<void> {
|
||||
switch (pkt.type) {
|
||||
case 'login':
|
||||
case "login":
|
||||
session.imei = pkt.imei;
|
||||
await this.gps.handleLogin(pkt.imei);
|
||||
socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial));
|
||||
break;
|
||||
case 'heartbeat':
|
||||
case "heartbeat":
|
||||
if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status);
|
||||
socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial));
|
||||
break;
|
||||
case 'location':
|
||||
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);
|
||||
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:
|
||||
32
apps/edr-gps-tracker/src/main.ts
Normal file
32
apps/edr-gps-tracker/src/main.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import "reflect-metadata";
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
/**
|
||||
* Standalone GT06 GPS ingester. Boots a Nest application context (NO HTTP
|
||||
* server) so only the DB connection and Gt06Server come up; the TCP listener
|
||||
* binds from Gt06Server.onApplicationBootstrap. The /gps REST API lives in
|
||||
* @edr/freight-api, which reads the same tables this process writes.
|
||||
*/
|
||||
async function bootstrap() {
|
||||
const logger = new Logger("gps-tracker");
|
||||
const port = Number(process.env.GT06_TCP_PORT ?? 5023);
|
||||
if (!port) {
|
||||
logger.error(
|
||||
"GT06_TCP_PORT=0 disables the listener — this process would idle. Set a port.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
logger.log(
|
||||
`GT06 ingester up — TCP ${process.env.GT06_TCP_HOST ?? "0.0.0.0"}:${port}`,
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
15
apps/edr-gps-tracker/tsconfig.json
Normal file
15
apps/edr-gps-tracker/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@edr/tsconfig/nestjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": false,
|
||||
"incremental": true,
|
||||
"tsBuildInfoFile": "./.tsbuildinfo",
|
||||
"preserveWatchOutput": true,
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -16,12 +16,33 @@ services:
|
||||
- npmrc
|
||||
ports:
|
||||
- "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}"
|
||||
# GT06 GPS tracker TCP ingestion (raw TCP — must be reachable by tracker SIMs).
|
||||
- "${GT06_TCP_PORT:-5023}:${GT06_TCP_PORT:-5023}"
|
||||
env_file:
|
||||
- apps/edr-freight-api/.env
|
||||
extra_hosts:
|
||||
- "paymentcallback.triaplc.com:10.18.7.179"
|
||||
|
||||
# Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no
|
||||
# HTTP. Writes freight.gps_devices / freight.gps_positions in the shared
|
||||
# freight DB; the /gps REST API stays in freight-api. Never runs migrations.
|
||||
gps-tracker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: apps/edr-gps-tracker/Dockerfile
|
||||
secrets:
|
||||
- npmrc
|
||||
depends_on:
|
||||
- freight-api
|
||||
ports:
|
||||
# Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it.
|
||||
- "${GT06_TCP_PORT:-5023}:5023"
|
||||
environment:
|
||||
GT06_TCP_PORT: "5023"
|
||||
GT06_TCP_HOST: "0.0.0.0"
|
||||
env_file:
|
||||
# Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME).
|
||||
- apps/edr-freight-api/.env
|
||||
restart: unless-stopped
|
||||
|
||||
passenger-api:
|
||||
build:
|
||||
context: .
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -676,6 +676,67 @@ importers:
|
||||
specifier: ^2.1.2
|
||||
version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0)
|
||||
|
||||
apps/edr-gps-tracker:
|
||||
dependencies:
|
||||
'@edr/api-common':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api-common
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/config':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/typeorm':
|
||||
specifier: ^11.0.1
|
||||
version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))
|
||||
dotenv:
|
||||
specifier: ^17.4.2
|
||||
version: 17.4.2
|
||||
pg:
|
||||
specifier: ^8.13.0
|
||||
version: 8.21.0
|
||||
reflect-metadata:
|
||||
specifier: ^0.2.2
|
||||
version: 0.2.2
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
typeorm:
|
||||
specifier: ^0.3.30
|
||||
version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))
|
||||
devDependencies:
|
||||
'@edr/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config/eslint-config
|
||||
'@edr/tsconfig':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/config/tsconfig
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3)
|
||||
'@nestjs/schematics':
|
||||
specifier: ^11.0.0
|
||||
version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)
|
||||
'@types/node':
|
||||
specifier: ^20.14.0
|
||||
version: 20.19.42
|
||||
'@types/pg':
|
||||
specifier: ^8.6.7
|
||||
version: 8.20.0
|
||||
ts-node:
|
||||
specifier: ^10.9.2
|
||||
version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3)
|
||||
tsconfig-paths:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
typescript:
|
||||
specifier: ^5.5.4
|
||||
version: 5.9.3
|
||||
|
||||
apps/edr-passenger-api:
|
||||
dependencies:
|
||||
'@edr/types':
|
||||
|
||||
Reference in New Issue
Block a user