Merge pull request #613 from Tria-plc/freight/feature/user_management_UI

Freight/feature/user management UI
This commit is contained in:
yaschalew10
2026-07-11 08:36:43 +03:00
committed by GitHub
18 changed files with 710 additions and 24 deletions

View File

@@ -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"
;;

View File

@@ -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 {}

View 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"]

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": false
}
}

View 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"
}
}

View 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 {}

View 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"],
};
});

View 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;
}

View 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;
}

View 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,
});
}
}

View 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 {}

View 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);
}
}

View 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]),
]);
}

View File

@@ -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:

View File

@@ -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();

View 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"]
}

View File

@@ -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
View File

@@ -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':