src/tracking/gps-simulator.service.ts
Methods |
|
constructor(prisma: PrismaService, trackingGateway: TrackingGateway)
|
|||||||||
|
Defined in src/tracking/gps-simulator.service.ts:159
|
|||||||||
|
Parameters :
|
| onModuleDestroy |
onModuleDestroy()
|
|
Defined in src/tracking/gps-simulator.service.ts:186
|
|
Returns :
void
|
| Async onModuleInit |
onModuleInit()
|
|
Defined in src/tracking/gps-simulator.service.ts:166
|
|
Returns :
any
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TrackingGateway } from './tracking.gateway';
/**
* Per-organization GPS simulator.
*
* The simulator boots once at app start and ticks every 5 seconds. Each
* tick it iterates organizations and only moves vehicles for orgs whose
* effective tracking mode is `demo` (org.settings.tracking.mode === 'demo'
* OR no tracking config at all OR live mode without verified telematics).
*
* Why per-org: a multi-tenant deployment may have one customer running
* the demo simulator (sandbox / sales pitch) and another customer pulling
* real positions from Mix Telematics — we must never let the simulator
* stomp on real GPS data, and we must never make a real customer's map
* stand still just because the demo customer wants animation.
*
* Routes: vehicles are distributed across multiple Kenyan road corridors
* so the demo map looks like a real fleet (Nairobi-Mombasa, Nairobi-Thika,
* Nairobi-Naivasha-Nakuru, Nairobi-Nyeri, Nairobi-Kisumu). Each vehicle
* is assigned a route based on its current position so the movement
* looks plausible relative to where the dispatcher last saw it.
*/
/** A GPS coordinate pair [latitude, longitude] in decimal degrees. */
type LatLng = [number, number];
/**
* Pre-defined road corridors across Kenya used for realistic vehicle movement.
* Each route is an array of waypoints that vehicles interpolate between.
* Routes are bidirectional (vehicles reverse direction at endpoints).
*/
const KENYA_ROUTES: Record<string, LatLng[]> = {
// Nairobi → Mombasa A109 (the original highway route)
nairobi_mombasa: [
[-1.2921, 36.8219], // Nairobi CBD
[-1.3350, 36.8500], // Athi River turnoff
[-1.4500, 37.0700], // Machakos junction
[-1.6200, 37.2500], // Sultan Hamud
[-2.0400, 37.5200], // Kibwezi
[-2.2800, 37.6200], // Mtito Andei
[-2.6300, 37.8200], // Man Eater area
[-2.9500, 38.1000], // Voi
[-3.2200, 38.5500], // Taru
[-3.4500, 38.9800], // Samburu
[-3.6300, 39.2300], // Mariakani
[-3.8200, 39.4500], // Mazeras
[-4.0435, 39.6682], // Mombasa
],
// Nairobi → Thika A2 (short urban-to-industrial)
nairobi_thika: [
[-1.2921, 36.8219], // Nairobi CBD
[-1.2400, 36.8800], // Roysambu
[-1.2200, 36.8950], // Kasarani
[-1.1894, 36.9300], // Ruiru
[-1.1500, 36.9650], // Juja
[-1.0833, 37.0167], // Witeithie
[-1.0333, 37.0833], // Thika
],
// Nairobi → Naivasha → Nakuru (B3 → A104 escarpment)
nairobi_nakuru: [
[-1.2921, 36.8219], // Nairobi CBD
[-1.2700, 36.7400], // Westlands
[-1.2500, 36.6800], // Limuru turnoff
[-1.0900, 36.6200], // Limuru
[-0.9000, 36.5500], // Kinungi (Rift Valley viewpoint)
[-0.7167, 36.4333], // Naivasha
[-0.5167, 36.3000], // Gilgil
[-0.3083, 36.0667], // Nakuru
],
// Nairobi → Murang'a → Nyeri (Central Kenya tea country)
nairobi_nyeri: [
[-1.2921, 36.8219], // Nairobi CBD
[-1.1894, 36.9300], // Ruiru
[-1.0333, 37.0833], // Thika
[-0.7167, 37.1500], // Murang'a
[-0.4833, 37.1333], // Karatina
[-0.4167, 36.9500], // Nyeri
[-0.3925, 36.9580], // Mweiga
],
// Nakuru → Kericho → Kisumu (A104 west)
nakuru_kisumu: [
[-0.3083, 36.0667], // Nakuru
[-0.2500, 35.7800], // Molo
[-0.3667, 35.2833], // Kericho
[-0.2700, 35.0500], // Awasi
[-0.1000, 34.9000], // Ahero
[-0.0917, 34.7680], // Kisumu
],
// Thika → Embu → Meru (eastern Mt Kenya circuit)
thika_meru: [
[-1.0333, 37.0833], // Thika
[-0.8500, 37.2500], // Kabati
[-0.6450, 37.1450], // Kiriaini
[-0.5310, 37.4500], // Embu
[-0.3500, 37.5500], // Chuka
[-0.0500, 37.6500], // Meru
],
// Local Nairobi loop (CBD → Industrial → Karen → Westlands → CBD)
nairobi_loop: [
[-1.2864, 36.8172], // CBD
[-1.3081, 36.8511], // Industrial Area
[-1.3210, 36.7060], // Karen
[-1.2655, 36.8025], // Westlands
[-1.2245, 36.8856], // Kasarani
[-1.2864, 36.8172], // CBD (loop back)
],
};
const ROUTE_KEYS = Object.keys(KENYA_ROUTES);
/** In-memory state for a single simulated vehicle's position along a route. */
interface SimulatedVehicle {
vehicleId: string;
organizationId: string;
unitNumber: string;
routeKey: string;
routeIndex: number; // current segment index
progress: number; // 0-1 along current segment
direction: 1 | -1; // 1 = forward, -1 = backward
speedKmh: number;
}
@Injectable()
export class GpsSimulatorService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(GpsSimulatorService.name);
private intervalId: NodeJS.Timeout | null = null;
// vehicleId → simulated state. Rebuilt on each tick for any new IN_TRANSIT
// vehicles, so newly-dispatched trucks pick up movement automatically.
private state = new Map<string, SimulatedVehicle>();
// Reentrancy guard. With ~60 vehicles per org, one tick can take 6+
// seconds against Azure Postgres. Without this guard, tick #2 would
// start while #1 is still mid-loop, both holding locks on the same
// vehicle/gps_event rows, and Postgres would deadlock one of them.
private running = false;
private readonly TICK_INTERVAL_MS = 5000; // 5 seconds
constructor(
private readonly prisma: PrismaService,
private readonly trackingGateway: TrackingGateway,
) {}
async onModuleInit() {
// The legacy ENABLE_GPS_SIMULATOR env var still acts as a kill switch
// for environments where you want to be 100% sure no fake data ever
// gets written (e.g. a customer-facing prod cluster with strict data
// requirements). When unset OR true, the simulator runs but only
// touches orgs with tracking.mode=demo, so it's safe by default.
const killed = process.env.ENABLE_GPS_SIMULATOR === 'false';
if (killed) {
this.logger.log(
'GPS Simulator hard-disabled via ENABLE_GPS_SIMULATOR=false',
);
return;
}
this.logger.log(
`GPS Simulator starting (per-org demo mode, tick=${this.TICK_INTERVAL_MS}ms)`,
);
this.intervalId = setInterval(() => this.tick(), this.TICK_INTERVAL_MS);
}
onModuleDestroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
this.logger.log('GPS Simulator stopped');
}
}
/**
* Decide whether an org's vehicles should be simulated.
*
* Returns true when:
* - org.settings.tracking.mode is 'demo' (or unset → default demo)
* - OR org.settings.tracking.mode is 'live' but no verified telematics
* (defensive fallback — never let a live-but-broken org show a
* dead map; the simulator keeps the demo experience alive until
* they fix their credentials)
*/
private isOrgInDemoMode(settings: any): boolean {
const requested = settings?.tracking?.mode || 'demo';
if (requested === 'demo') return true;
// requested === 'live'
const t = settings?.telematics;
if (!t || !t.provider) return true; // no provider yet → demo
const hasCreds = !!(t.apiKey || t.clientSecret || t.password);
const verified = t.lastTestStatus === 'ok';
if (!hasCreds || !verified) return true; // not verified → demo
return false; // truly live
}
/**
* One tick: load all orgs, decide which are in demo mode, and move
* their vehicles. Live orgs are skipped — the LiveTelematicsWorker
* handles those.
*
* Tenant context: this worker runs OUTSIDE the HTTP request lifecycle,
* so the AsyncLocalStorage tenant context that PrismaService normally
* uses is empty. Without it, the `vehicles` table's RLS policy returns
* ZERO rows (fail-closed). We solve this by wrapping each org's work
* in a Prisma `$transaction` and calling
* SELECT set_config('app.current_org_id', $1, true)
* inside it — this gives the transaction's connection the GUC the
* RLS policy needs, scoped to the tx so it can't bleed into other
* concurrent queries.
*/
private async tick() {
if (this.running) {
// Previous tick still in flight (slow Postgres, big fleet, or
// both). Skip this interval rather than racing it.
return;
}
this.running = true;
try {
await this.tickInner();
} finally {
this.running = false;
}
}
private async tickInner() {
let orgs: Array<{ id: string; settings: any }> = [];
try {
// organizations is intentionally excluded from RLS so this read
// works without tenant context — see PrismaService docs.
orgs = await this.prisma.organization.findMany({
select: { id: true, settings: true },
});
} catch (err: any) {
this.logger.error(`Simulator: failed to list orgs: ${err?.message || err}`);
return;
}
const demoOrgs = orgs.filter(o => this.isOrgInDemoMode(o.settings));
if (demoOrgs.length === 0) return;
const allLiveIds = new Set<string>();
for (const org of demoOrgs) {
try {
await this.tickOrgBatched(org.id, allLiveIds);
} catch (err: any) {
this.logger.error(`Simulator: org ${org.id} tick failed: ${err?.message || err}`);
}
}
// Drop sim state for vehicles we no longer track (deleted, moved
// into MAINTENANCE, or whose org switched to live).
for (const id of this.state.keys()) {
if (!allLiveIds.has(id)) this.state.delete(id);
}
}
/**
* Tick a single org's vehicles using a single batched UPDATE.
*
* Approach:
* 1. Open one transaction, set the RLS GUC.
* 2. SELECT all eligible vehicles for the org (1 round-trip).
* 3. Compute the new position for every vehicle in memory (no DB).
* 4. Issue a SINGLE `UPDATE ... FROM (VALUES ...)` that touches every
* moved row in one statement (1 round-trip).
*
* Total: 3 DB round-trips per org per tick (set_config + SELECT + UPDATE)
* regardless of fleet size, vs the old approach of 1 + N×2 round-trips
* (124 for a 62-vehicle fleet). This is what makes the simulator
* actually fit inside the 5-second tick budget for non-trivial fleets.
*
* We deliberately skip writing per-tick gps_events rows here — the
* map only needs vehicles.current_lat/lng/last_gps_at to render, and
* writing 60 history rows every 5 seconds is wasted work for a demo.
* Real telematics ingestion (LiveTelematicsWorker) DOES write
* gps_events because that's the actual position history.
*/
private async tickOrgBatched(orgId: string, allLiveIds: Set<string>) {
type VehicleRow = {
id: string;
unit_number: string;
current_lat: number | null;
current_lng: number | null;
};
let vehicles: VehicleRow[] = [];
// Step 1: open a tx, set RLS, read vehicles. Quick — just 2 queries.
await (this.prisma as any).$transaction(
async (tx: any) => {
await tx.$queryRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;
vehicles = await tx.$queryRaw<VehicleRow[]>`
SELECT id, unit_number, current_lat, current_lng
FROM vehicles
WHERE status NOT IN ('MAINTENANCE', 'OUT_OF_SERVICE')
`;
},
{ timeout: 15_000, maxWait: 10_000 },
);
if (vehicles.length === 0) return;
// Step 2: compute every new position in memory.
type Patch = { id: string; lat: number; lng: number; speed: number; heading: number };
const patches: Patch[] = [];
for (const v of vehicles) {
let sim = this.state.get(v.id);
if (!sim) {
sim = this.assignInitialState({
id: v.id,
organizationId: orgId,
unitNumber: v.unit_number,
currentLat: v.current_lat,
currentLng: v.current_lng,
});
this.state.set(v.id, sim);
}
const next = this.computeNextPosition(sim);
if (next) {
patches.push({ id: v.id, ...next });
allLiveIds.add(v.id);
}
}
if (patches.length === 0) return;
// Step 3: one batched UPDATE for the whole org. Build a parameterized
// VALUES list — Prisma's $executeRaw with template strings only
// takes a fixed number of slots, so we use $executeRawUnsafe with
// a hand-built parameter list. Values are validated as numbers
// before composing, so SQL injection is impossible.
const params: any[] = [];
const valuesSql = patches
.map((p, i) => {
// Each row contributes 5 params: id, lat, lng, speed, last_gps
const idx = i * 5;
params.push(p.id, p.lat, p.lng, p.speed, new Date());
return `($${idx + 1}::text, $${idx + 2}::float8, $${idx + 3}::float8, $${idx + 4}::float8, $${idx + 5}::timestamp)`;
})
.join(',');
const sql = `
UPDATE vehicles AS v SET
current_lat = u.lat,
current_lng = u.lng,
current_speed = u.speed,
last_gps_at = u.ts,
updated_at = NOW()
FROM (VALUES ${valuesSql}) AS u(id, lat, lng, speed, ts)
WHERE v.id = u.id
`;
await (this.prisma as any).$transaction(
async (tx: any) => {
await tx.$queryRaw`SELECT set_config('app.current_org_id', ${orgId}, true)`;
await tx.$executeRawUnsafe(sql, ...params);
},
{ timeout: 15_000, maxWait: 10_000 },
);
// Step 4: broadcast over WebSocket so any open dispatcher map gets
// the smooth animation without polling.
for (const p of patches) {
const sim = this.state.get(p.id)!;
this.trackingGateway.broadcastPosition(p.id, {
lat: p.lat,
lng: p.lng,
speed: p.speed,
heading: p.heading,
timestamp: new Date(),
unitNumber: sim.unitNumber,
});
}
}
/**
* Pure function — advances the sim state and returns the new
* lat/lng/speed/heading. No DB writes here, so we can call this for
* every vehicle in memory before issuing a single batched UPDATE.
*/
private computeNextPosition(vehicle: SimulatedVehicle): { lat: number; lng: number; speed: number; heading: number } | null {
const route = KENYA_ROUTES[vehicle.routeKey];
if (!route) return null;
const from = route[vehicle.routeIndex];
const to = route[vehicle.routeIndex + 1];
if (!from || !to) {
vehicle.routeIndex = Math.max(0, Math.min(vehicle.routeIndex, route.length - 2));
return null;
}
const segmentDistKm = this.haversineDistance(from[0], from[1], to[0], to[1]);
vehicle.speedKmh = Math.max(40, Math.min(110, vehicle.speedKmh + (Math.random() - 0.5) * 8));
const distanceTraveledKm = (vehicle.speedKmh / 3600) * (this.TICK_INTERVAL_MS / 1000);
const progressDelta = distanceTraveledKm / segmentDistKm;
vehicle.progress += progressDelta * vehicle.direction;
if (vehicle.progress >= 1) {
vehicle.progress = 0;
vehicle.routeIndex += 1;
if (vehicle.routeIndex >= route.length - 1) {
vehicle.routeIndex = route.length - 2;
vehicle.direction = -1;
vehicle.progress = 1;
}
} else if (vehicle.progress <= 0) {
vehicle.progress = 1;
vehicle.routeIndex -= 1;
if (vehicle.routeIndex < 0) {
vehicle.routeIndex = 0;
vehicle.direction = 1;
vehicle.progress = 0;
}
}
const currentFrom = route[vehicle.routeIndex];
const currentTo = route[vehicle.routeIndex + 1] || route[vehicle.routeIndex];
const lat = currentFrom[0] + (currentTo[0] - currentFrom[0]) * vehicle.progress + (Math.random() - 0.5) * 0.0008;
const lng = currentFrom[1] + (currentTo[1] - currentFrom[1]) * vehicle.progress + (Math.random() - 0.5) * 0.0008;
const heading = this.calculateBearing(currentFrom[0], currentFrom[1], currentTo[0], currentTo[1]);
const adjustedHeading = vehicle.direction === 1 ? heading : (heading + 180) % 360;
return { lat, lng, speed: vehicle.speedKmh, heading: adjustedHeading };
}
/** Pick the closest Kenyan route to wherever this vehicle currently is. */
private assignInitialState(v: {
id: string;
organizationId: string;
unitNumber: string;
currentLat: number | null;
currentLng: number | null;
}): SimulatedVehicle {
let routeKey = ROUTE_KEYS[Math.abs(this.hashId(v.id)) % ROUTE_KEYS.length];
let routeIndex = 0;
let progress = Math.random();
if (v.currentLat != null && v.currentLng != null) {
// Find the closest waypoint across all routes — that's the most
// believable starting point for this vehicle's first tick.
let bestDist = Infinity;
for (const [key, pts] of Object.entries(KENYA_ROUTES)) {
for (let i = 0; i < pts.length; i++) {
const d = this.squaredDist(v.currentLat, v.currentLng, pts[i][0], pts[i][1]);
if (d < bestDist) {
bestDist = d;
routeKey = key;
routeIndex = Math.min(i, pts.length - 2);
progress = 0;
}
}
}
}
return {
vehicleId: v.id,
organizationId: v.organizationId,
unitNumber: v.unitNumber,
routeKey,
routeIndex,
progress,
direction: Math.random() > 0.5 ? 1 : -1,
speedKmh: 50 + Math.random() * 50, // 50-100 km/h
};
}
private hashId(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) - h) + s.charCodeAt(i);
h |= 0;
}
return h;
}
private squaredDist(lat1: number, lng1: number, lat2: number, lng2: number) {
const dLat = lat2 - lat1;
const dLng = lng2 - lng1;
return dLat * dLat + dLng * dLng;
}
/**
* Calculate great-circle distance between two GPS coordinates in kilometres.
*
* @param lat1 - Start latitude in decimal degrees.
* @param lng1 - Start longitude in decimal degrees.
* @param lat2 - End latitude in decimal degrees.
* @param lng2 - End longitude in decimal degrees.
* @returns Distance in kilometres.
*/
private haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const dLat = this.toRad(lat2 - lat1);
const dLng = this.toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Calculate the initial bearing (compass heading) between two GPS points.
*
* @param lat1 - Start latitude in decimal degrees.
* @param lng1 - Start longitude in decimal degrees.
* @param lat2 - End latitude in decimal degrees.
* @param lng2 - End longitude in decimal degrees.
* @returns Bearing in degrees (0-360, clockwise from north).
*/
private calculateBearing(lat1: number, lng1: number, lat2: number, lng2: number): number {
const dLng = this.toRad(lng2 - lng1);
const y = Math.sin(dLng) * Math.cos(this.toRad(lat2));
const x =
Math.cos(this.toRad(lat1)) * Math.sin(this.toRad(lat2)) -
Math.sin(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.cos(dLng);
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
}