diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..057cb0c1b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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" ;; 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 index da527fff0..860c51f4f 100644 --- 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 @@ -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 {} diff --git a/apps/edr-gps-tracker/Dockerfile b/apps/edr-gps-tracker/Dockerfile new file mode 100644 index 000000000..7fa103207 --- /dev/null +++ b/apps/edr-gps-tracker/Dockerfile @@ -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"] diff --git a/apps/edr-gps-tracker/nest-cli.json b/apps/edr-gps-tracker/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-gps-tracker/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-gps-tracker/package.json b/apps/edr-gps-tracker/package.json new file mode 100644 index 000000000..bcdbc4dbe --- /dev/null +++ b/apps/edr-gps-tracker/package.json @@ -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" + } +} diff --git a/apps/edr-gps-tracker/src/app.module.ts b/apps/edr-gps-tracker/src/app.module.ts new file mode 100644 index 000000000..836cddefa --- /dev/null +++ b/apps/edr-gps-tracker/src/app.module.ts @@ -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("database")!, + }), + GpsModule, + ], +}) +export class AppModule {} diff --git a/apps/edr-gps-tracker/src/config/database.config.ts b/apps/edr-gps-tracker/src/config/database.config.ts new file mode 100644 index 000000000..d93874ae8 --- /dev/null +++ b/apps/edr-gps-tracker/src/config/database.config.ts @@ -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"], + }; +}); diff --git a/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts b/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts new file mode 100644 index 000000000..de6ba73ea --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts @@ -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; +} diff --git a/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts b/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts new file mode 100644 index 000000000..9d1bb949a --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts @@ -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; +} diff --git a/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts b/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts new file mode 100644 index 000000000..37fa452ea --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts @@ -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 { + 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 { + 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, + }); + } +} diff --git a/apps/edr-gps-tracker/src/gps/gps.module.ts b/apps/edr-gps-tracker/src/gps/gps.module.ts new file mode 100644 index 000000000..b51f0cc71 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps.module.ts @@ -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 {} diff --git a/apps/edr-gps-tracker/src/gps/gps.repository.ts b/apps/edr-gps-tracker/src/gps/gps.repository.ts new file mode 100644 index 000000000..2a8c2f1e8 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps.repository.ts @@ -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 { + 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-gps-tracker/src/gps/gt06/gt06.codec.ts b/apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/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-gps-tracker/src/gps/gt06/gt06.server.ts similarity index 67% rename from apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts rename to apps/edr-gps-tracker/src/gps/gt06/gt06.server.ts index a2095fa12..68ee12bb0 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts +++ b/apps/edr-gps-tracker/src/gps/gt06/gt06.server.ts @@ -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(); - 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 { @@ -71,23 +78,24 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { private async handle( socket: net.Socket, session: Session, - pkt: ReturnType['packets'][number], + pkt: ReturnType["packets"][number], ): Promise { 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: diff --git a/apps/edr-gps-tracker/src/main.ts b/apps/edr-gps-tracker/src/main.ts new file mode 100644 index 000000000..afdec43c5 --- /dev/null +++ b/apps/edr-gps-tracker/src/main.ts @@ -0,0 +1,33 @@ +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(); diff --git a/apps/edr-gps-tracker/tsconfig.json b/apps/edr-gps-tracker/tsconfig.json new file mode 100644 index 000000000..52598cb95 --- /dev/null +++ b/apps/edr-gps-tracker/tsconfig.json @@ -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"] +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 863c1b5e2..d3c89c4a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -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: . diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c04cd634..9ded80852 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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':