add passenger telemetry
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "nodemon src/index.js",
|
"dev": "nodemon src/index.js",
|
||||||
|
"test": "node --test",
|
||||||
"migrate": "node src/utils/migrate.js",
|
"migrate": "node src/utils/migrate.js",
|
||||||
"create-admin": "node scripts/create-admin.js",
|
"create-admin": "node scripts/create-admin.js",
|
||||||
"seed": "node src/utils/seed.js"
|
"seed": "node src/utils/seed.js"
|
||||||
|
|||||||
74
backend/src/controllers/passengerTelemetryController.js
Normal file
74
backend/src/controllers/passengerTelemetryController.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const { getPassengerTelemetryService } = require('../services/passengerTelemetryService');
|
||||||
|
|
||||||
|
function sendError(res, error, fallbackMessage) {
|
||||||
|
if (error.status) {
|
||||||
|
return res.status(error.status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(fallbackMessage, error);
|
||||||
|
return res.status(500).json({ error: fallbackMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
class PassengerTelemetryController {
|
||||||
|
constructor({
|
||||||
|
service = null,
|
||||||
|
broadcastToRoute = null
|
||||||
|
} = {}) {
|
||||||
|
this.service = service;
|
||||||
|
this.broadcastToRoute = broadcastToRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
getService() {
|
||||||
|
return this.service || getPassengerTelemetryService();
|
||||||
|
}
|
||||||
|
|
||||||
|
getBroadcastToRoute() {
|
||||||
|
if (this.broadcastToRoute) {
|
||||||
|
return this.broadcastToRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
return require('../services/realtimeService').broadcastToRoute;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRouteSnapshot(routeId) {
|
||||||
|
const telemetryService = require('../services/telemetryService');
|
||||||
|
return telemetryService.getRouteLiveVehicles(routeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(req, res) {
|
||||||
|
try {
|
||||||
|
const result = await this.getService().ingest(req.body);
|
||||||
|
const broadcastToRoute = this.getBroadcastToRoute();
|
||||||
|
|
||||||
|
if (result.routeId && (result.virtualVehicles || []).length > 0) {
|
||||||
|
const vehicles = await this.getRouteSnapshot(result.routeId).catch((error) => {
|
||||||
|
console.error('Failed to build passenger telemetry realtime snapshot', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vehicles) {
|
||||||
|
broadcastToRoute(result.routeId, {
|
||||||
|
type: 'snapshot:route',
|
||||||
|
routeId: result.routeId,
|
||||||
|
vehicles
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const vehicle of result.virtualVehicles) {
|
||||||
|
broadcastToRoute(vehicle.route_id, {
|
||||||
|
type: 'vehicle:position',
|
||||||
|
routeId: vehicle.route_id,
|
||||||
|
vehicle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(202).json({ accepted: true });
|
||||||
|
} catch (error) {
|
||||||
|
return sendError(res, error, 'Failed to ingest passenger telemetry');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new PassengerTelemetryController();
|
||||||
|
module.exports.PassengerTelemetryController = PassengerTelemetryController;
|
||||||
@@ -36,6 +36,19 @@ class TelemetryController {
|
|||||||
vehicle
|
vehicle
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const routeVehicles = await telemetryService.getRouteLiveVehicles(vehicle.route_id).catch((error) => {
|
||||||
|
logger.error('Failed to build realtime snapshot after official telemetry ingest:', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (routeVehicles) {
|
||||||
|
broadcastToRoute(vehicle.route_id, {
|
||||||
|
type: 'snapshot:route',
|
||||||
|
routeId: vehicle.route_id,
|
||||||
|
vehicles: routeVehicles
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: 'Telemetry ingested successfully',
|
message: 'Telemetry ingested successfully',
|
||||||
vehicle
|
vehicle
|
||||||
|
|||||||
83
backend/src/middleware/passengerTelemetryRateLimiter.js
Normal file
83
backend/src/middleware/passengerTelemetryRateLimiter.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
const { hashValue } = require('../services/passengerTelemetryAggregator');
|
||||||
|
|
||||||
|
const buckets = new Map();
|
||||||
|
|
||||||
|
function cleanupBucket(key, now) {
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
|
||||||
|
if (bucket && bucket.resetAt <= now) {
|
||||||
|
buckets.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementBucket(key, now, windowMs) {
|
||||||
|
cleanupBucket(key, now);
|
||||||
|
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bucket = {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + windowMs
|
||||||
|
};
|
||||||
|
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeKeyPart(value, fallback) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hashValue(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPassengerTelemetryRateLimiter({
|
||||||
|
maxRequests = 60,
|
||||||
|
windowMs = 60 * 1000,
|
||||||
|
nowProvider = () => Date.now()
|
||||||
|
} = {}) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const now = nowProvider();
|
||||||
|
const sessionId = req.body?.session_id ?? req.body?.sessionId;
|
||||||
|
const sessionKey = normalizeKeyPart(sessionId, 'missing-session');
|
||||||
|
const ipKey = normalizeKeyPart(req.ip || req.socket?.remoteAddress, 'unknown-ip');
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
`passenger-telemetry:session:${sessionKey}`,
|
||||||
|
`passenger-telemetry:ip:${ipKey}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (req.apiKeyInfo?.id) {
|
||||||
|
keys.push(`passenger-telemetry:api-key:${normalizeKeyPart(req.apiKeyInfo.id, 'api-key')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exceeded = keys
|
||||||
|
.map((key) => incrementBucket(key, now, windowMs))
|
||||||
|
.find((bucket) => bucket.count > maxRequests);
|
||||||
|
|
||||||
|
if (exceeded) {
|
||||||
|
return res.status(429).json({
|
||||||
|
error: 'Too many passenger telemetry samples',
|
||||||
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
|
retryAfter: Math.ceil((exceeded.resetAt - now) / 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPassengerTelemetryRateLimitersForTesting() {
|
||||||
|
buckets.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPassengerTelemetryRateLimiter,
|
||||||
|
resetPassengerTelemetryRateLimitersForTesting
|
||||||
|
};
|
||||||
@@ -10,6 +10,8 @@ const holidaysController = require('../controllers/holidaysController');
|
|||||||
const etaController = require('../controllers/etaController');
|
const etaController = require('../controllers/etaController');
|
||||||
const syncController = require('../controllers/syncController');
|
const syncController = require('../controllers/syncController');
|
||||||
const telemetryController = require('../controllers/telemetryController');
|
const telemetryController = require('../controllers/telemetryController');
|
||||||
|
const passengerTelemetryController = require('../controllers/passengerTelemetryController');
|
||||||
|
const { createPassengerTelemetryRateLimiter } = require('../middleware/passengerTelemetryRateLimiter');
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
router.get('/routes', routesController.getAllRoutes.bind(routesController));
|
||||||
@@ -60,6 +62,11 @@ router.get('/eta/stop/:stopId', etaController.getStopEtas.bind(etaController));
|
|||||||
|
|
||||||
// Realtime GPS / Telemetry
|
// Realtime GPS / Telemetry
|
||||||
router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryController));
|
router.post('/telemetry/ingest', telemetryController.ingest.bind(telemetryController));
|
||||||
|
router.post(
|
||||||
|
'/passenger-telemetry/ingest',
|
||||||
|
createPassengerTelemetryRateLimiter(),
|
||||||
|
passengerTelemetryController.ingest.bind(passengerTelemetryController)
|
||||||
|
);
|
||||||
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));
|
router.get('/realtime/routes/:routeId/vehicles', telemetryController.getRouteLiveVehicles.bind(telemetryController));
|
||||||
router.get('/realtime/vehicles/:vehicleId', telemetryController.getVehicleLiveState.bind(telemetryController));
|
router.get('/realtime/vehicles/:vehicleId', telemetryController.getVehicleLiveState.bind(telemetryController));
|
||||||
|
|
||||||
|
|||||||
32
backend/src/services/liveVehicleMapper.js
Normal file
32
backend/src/services/liveVehicleMapper.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
function parseNullableNumber(value) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateOfficialVehicle(row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
source: 'official',
|
||||||
|
confidence: 1,
|
||||||
|
accuracy_m: parseNullableNumber(row.accuracy_meters),
|
||||||
|
is_estimated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLiveVehicles(a, b) {
|
||||||
|
if (a.source !== b.source) {
|
||||||
|
return a.source === 'official' ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(b.last_seen_at).getTime() - new Date(a.last_seen_at).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
decorateOfficialVehicle,
|
||||||
|
parseNullableNumber,
|
||||||
|
sortLiveVehicles
|
||||||
|
};
|
||||||
503
backend/src/services/passengerTelemetryAggregator.js
Normal file
503
backend/src/services/passengerTelemetryAggregator.js
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const { createHttpError, validatePassengerTelemetryPayload } = require('./passengerTelemetryValidator');
|
||||||
|
|
||||||
|
const EARTH_RADIUS_M = 6371000;
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRadians(value) {
|
||||||
|
return value * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashValue(value) {
|
||||||
|
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function haversineDistanceMeters(a, b) {
|
||||||
|
const lat1 = toRadians(a.latitude);
|
||||||
|
const lat2 = toRadians(b.latitude);
|
||||||
|
const deltaLat = toRadians(b.latitude - a.latitude);
|
||||||
|
const deltaLon = toRadians(b.longitude - a.longitude);
|
||||||
|
const sinLat = Math.sin(deltaLat / 2);
|
||||||
|
const sinLon = Math.sin(deltaLon / 2);
|
||||||
|
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
|
||||||
|
|
||||||
|
return 2 * EARTH_RADIUS_M * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearingDegrees(a, b) {
|
||||||
|
const lat1 = toRadians(a.latitude);
|
||||||
|
const lat2 = toRadians(b.latitude);
|
||||||
|
const deltaLon = toRadians(b.longitude - a.longitude);
|
||||||
|
const y = Math.sin(deltaLon) * Math.cos(lat2);
|
||||||
|
const x = Math.cos(lat1) * Math.sin(lat2) -
|
||||||
|
Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon);
|
||||||
|
|
||||||
|
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
|
||||||
|
}
|
||||||
|
|
||||||
|
function headingDifferenceDegrees(a, b) {
|
||||||
|
const diff = Math.abs(a - b) % 360;
|
||||||
|
return diff > 180 ? 360 - diff : diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPlanar(point, referenceLatitude) {
|
||||||
|
return {
|
||||||
|
x: point.longitude * 111320 * Math.cos(toRadians(referenceLatitude)),
|
||||||
|
y: point.latitude * 110540
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function distanceToSegment(point, start, end) {
|
||||||
|
const refLat = (point.latitude + start.latitude + end.latitude) / 3;
|
||||||
|
const p = toPlanar(point, refLat);
|
||||||
|
const a = toPlanar(start, refLat);
|
||||||
|
const b = toPlanar(end, refLat);
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const lengthSquared = dx * dx + dy * dy;
|
||||||
|
|
||||||
|
if (lengthSquared === 0) {
|
||||||
|
return { distanceM: haversineDistanceMeters(point, start), fraction: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fraction = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSquared, 0, 1);
|
||||||
|
const x = a.x + fraction * dx;
|
||||||
|
const y = a.y + fraction * dy;
|
||||||
|
|
||||||
|
return {
|
||||||
|
distanceM: Math.sqrt((p.x - x) ** 2 + (p.y - y) ** 2),
|
||||||
|
fraction
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function average(values) {
|
||||||
|
const finite = values.filter((value) => Number.isFinite(value));
|
||||||
|
return finite.length
|
||||||
|
? finite.reduce((sum, value) => sum + value, 0) / finite.length
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoute(row) {
|
||||||
|
const rawStops = Array.isArray(row.stops)
|
||||||
|
? row.stops
|
||||||
|
: typeof row.stops === 'string'
|
||||||
|
? JSON.parse(row.stops)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number.parseInt(row.id, 10),
|
||||||
|
route_number: row.route_number,
|
||||||
|
name: row.name,
|
||||||
|
type: row.type,
|
||||||
|
color: row.color,
|
||||||
|
stops: rawStops
|
||||||
|
.map((stop) => ({
|
||||||
|
stop_id: Number.parseInt(stop.stop_id ?? stop.id, 10),
|
||||||
|
sequence: Number.parseInt(stop.sequence, 10),
|
||||||
|
latitude: Number.parseFloat(stop.latitude),
|
||||||
|
longitude: Number.parseFloat(stop.longitude)
|
||||||
|
}))
|
||||||
|
.filter((stop) => (
|
||||||
|
Number.isInteger(stop.stop_id) &&
|
||||||
|
Number.isFinite(stop.latitude) &&
|
||||||
|
Number.isFinite(stop.longitude)
|
||||||
|
))
|
||||||
|
.sort((a, b) => a.sequence - b.sequence)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceLabel(confidence) {
|
||||||
|
if (confidence >= 0.75) return 'HIGH';
|
||||||
|
if (confidence >= 0.5) return 'MEDIUM';
|
||||||
|
return 'LOW';
|
||||||
|
}
|
||||||
|
|
||||||
|
class PassengerTelemetryAggregator {
|
||||||
|
constructor({
|
||||||
|
pool,
|
||||||
|
nowProvider = () => new Date(),
|
||||||
|
ttlMs = 10 * 60 * 1000,
|
||||||
|
routeMatchMaxDistanceM = 200,
|
||||||
|
maxAggregationAccuracyM = 250,
|
||||||
|
poorAccuracyThresholdM = 100,
|
||||||
|
clusterDistanceM = 160,
|
||||||
|
clusterTimeWindowMs = 3 * 60 * 1000,
|
||||||
|
clusterSpeedWindowKmh = 25,
|
||||||
|
minDistinctSessions = 2,
|
||||||
|
minConfidence = 0.3,
|
||||||
|
officialSuppressionDistanceM = 300
|
||||||
|
}) {
|
||||||
|
if (!pool || typeof pool.query !== 'function') {
|
||||||
|
throw new Error('PassengerTelemetryAggregator requires a pool with query(sql, params)');
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this, {
|
||||||
|
pool,
|
||||||
|
nowProvider,
|
||||||
|
ttlMs,
|
||||||
|
routeMatchMaxDistanceM,
|
||||||
|
maxAggregationAccuracyM,
|
||||||
|
poorAccuracyThresholdM,
|
||||||
|
clusterDistanceM,
|
||||||
|
clusterTimeWindowMs,
|
||||||
|
clusterSpeedWindowKmh,
|
||||||
|
minDistinctSessions,
|
||||||
|
minConfidence,
|
||||||
|
officialSuppressionDistanceM
|
||||||
|
});
|
||||||
|
this.samples = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(payload) {
|
||||||
|
const now = this.nowProvider();
|
||||||
|
this.prune(now);
|
||||||
|
|
||||||
|
const point = validatePassengerTelemetryPayload(payload, {
|
||||||
|
now,
|
||||||
|
maxAgeSeconds: Math.floor(this.ttlMs / 1000)
|
||||||
|
});
|
||||||
|
const match = await this.matchPointToRoute(point);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { accepted: true, matched: false, virtualVehicles: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (point.accuracyM !== null && point.accuracyM > this.maxAggregationAccuracyM) {
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
matched: true,
|
||||||
|
routeId: match.route.id,
|
||||||
|
ignoredReason: 'accuracy_too_low',
|
||||||
|
virtualVehicles: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.samples.push({
|
||||||
|
sample_id: crypto.randomUUID ? crypto.randomUUID() : hashValue(`${Date.now()}:${Math.random()}`),
|
||||||
|
session_key: hashValue(point.sessionId),
|
||||||
|
route_id: match.route.id,
|
||||||
|
route_number: match.route.route_number,
|
||||||
|
route_name: match.route.name,
|
||||||
|
route_type: match.route.type,
|
||||||
|
route_color: match.route.color,
|
||||||
|
latitude: point.latitude,
|
||||||
|
longitude: point.longitude,
|
||||||
|
accuracy_m: point.accuracyM,
|
||||||
|
speed_kmh: point.speedKmh,
|
||||||
|
heading: point.heading,
|
||||||
|
observed_at: point.observedAt,
|
||||||
|
received_at: now,
|
||||||
|
app_state: point.appState,
|
||||||
|
stop_id: point.stopId,
|
||||||
|
matched_distance_m: match.distanceM,
|
||||||
|
progress_m: match.progressM,
|
||||||
|
direction: this.resolveDirection(point.heading, match.bearing)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
matched: true,
|
||||||
|
routeId: match.route.id,
|
||||||
|
virtualVehicles: await this.getVirtualVehiclesForRoute(match.route.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVirtualVehiclesForRoute(routeId, options = {}) {
|
||||||
|
const routeIdInt = Number.parseInt(routeId, 10);
|
||||||
|
|
||||||
|
if (!Number.isInteger(routeIdInt) || routeIdInt <= 0) {
|
||||||
|
throw createHttpError(400, 'routeId must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = this.nowProvider();
|
||||||
|
this.prune(now);
|
||||||
|
|
||||||
|
const routeSamples = this.samples.filter((sample) => (
|
||||||
|
sample.route_id === routeIdInt &&
|
||||||
|
now.getTime() - sample.received_at.getTime() <= this.ttlMs &&
|
||||||
|
sample.matched_distance_m <= this.routeMatchMaxDistanceM &&
|
||||||
|
(sample.accuracy_m === null || sample.accuracy_m <= this.maxAggregationAccuracyM)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (routeSamples.length < this.minDistinctSessions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const officialVehicles = Array.isArray(options.officialVehicles) ? options.officialVehicles : [];
|
||||||
|
|
||||||
|
return this.clusterSamples(routeSamples)
|
||||||
|
.map((cluster) => this.buildVirtualVehicle(cluster, now))
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((vehicle) => !this.isSuppressedByOfficialVehicle(vehicle, officialVehicles));
|
||||||
|
}
|
||||||
|
|
||||||
|
prune(now = this.nowProvider()) {
|
||||||
|
const cutoffMs = now.getTime() - this.ttlMs;
|
||||||
|
this.samples = this.samples.filter((sample) => (
|
||||||
|
sample.received_at.getTime() >= cutoffMs &&
|
||||||
|
sample.observed_at.getTime() >= cutoffMs
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.samples = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCandidateRoutes(routeId) {
|
||||||
|
const params = [];
|
||||||
|
let whereClause = 'WHERE r.is_active = true';
|
||||||
|
|
||||||
|
if (routeId) {
|
||||||
|
params.push(routeId);
|
||||||
|
whereClause += ' AND r.id = $1';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query(
|
||||||
|
`SELECT
|
||||||
|
r.id,
|
||||||
|
r.route_number,
|
||||||
|
r.name,
|
||||||
|
r.type,
|
||||||
|
r.color,
|
||||||
|
COALESCE(
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'stop_id', s.id,
|
||||||
|
'sequence', rs.sequence,
|
||||||
|
'latitude', ST_Y(s.location::geometry),
|
||||||
|
'longitude', ST_X(s.location::geometry)
|
||||||
|
)
|
||||||
|
ORDER BY rs.sequence
|
||||||
|
) FILTER (WHERE s.id IS NOT NULL),
|
||||||
|
'[]'::json
|
||||||
|
) AS stops
|
||||||
|
FROM routes r
|
||||||
|
LEFT JOIN route_stops rs ON rs.route_id = r.id
|
||||||
|
LEFT JOIN stops s ON s.id = rs.stop_id AND s.is_active = true
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY r.id, r.route_number, r.name, r.type, r.color
|
||||||
|
ORDER BY r.id`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.rows.map(normalizeRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
async matchPointToRoute(point) {
|
||||||
|
const routes = await this.getCandidateRoutes(point.routeId);
|
||||||
|
|
||||||
|
if (point.routeId && routes.length === 0) {
|
||||||
|
throw createHttpError(404, 'Route not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const best = routes
|
||||||
|
.map((route) => ({ route, match: this.distanceToRoute(point, route) }))
|
||||||
|
.filter((entry) => entry.match)
|
||||||
|
.sort((a, b) => a.match.distanceM - b.match.distanceM)[0];
|
||||||
|
|
||||||
|
if (!best || best.match.distanceM > this.routeMatchMaxDistanceM) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route: best.route, ...best.match };
|
||||||
|
}
|
||||||
|
|
||||||
|
distanceToRoute(point, route) {
|
||||||
|
if (!route.stops || route.stops.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (route.stops.length === 1) {
|
||||||
|
return {
|
||||||
|
distanceM: haversineDistanceMeters(point, route.stops[0]),
|
||||||
|
progressM: 0,
|
||||||
|
bearing: point.heading ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressBeforeM = 0;
|
||||||
|
let best = null;
|
||||||
|
|
||||||
|
for (let index = 0; index < route.stops.length - 1; index += 1) {
|
||||||
|
const start = route.stops[index];
|
||||||
|
const end = route.stops[index + 1];
|
||||||
|
const segmentLengthM = haversineDistanceMeters(start, end);
|
||||||
|
const segment = distanceToSegment(point, start, end);
|
||||||
|
const candidate = {
|
||||||
|
distanceM: segment.distanceM,
|
||||||
|
progressM: progressBeforeM + segment.fraction * segmentLengthM,
|
||||||
|
bearing: bearingDegrees(start, end)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!best || candidate.distanceM < best.distanceM) {
|
||||||
|
best = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressBeforeM += segmentLengthM;
|
||||||
|
}
|
||||||
|
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveDirection(heading, routeBearing) {
|
||||||
|
if (!Number.isFinite(heading) || !Number.isFinite(routeBearing)) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return headingDifferenceDegrees(heading, routeBearing) <= 90 ? 'forward' : 'backward';
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterSamples(samples) {
|
||||||
|
const clusters = [];
|
||||||
|
|
||||||
|
for (const sample of [...samples].sort((a, b) => a.progress_m - b.progress_m)) {
|
||||||
|
const cluster = clusters.find((item) => this.sampleFitsCluster(sample, item));
|
||||||
|
|
||||||
|
if (!cluster) {
|
||||||
|
clusters.push({
|
||||||
|
direction: sample.direction,
|
||||||
|
progress_m: sample.progress_m,
|
||||||
|
observed_at: sample.observed_at,
|
||||||
|
speed_kmh: sample.speed_kmh,
|
||||||
|
samples: [sample]
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.samples.push(sample);
|
||||||
|
cluster.progress_m = average(cluster.samples.map((item) => item.progress_m));
|
||||||
|
cluster.observed_at = new Date(Math.max(...cluster.samples.map((item) => item.observed_at.getTime())));
|
||||||
|
cluster.speed_kmh = average(cluster.samples.map((item) => item.speed_kmh));
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
sampleFitsCluster(sample, cluster) {
|
||||||
|
const directionsMatch = sample.direction === cluster.direction ||
|
||||||
|
sample.direction === 'unknown' ||
|
||||||
|
cluster.direction === 'unknown';
|
||||||
|
const speedDiff = Number.isFinite(sample.speed_kmh) && Number.isFinite(cluster.speed_kmh)
|
||||||
|
? Math.abs(sample.speed_kmh - cluster.speed_kmh)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return directionsMatch &&
|
||||||
|
Math.abs(sample.progress_m - cluster.progress_m) <= this.clusterDistanceM &&
|
||||||
|
Math.abs(sample.observed_at.getTime() - cluster.observed_at.getTime()) <= this.clusterTimeWindowMs &&
|
||||||
|
speedDiff <= this.clusterSpeedWindowKmh;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildVirtualVehicle(cluster, now) {
|
||||||
|
const samples = cluster.samples;
|
||||||
|
const distinctSessions = new Set(samples.map((sample) => sample.session_key));
|
||||||
|
|
||||||
|
if (distinctSessions.size < this.minDistinctSessions) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confidence = this.calculateConfidence(samples, distinctSessions.size, now);
|
||||||
|
|
||||||
|
if (confidence < this.minConfidence) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newest = samples.reduce((latest, sample) => (
|
||||||
|
sample.observed_at > latest.observed_at ? sample : latest
|
||||||
|
), samples[0]);
|
||||||
|
const position = this.weightedPosition(samples);
|
||||||
|
const clusterId = hashValue(
|
||||||
|
`${newest.route_id}:${cluster.direction}:${Math.round(cluster.progress_m / this.clusterDistanceM)}`
|
||||||
|
).slice(0, 12);
|
||||||
|
const speedKmh = average(samples.map((sample) => sample.speed_kmh));
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicle_id: `crowd:${newest.route_id}:${clusterId}`,
|
||||||
|
vehicle_number: null,
|
||||||
|
registration: null,
|
||||||
|
vehicle_type: newest.route_type,
|
||||||
|
capacity: null,
|
||||||
|
route_id: newest.route_id,
|
||||||
|
route_number: newest.route_number,
|
||||||
|
route_name: newest.route_name,
|
||||||
|
route_color: newest.route_color,
|
||||||
|
latitude: position.latitude,
|
||||||
|
longitude: position.longitude,
|
||||||
|
speed_kmh: speedKmh,
|
||||||
|
heading: average(samples.map((sample) => sample.heading)),
|
||||||
|
accuracy_meters: average(samples.map((sample) => sample.accuracy_m)),
|
||||||
|
accuracy_m: average(samples.map((sample) => sample.accuracy_m)),
|
||||||
|
source_type: 'passenger',
|
||||||
|
source_ref: null,
|
||||||
|
source: 'crowdsourced',
|
||||||
|
confidence,
|
||||||
|
confidence_label: confidenceLabel(confidence),
|
||||||
|
is_estimated: true,
|
||||||
|
last_seen_at: newest.observed_at.toISOString(),
|
||||||
|
updated_at: now.toISOString(),
|
||||||
|
is_online: true,
|
||||||
|
seconds_since_update: Math.max(0, Math.floor((now.getTime() - newest.observed_at.getTime()) / 1000)),
|
||||||
|
is_moving: Number.isFinite(speedKmh) ? speedKmh >= 1 : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateConfidence(samples, distinctSessionCount, now) {
|
||||||
|
const avgAccuracy = average(samples.map((sample) => sample.accuracy_m)) ?? this.poorAccuracyThresholdM;
|
||||||
|
const avgDistance = average(samples.map((sample) => sample.matched_distance_m)) ?? this.routeMatchMaxDistanceM;
|
||||||
|
const newestObservedAt = Math.max(...samples.map((sample) => sample.observed_at.getTime()));
|
||||||
|
const sessionFactor = clamp((distinctSessionCount - 1) / 3, 0, 1);
|
||||||
|
const accuracyFactor = clamp(1 - (avgAccuracy / this.maxAggregationAccuracyM), 0, 1);
|
||||||
|
const distanceFactor = clamp(1 - (avgDistance / this.routeMatchMaxDistanceM), 0, 1);
|
||||||
|
const recencyFactor = clamp(1 - ((now.getTime() - newestObservedAt) / this.ttlMs), 0, 1);
|
||||||
|
const confidence = 0.2 + sessionFactor * 0.35 + accuracyFactor * 0.2 + distanceFactor * 0.15 + recencyFactor * 0.1;
|
||||||
|
|
||||||
|
return Number(clamp(confidence, 0, 0.95).toFixed(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
weightedPosition(samples) {
|
||||||
|
let weightSum = 0;
|
||||||
|
let latSum = 0;
|
||||||
|
let lonSum = 0;
|
||||||
|
|
||||||
|
for (const sample of samples) {
|
||||||
|
const accuracy = Number.isFinite(sample.accuracy_m) && sample.accuracy_m > 0
|
||||||
|
? sample.accuracy_m
|
||||||
|
: this.poorAccuracyThresholdM;
|
||||||
|
const weight = 1 / Math.max(accuracy, 5);
|
||||||
|
weightSum += weight;
|
||||||
|
latSum += sample.latitude * weight;
|
||||||
|
lonSum += sample.longitude * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: Number((latSum / weightSum).toFixed(6)),
|
||||||
|
longitude: Number((lonSum / weightSum).toFixed(6))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isSuppressedByOfficialVehicle(vehicle, officialVehicles) {
|
||||||
|
return officialVehicles.some((official) => {
|
||||||
|
if (!official || official.is_online === false) return false;
|
||||||
|
|
||||||
|
const latitude = Number.parseFloat(official.latitude);
|
||||||
|
const longitude = Number.parseFloat(official.longitude);
|
||||||
|
|
||||||
|
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return false;
|
||||||
|
|
||||||
|
return haversineDistanceMeters(
|
||||||
|
{ latitude: vehicle.latitude, longitude: vehicle.longitude },
|
||||||
|
{ latitude, longitude }
|
||||||
|
) <= this.officialSuppressionDistanceM;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PassengerTelemetryAggregator,
|
||||||
|
bearingDegrees,
|
||||||
|
haversineDistanceMeters,
|
||||||
|
hashValue
|
||||||
|
};
|
||||||
21
backend/src/services/passengerTelemetryService.js
Normal file
21
backend/src/services/passengerTelemetryService.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { PassengerTelemetryAggregator } = require('./passengerTelemetryAggregator');
|
||||||
|
|
||||||
|
let singleton = null;
|
||||||
|
|
||||||
|
function getPassengerTelemetryService() {
|
||||||
|
if (!singleton) {
|
||||||
|
const pool = require('../config/database');
|
||||||
|
singleton = new PassengerTelemetryAggregator({ pool });
|
||||||
|
}
|
||||||
|
|
||||||
|
return singleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPassengerTelemetryServiceForTesting(service) {
|
||||||
|
singleton = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPassengerTelemetryService,
|
||||||
|
setPassengerTelemetryServiceForTesting
|
||||||
|
};
|
||||||
130
backend/src/services/passengerTelemetryValidator.js
Normal file
130
backend/src/services/passengerTelemetryValidator.js
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
const DEFAULT_MAX_FUTURE_SECONDS = 120;
|
||||||
|
const DEFAULT_MAX_AGE_SECONDS = 10 * 60;
|
||||||
|
const MAX_REASONABLE_ACCURACY_M = 1000;
|
||||||
|
const MAX_CITY_TRANSIT_SPEED_KMH = 120;
|
||||||
|
|
||||||
|
function createHttpError(status, message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.status = status;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value, fieldName, required = false) {
|
||||||
|
if (!required && (value === undefined || value === null || value === '')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
throw createHttpError(400, `${fieldName} must be a valid number`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value, fieldName) {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw createHttpError(400, `${fieldName} must be a positive integer`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionIdValue(value) {
|
||||||
|
if (typeof value !== 'string' || !value.trim()) {
|
||||||
|
throw createHttpError(400, 'session_id must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = value.trim();
|
||||||
|
if (sessionId.length > 128) {
|
||||||
|
throw createHttpError(400, 'session_id must be 128 characters or fewer');
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observedAtValue(value, now, maxFutureSeconds, maxAgeSeconds) {
|
||||||
|
const observedAt = value ? new Date(value) : new Date(now);
|
||||||
|
|
||||||
|
if (Number.isNaN(observedAt.getTime())) {
|
||||||
|
throw createHttpError(400, 'observed_at must be a valid ISO datetime string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaMs = observedAt.getTime() - now.getTime();
|
||||||
|
if (deltaMs > maxFutureSeconds * 1000) {
|
||||||
|
throw createHttpError(400, 'observed_at is too far in the future');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deltaMs < -maxAgeSeconds * 1000) {
|
||||||
|
throw createHttpError(400, 'observed_at is too old');
|
||||||
|
}
|
||||||
|
|
||||||
|
return observedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePassengerTelemetryPayload(payload, options = {}) {
|
||||||
|
const now = options.now ? new Date(options.now) : new Date();
|
||||||
|
const sessionId = sessionIdValue(payload.session_id ?? payload.sessionId);
|
||||||
|
const latitude = numberValue(payload.latitude, 'latitude', true);
|
||||||
|
const longitude = numberValue(payload.longitude, 'longitude', true);
|
||||||
|
|
||||||
|
if (latitude < -90 || latitude > 90) {
|
||||||
|
throw createHttpError(400, 'latitude must be between -90 and 90');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (longitude < -180 || longitude > 180) {
|
||||||
|
throw createHttpError(400, 'longitude must be between -180 and 180');
|
||||||
|
}
|
||||||
|
|
||||||
|
const accuracyM = numberValue(payload.accuracy_m ?? payload.accuracyMeters, 'accuracy_m');
|
||||||
|
if (accuracyM !== null && (accuracyM < 0 || accuracyM > MAX_REASONABLE_ACCURACY_M)) {
|
||||||
|
throw createHttpError(400, `accuracy_m must be between 0 and ${MAX_REASONABLE_ACCURACY_M}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const speedKmh = numberValue(payload.speed_kmh ?? payload.speedKmh, 'speed_kmh');
|
||||||
|
if (speedKmh !== null && (speedKmh < 0 || speedKmh > MAX_CITY_TRANSIT_SPEED_KMH)) {
|
||||||
|
throw createHttpError(400, `speed_kmh must be between 0 and ${MAX_CITY_TRANSIT_SPEED_KMH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = numberValue(payload.heading, 'heading');
|
||||||
|
if (heading !== null && (heading < 0 || heading > 360)) {
|
||||||
|
throw createHttpError(400, 'heading must be between 0 and 360');
|
||||||
|
}
|
||||||
|
|
||||||
|
const appState = payload.app_state ?? payload.appState ?? null;
|
||||||
|
if (appState !== null && appState !== undefined && !['foreground', 'background'].includes(appState)) {
|
||||||
|
throw createHttpError(400, 'app_state must be foreground or background');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
accuracyM,
|
||||||
|
speedKmh,
|
||||||
|
heading,
|
||||||
|
observedAt: observedAtValue(
|
||||||
|
payload.observed_at ?? payload.observedAt,
|
||||||
|
now,
|
||||||
|
options.maxFutureSeconds ?? DEFAULT_MAX_FUTURE_SECONDS,
|
||||||
|
options.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS
|
||||||
|
),
|
||||||
|
routeId: positiveInteger(payload.route_id ?? payload.routeId, 'route_id'),
|
||||||
|
stopId: positiveInteger(payload.stop_id ?? payload.stopId, 'stop_id'),
|
||||||
|
appState: appState || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_MAX_AGE_SECONDS,
|
||||||
|
DEFAULT_MAX_FUTURE_SECONDS,
|
||||||
|
MAX_CITY_TRANSIT_SPEED_KMH,
|
||||||
|
MAX_REASONABLE_ACCURACY_M,
|
||||||
|
createHttpError,
|
||||||
|
validatePassengerTelemetryPayload
|
||||||
|
};
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
const pool = require('../config/database');
|
const pool = require('../config/database');
|
||||||
const logger = require('../config/logger');
|
const logger = require('../config/logger');
|
||||||
const { ensureRealtimeSchema } = require('./realtimeSchema');
|
const { ensureRealtimeSchema } = require('./realtimeSchema');
|
||||||
|
const { getPassengerTelemetryService } = require('./passengerTelemetryService');
|
||||||
|
const { decorateOfficialVehicle, sortLiveVehicles } = require('./liveVehicleMapper');
|
||||||
|
|
||||||
const DEFAULT_STALE_AFTER_SECONDS = 180;
|
const DEFAULT_STALE_AFTER_SECONDS = 180;
|
||||||
|
|
||||||
@@ -243,7 +245,15 @@ class TelemetryService {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await pool.query(query, [routeIdInt, staleAfterSeconds]);
|
const result = await pool.query(query, [routeIdInt, staleAfterSeconds]);
|
||||||
return result.rows;
|
const officialVehicles = result.rows.map(decorateOfficialVehicle);
|
||||||
|
const crowdsourcedVehicles = await getPassengerTelemetryService()
|
||||||
|
.getVirtualVehiclesForRoute(routeIdInt, { officialVehicles })
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error(`Failed to build passenger telemetry vehicles for route ${routeIdInt}:`, error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...officialVehicles, ...crowdsourcedVehicles].sort(sortLiveVehicles);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVehicleLiveState(vehicleId, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
|
async getVehicleLiveState(vehicleId, staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS) {
|
||||||
@@ -293,7 +303,7 @@ class TelemetryService {
|
|||||||
throw createHttpError(404, 'Live state not found for this vehicle');
|
throw createHttpError(404, 'Live state not found for this vehicle');
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.rows[0];
|
return decorateOfficialVehicle(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRoute(routeId) {
|
async getRoute(routeId) {
|
||||||
|
|||||||
142
backend/test/passengerTelemetryAggregator.test.js
Normal file
142
backend/test/passengerTelemetryAggregator.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const { PassengerTelemetryAggregator } = require('../src/services/passengerTelemetryAggregator');
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-20T12:30:00Z');
|
||||||
|
const routeRows = [{
|
||||||
|
id: 1,
|
||||||
|
route_number: '12',
|
||||||
|
name: 'Route 12',
|
||||||
|
type: 'bus',
|
||||||
|
color: '#0066CC',
|
||||||
|
stops: [
|
||||||
|
{ stop_id: 1, sequence: 1, latitude: 55.485, longitude: 28.768 },
|
||||||
|
{ stop_id: 2, sequence: 2, latitude: 55.485, longitude: 28.778 }
|
||||||
|
]
|
||||||
|
}];
|
||||||
|
|
||||||
|
function createPool(rows = routeRows) {
|
||||||
|
return {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
if (params.length > 0) {
|
||||||
|
return { rows: rows.filter((row) => row.id === params[0]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAggregator(options = {}) {
|
||||||
|
return new PassengerTelemetryAggregator({
|
||||||
|
pool: createPool(options.rows),
|
||||||
|
nowProvider: () => NOW,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ingestCluster(aggregator, accuracyM = 18) {
|
||||||
|
await aggregator.ingest({
|
||||||
|
session_id: 'session-a',
|
||||||
|
latitude: 55.48502,
|
||||||
|
longitude: 28.7700,
|
||||||
|
accuracy_m: accuracyM,
|
||||||
|
speed_kmh: 31,
|
||||||
|
heading: 90,
|
||||||
|
observed_at: '2026-05-20T12:29:55Z',
|
||||||
|
route_id: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
await aggregator.ingest({
|
||||||
|
session_id: 'session-b',
|
||||||
|
latitude: 55.48498,
|
||||||
|
longitude: 28.77015,
|
||||||
|
accuracy_m: accuracyM,
|
||||||
|
speed_kmh: 33,
|
||||||
|
heading: 92,
|
||||||
|
observed_at: '2026-05-20T12:29:58Z',
|
||||||
|
route_id: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('one passenger point does not create a virtual vehicle', async () => {
|
||||||
|
const aggregator = createAggregator();
|
||||||
|
const result = await aggregator.ingest({
|
||||||
|
session_id: 'session-a',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 28.770,
|
||||||
|
accuracy_m: 18,
|
||||||
|
speed_kmh: 32,
|
||||||
|
heading: 90,
|
||||||
|
observed_at: '2026-05-20T12:29:55Z',
|
||||||
|
route_id: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.accepted, true);
|
||||||
|
assert.equal(result.matched, true);
|
||||||
|
assert.deepEqual(result.virtualVehicles, []);
|
||||||
|
assert.deepEqual(await aggregator.getVirtualVehiclesForRoute(1), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearby points from distinct sessions create a virtual crowdsourced vehicle', async () => {
|
||||||
|
const aggregator = createAggregator();
|
||||||
|
await ingestCluster(aggregator);
|
||||||
|
const vehicles = await aggregator.getVirtualVehiclesForRoute(1);
|
||||||
|
|
||||||
|
assert.equal(vehicles.length, 1);
|
||||||
|
assert.match(vehicles[0].vehicle_id, /^crowd:1:/);
|
||||||
|
assert.equal(vehicles[0].source, 'crowdsourced');
|
||||||
|
assert.equal(vehicles[0].source_ref, null);
|
||||||
|
assert.equal(vehicles[0].is_estimated, true);
|
||||||
|
assert.equal('session_id' in vehicles[0], false);
|
||||||
|
assert.equal('session_key' in vehicles[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('poor accuracy lowers crowdsourced confidence', async () => {
|
||||||
|
const goodAggregator = createAggregator();
|
||||||
|
await ingestCluster(goodAggregator, 15);
|
||||||
|
const [goodVehicle] = await goodAggregator.getVirtualVehiclesForRoute(1);
|
||||||
|
|
||||||
|
const poorAggregator = createAggregator();
|
||||||
|
await ingestCluster(poorAggregator, 150);
|
||||||
|
const [poorVehicle] = await poorAggregator.getVirtualVehiclesForRoute(1);
|
||||||
|
|
||||||
|
assert.ok(goodVehicle.confidence > poorVehicle.confidence);
|
||||||
|
assert.equal(poorVehicle.source, 'crowdsourced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('points outside route geometry are accepted but ignored', async () => {
|
||||||
|
const aggregator = createAggregator();
|
||||||
|
const result = await aggregator.ingest({
|
||||||
|
session_id: 'session-a',
|
||||||
|
latitude: 56.0,
|
||||||
|
longitude: 29.5,
|
||||||
|
accuracy_m: 20,
|
||||||
|
observed_at: '2026-05-20T12:29:55Z',
|
||||||
|
route_id: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.accepted, true);
|
||||||
|
assert.equal(result.matched, false);
|
||||||
|
assert.deepEqual(await aggregator.getVirtualVehiclesForRoute(1), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official telemetry suppresses nearby crowdsourced virtual vehicle', async () => {
|
||||||
|
const aggregator = createAggregator();
|
||||||
|
await ingestCluster(aggregator);
|
||||||
|
const [vehicle] = await aggregator.getVirtualVehiclesForRoute(1);
|
||||||
|
|
||||||
|
assert.ok(vehicle);
|
||||||
|
|
||||||
|
const suppressed = await aggregator.getVirtualVehiclesForRoute(1, {
|
||||||
|
officialVehicles: [{
|
||||||
|
vehicle_id: 99,
|
||||||
|
latitude: vehicle.latitude,
|
||||||
|
longitude: vehicle.longitude,
|
||||||
|
is_online: true
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(suppressed, []);
|
||||||
|
});
|
||||||
110
backend/test/passengerTelemetryApi.test.js
Normal file
110
backend/test/passengerTelemetryApi.test.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const { PassengerTelemetryController } = require('../src/controllers/passengerTelemetryController');
|
||||||
|
const { decorateOfficialVehicle } = require('../src/services/liveVehicleMapper');
|
||||||
|
|
||||||
|
function createResponse() {
|
||||||
|
return {
|
||||||
|
statusCode: null,
|
||||||
|
body: null,
|
||||||
|
status(code) {
|
||||||
|
this.statusCode = code;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
json(payload) {
|
||||||
|
this.body = payload;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('POST passenger telemetry ingest returns accepted response and broadcasts virtual vehicles', async () => {
|
||||||
|
const broadcasts = [];
|
||||||
|
const service = {
|
||||||
|
async ingest(payload) {
|
||||||
|
assert.equal(payload.session_id, 'temporary-anonymous-id');
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
routeId: 12,
|
||||||
|
virtualVehicles: [
|
||||||
|
{
|
||||||
|
vehicle_id: 'crowd:12:abc123',
|
||||||
|
route_id: 12,
|
||||||
|
source: 'crowdsourced',
|
||||||
|
source_ref: null,
|
||||||
|
is_estimated: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controller = new PassengerTelemetryController({
|
||||||
|
service,
|
||||||
|
broadcastToRoute: (routeId, message) => broadcasts.push({ routeId, message })
|
||||||
|
});
|
||||||
|
controller.getRouteSnapshot = async () => [
|
||||||
|
{
|
||||||
|
vehicle_id: 'crowd:12:abc123',
|
||||||
|
route_id: 12,
|
||||||
|
source: 'crowdsourced',
|
||||||
|
source_ref: null,
|
||||||
|
is_estimated: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.ingest({
|
||||||
|
body: {
|
||||||
|
session_id: 'temporary-anonymous-id',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 28.768
|
||||||
|
}
|
||||||
|
}, res);
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 202);
|
||||||
|
assert.deepEqual(res.body, { accepted: true });
|
||||||
|
assert.equal(broadcasts.length, 1);
|
||||||
|
assert.equal(broadcasts[0].routeId, 12);
|
||||||
|
assert.equal(broadcasts[0].message.type, 'snapshot:route');
|
||||||
|
assert.equal(broadcasts[0].message.vehicles[0].source, 'crowdsourced');
|
||||||
|
assert.equal('session_id' in broadcasts[0].message.vehicles[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passenger telemetry controller maps validation failures to 400', async () => {
|
||||||
|
const service = {
|
||||||
|
async ingest() {
|
||||||
|
const error = new Error('latitude must be between -90 and 90');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const controller = new PassengerTelemetryController({
|
||||||
|
service,
|
||||||
|
broadcastToRoute: () => {}
|
||||||
|
});
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.ingest({ body: {} }, res);
|
||||||
|
|
||||||
|
assert.equal(res.statusCode, 400);
|
||||||
|
assert.deepEqual(res.body, { error: 'latitude must be between -90 and 90' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('official live vehicle mapping adds optional realtime fields', () => {
|
||||||
|
const vehicle = decorateOfficialVehicle({
|
||||||
|
vehicle_id: 7,
|
||||||
|
vehicle_number: 'DEMO-7A',
|
||||||
|
route_id: 12,
|
||||||
|
latitude: '55.485',
|
||||||
|
longitude: '28.768',
|
||||||
|
accuracy_meters: '5.5',
|
||||||
|
last_seen_at: '2026-05-20T12:29:58Z'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(vehicle.source, 'official');
|
||||||
|
assert.equal(vehicle.confidence, 1);
|
||||||
|
assert.equal(vehicle.accuracy_m, 5.5);
|
||||||
|
assert.equal(vehicle.is_estimated, false);
|
||||||
|
});
|
||||||
70
backend/test/passengerTelemetryValidator.test.js
Normal file
70
backend/test/passengerTelemetryValidator.test.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const { validatePassengerTelemetryPayload } = require('../src/services/passengerTelemetryValidator');
|
||||||
|
|
||||||
|
const NOW = new Date('2026-05-20T12:30:00Z');
|
||||||
|
|
||||||
|
test('validates and normalizes passenger telemetry payload', () => {
|
||||||
|
const payload = validatePassengerTelemetryPayload({
|
||||||
|
session_id: ' temporary-anonymous-id ',
|
||||||
|
latitude: '55.485',
|
||||||
|
longitude: '28.768',
|
||||||
|
accuracy_m: '18.5',
|
||||||
|
speed_kmh: '32',
|
||||||
|
heading: '91',
|
||||||
|
observed_at: '2026-05-20T12:29:30Z',
|
||||||
|
route_id: '12',
|
||||||
|
stop_id: '123',
|
||||||
|
app_state: 'foreground'
|
||||||
|
}, { now: NOW });
|
||||||
|
|
||||||
|
assert.equal(payload.sessionId, 'temporary-anonymous-id');
|
||||||
|
assert.equal(payload.latitude, 55.485);
|
||||||
|
assert.equal(payload.longitude, 28.768);
|
||||||
|
assert.equal(payload.accuracyM, 18.5);
|
||||||
|
assert.equal(payload.speedKmh, 32);
|
||||||
|
assert.equal(payload.heading, 91);
|
||||||
|
assert.equal(payload.routeId, 12);
|
||||||
|
assert.equal(payload.stopId, 123);
|
||||||
|
assert.equal(payload.appState, 'foreground');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid latitude and longitude', () => {
|
||||||
|
assert.throws(() => validatePassengerTelemetryPayload({
|
||||||
|
session_id: 's1',
|
||||||
|
latitude: 91,
|
||||||
|
longitude: 28.768
|
||||||
|
}, { now: NOW }), /latitude must be between -90 and 90/);
|
||||||
|
|
||||||
|
assert.throws(() => validatePassengerTelemetryPayload({
|
||||||
|
session_id: 's1',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 181
|
||||||
|
}, { now: NOW }), /longitude must be between -180 and 180/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects stale and far-future observations', () => {
|
||||||
|
assert.throws(() => validatePassengerTelemetryPayload({
|
||||||
|
session_id: 's1',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 28.768,
|
||||||
|
observed_at: '2026-05-20T12:10:00Z'
|
||||||
|
}, { now: NOW }), /observed_at is too old/);
|
||||||
|
|
||||||
|
assert.throws(() => validatePassengerTelemetryPayload({
|
||||||
|
session_id: 's1',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 28.768,
|
||||||
|
observed_at: '2026-05-20T12:40:00Z'
|
||||||
|
}, { now: NOW }), /observed_at is too far in the future/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects implausible city transit speed', () => {
|
||||||
|
assert.throws(() => validatePassengerTelemetryPayload({
|
||||||
|
session_id: 's1',
|
||||||
|
latitude: 55.485,
|
||||||
|
longitude: 28.768,
|
||||||
|
speed_kmh: 180
|
||||||
|
}, { now: NOW }), /speed_kmh must be between 0 and 120/);
|
||||||
|
});
|
||||||
@@ -1013,6 +1013,8 @@
|
|||||||
|
|
||||||
## ETA
|
## ETA
|
||||||
|
|
||||||
|
Текущая ETA-логика в коде остается schedule-based: расписание + `time_offset_minutes` + `historical_delays`. Realtime-позиции, включая `source: "crowdsourced"`, публикуются через realtime API как дополнительный сигнал, но низкоуверенные passenger clusters не ухудшают schedule fallback. TODO для будущего realtime ETA: использовать official telemetry как основной источник, а crowdsourced virtual vehicles только как fallback/secondary signal с учетом `confidence`.
|
||||||
|
|
||||||
### [POST] /api/v1/eta/calculate
|
### [POST] /api/v1/eta/calculate
|
||||||
|
|
||||||
- Описание: считает ETA для одной пары `routeId + stopId`
|
- Описание: считает ETA для одной пары `routeId + stopId`
|
||||||
@@ -1320,7 +1322,8 @@
|
|||||||
|
|
||||||
### [POST] /api/v1/telemetry/ingest
|
### [POST] /api/v1/telemetry/ingest
|
||||||
|
|
||||||
- Описание: принимает GPS/телеметрию, при необходимости автоматически создает транспорт по `vehicleNumber`, обновляет `vehicle_live_state` и пушит событие в realtime-канал маршрута
|
- Описание: принимает официальную GPS/телеметрию транспорта, при необходимости автоматически создает транспорт по `vehicleNumber`, обновляет `vehicle_live_state` и пушит событие в realtime-канал маршрута
|
||||||
|
- Важно: этот endpoint предназначен только для водительского приложения, GPS-трекера или официальной транспортной телеметрии. Пассажирское приложение не должно отправлять сюда пользовательские геосигналы.
|
||||||
- Авторизация: `Authorization: Bearer <JWT>` или `X-API-Key: <key>`
|
- Авторизация: `Authorization: Bearer <JWT>` или `X-API-Key: <key>`
|
||||||
- Параметры path: нет
|
- Параметры path: нет
|
||||||
- Query-параметры: нет
|
- Query-параметры: нет
|
||||||
@@ -1369,9 +1372,57 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### [POST] /api/v1/passenger-telemetry/ingest
|
||||||
|
|
||||||
|
- Описание: принимает обезличенный пассажирский геосигнал только при явном opt-in пользователя. Сигналы сохраняются только во временной in-memory очереди с TTL около 10 минут и используются для агрегированных approximate realtime-позиций.
|
||||||
|
- Авторизация: `Authorization: Bearer <JWT>` или `X-API-Key: <key>`
|
||||||
|
- Важно по приватности:
|
||||||
|
- не принимает и не хранит `user id`, `account id`, телефон, device id или постоянные идентификаторы;
|
||||||
|
- `session_id` трактуется как временный анонимный идентификатор и в памяти хранится только в хешированном виде;
|
||||||
|
- сырые пассажирские точки не отдаются через публичный REST API и WebSocket;
|
||||||
|
- пассажирская точка никогда не показывается напрямую как транспорт;
|
||||||
|
- realtime может отдать только агрегированную виртуальную позицию `source: "crowdsourced"` / `is_estimated: true`, если достаточно уверенности.
|
||||||
|
- Rate limit: отдельный лимит поверх общего API rate limit по `session_id`, IP и API key.
|
||||||
|
- Request Body:
|
||||||
|
- `session_id: string` — временный анонимный id сессии
|
||||||
|
- `latitude: number`
|
||||||
|
- `longitude: number`
|
||||||
|
- `accuracy_m?: number | null`
|
||||||
|
- `speed_kmh?: number | null`
|
||||||
|
- `heading?: number | null`
|
||||||
|
- `observed_at?: string (ISO datetime)`
|
||||||
|
- `route_id?: number | string | null` — optional hint, сервер проверяет существование и матчинг к маршруту
|
||||||
|
- `stop_id?: number | string | null`
|
||||||
|
- `app_state?: 'foreground' | 'background'`
|
||||||
|
- Response 202:
|
||||||
|
- `{ accepted: true }`
|
||||||
|
- Ошибки:
|
||||||
|
- `400` — невалидные координаты, `accuracy_m`, `speed_kmh`, `heading`, `observed_at`, `route_id`, `stop_id`, `app_state`
|
||||||
|
- `404` — `Route not found`, если переданный `route_id` не существует
|
||||||
|
- `429` — превышен passenger telemetry rate limit
|
||||||
|
- `500` — `Failed to ingest passenger telemetry`
|
||||||
|
- Где найдено в коде: `backend/src/routes/api.js` -> `passengerTelemetryController.ingest`, `backend/src/controllers/passengerTelemetryController.js`, `backend/src/services/passengerTelemetryAggregator.js`
|
||||||
|
|
||||||
|
Пример запроса:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session_id": "temporary-anonymous-id",
|
||||||
|
"latitude": 55.485,
|
||||||
|
"longitude": 28.768,
|
||||||
|
"accuracy_m": 18.5,
|
||||||
|
"speed_kmh": 32.0,
|
||||||
|
"heading": 91.0,
|
||||||
|
"observed_at": "2026-05-20T12:30:00Z",
|
||||||
|
"route_id": "12",
|
||||||
|
"stop_id": "123",
|
||||||
|
"app_state": "foreground"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### [GET] /api/v1/realtime/routes/:routeId/vehicles
|
### [GET] /api/v1/realtime/routes/:routeId/vehicles
|
||||||
|
|
||||||
- Описание: отдает snapshot живых транспортных единиц по маршруту
|
- Описание: отдает snapshot живых транспортных единиц по маршруту. Ответ может включать official live vehicles и агрегированные approximate crowdsourced virtual vehicles, если official telemetry рядом отсутствует и passenger confidence достаточный.
|
||||||
- Авторизация: `Authorization: Bearer <JWT>` или `X-API-Key: <key>`
|
- Авторизация: `Authorization: Bearer <JWT>` или `X-API-Key: <key>`
|
||||||
- Параметры path: `routeId`
|
- Параметры path: `routeId`
|
||||||
- Query-параметры:
|
- Query-параметры:
|
||||||
@@ -1380,6 +1431,14 @@
|
|||||||
- Headers: `Authorization` или `X-API-Key`
|
- Headers: `Authorization` или `X-API-Key`
|
||||||
- Request Body: нет
|
- Request Body: нет
|
||||||
- Response 200/201: `LiveVehicleState[]`
|
- Response 200/201: `LiveVehicleState[]`
|
||||||
|
- Backward-compatible optional fields:
|
||||||
|
- `source?: 'official' | 'crowdsourced'`
|
||||||
|
- `confidence?: number`
|
||||||
|
- `confidence_label?: 'LOW' | 'MEDIUM' | 'HIGH'`
|
||||||
|
- `accuracy_m?: number`
|
||||||
|
- `is_estimated?: boolean`
|
||||||
|
- Для crowdsourced транспорта `vehicle_id` имеет synthetic формат `crowd:{routeId}:{clusterId}` и не содержит `session_id`.
|
||||||
|
- Сырые passenger positions в этом endpoint не публикуются.
|
||||||
- Ошибки:
|
- Ошибки:
|
||||||
- `400` — `routeId must be a positive integer`
|
- `400` — `routeId must be a positive integer`
|
||||||
- `500` — `Failed to fetch route live vehicles`
|
- `500` — `Failed to fetch route live vehicles`
|
||||||
@@ -1454,8 +1513,27 @@
|
|||||||
"latitude": 55.4869,
|
"latitude": 55.4869,
|
||||||
"longitude": 28.7856,
|
"longitude": 28.7856,
|
||||||
"speed_kmh": 24,
|
"speed_kmh": 24,
|
||||||
|
"source": "official",
|
||||||
|
"confidence": 1,
|
||||||
|
"accuracy_m": 5,
|
||||||
|
"is_estimated": false,
|
||||||
"is_online": true,
|
"is_online": true,
|
||||||
"seconds_since_update": 3
|
"seconds_since_update": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vehicle_id": "crowd:7:ab12cd34ef56",
|
||||||
|
"vehicle_number": null,
|
||||||
|
"route_id": 7,
|
||||||
|
"latitude": 55.4871,
|
||||||
|
"longitude": 28.786,
|
||||||
|
"speed_kmh": 22,
|
||||||
|
"source": "crowdsourced",
|
||||||
|
"confidence": 0.63,
|
||||||
|
"confidence_label": "MEDIUM",
|
||||||
|
"accuracy_m": 27,
|
||||||
|
"is_estimated": true,
|
||||||
|
"is_online": true,
|
||||||
|
"seconds_since_update": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ tags:
|
|||||||
- name: Alerts
|
- name: Alerts
|
||||||
- name: Users
|
- name: Users
|
||||||
- name: Telemetry
|
- name: Telemetry
|
||||||
|
- name: Passenger Telemetry
|
||||||
- name: Realtime
|
- name: Realtime
|
||||||
paths:
|
paths:
|
||||||
/health:
|
/health:
|
||||||
@@ -1694,7 +1695,8 @@ paths:
|
|||||||
/api/v1/telemetry/ingest:
|
/api/v1/telemetry/ingest:
|
||||||
post:
|
post:
|
||||||
tags: [Telemetry]
|
tags: [Telemetry]
|
||||||
summary: Ingest GPS telemetry
|
summary: Ingest official vehicle GPS telemetry
|
||||||
|
description: For driver apps, GPS trackers, and official telemetry only. Passenger apps must use /api/v1/passenger-telemetry/ingest instead.
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- ApiKeyAuth: []
|
- ApiKeyAuth: []
|
||||||
@@ -1733,10 +1735,58 @@ paths:
|
|||||||
$ref: '#/components/responses/TooManyRequests'
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalError'
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/api/v1/passenger-telemetry/ingest:
|
||||||
|
post:
|
||||||
|
tags: [Passenger Telemetry]
|
||||||
|
summary: Ingest anonymized passenger telemetry
|
||||||
|
description: |
|
||||||
|
Accepts opt-in, temporary, anonymous passenger geo-signals for short-lived aggregation.
|
||||||
|
Raw passenger points are not exposed through public REST APIs or WebSocket messages.
|
||||||
|
The server may emit only aggregate approximate virtual vehicles with source=crowdsourced.
|
||||||
|
security:
|
||||||
|
- BearerAuth: []
|
||||||
|
- ApiKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PassengerTelemetryIngestRequest'
|
||||||
|
example:
|
||||||
|
session_id: temporary-anonymous-id
|
||||||
|
latitude: 55.485
|
||||||
|
longitude: 28.768
|
||||||
|
accuracy_m: 18.5
|
||||||
|
speed_kmh: 32
|
||||||
|
heading: 91
|
||||||
|
observed_at: '2026-05-20T12:30:00Z'
|
||||||
|
route_id: '12'
|
||||||
|
stop_id: '123'
|
||||||
|
app_state: foreground
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Passenger signal accepted for aggregation
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PassengerTelemetryIngestResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'429':
|
||||||
|
$ref: '#/components/responses/TooManyRequests'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
/api/v1/realtime/routes/{routeId}/vehicles:
|
/api/v1/realtime/routes/{routeId}/vehicles:
|
||||||
get:
|
get:
|
||||||
tags: [Realtime]
|
tags: [Realtime]
|
||||||
summary: Live vehicles snapshot for route
|
summary: Live vehicles snapshot for route
|
||||||
|
description: Returns official live vehicles plus aggregate approximate crowdsourced virtual vehicles when confidence is sufficient. Raw passenger points are never returned.
|
||||||
security:
|
security:
|
||||||
- BearerAuth: []
|
- BearerAuth: []
|
||||||
- ApiKeyAuth: []
|
- ApiKeyAuth: []
|
||||||
@@ -2879,11 +2929,66 @@ components:
|
|||||||
sourceRef:
|
sourceRef:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
PassengerTelemetryIngestRequest:
|
||||||
|
type: object
|
||||||
|
required: [session_id, latitude, longitude]
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
session_id:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 128
|
||||||
|
description: Temporary anonymous session id; not a user, account, phone, or device id.
|
||||||
|
latitude:
|
||||||
|
type: number
|
||||||
|
minimum: -90
|
||||||
|
maximum: 90
|
||||||
|
longitude:
|
||||||
|
type: number
|
||||||
|
minimum: -180
|
||||||
|
maximum: 180
|
||||||
|
accuracy_m:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1000
|
||||||
|
nullable: true
|
||||||
|
speed_kmh:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 120
|
||||||
|
nullable: true
|
||||||
|
heading:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 360
|
||||||
|
nullable: true
|
||||||
|
observed_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
route_id:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
nullable: true
|
||||||
|
description: Optional client hint; server verifies it before aggregation.
|
||||||
|
stop_id:
|
||||||
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
nullable: true
|
||||||
|
app_state:
|
||||||
|
type: string
|
||||||
|
enum: [foreground, background]
|
||||||
|
nullable: true
|
||||||
LiveVehicleState:
|
LiveVehicleState:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
vehicle_id:
|
vehicle_id:
|
||||||
type: integer
|
oneOf:
|
||||||
|
- type: integer
|
||||||
|
- type: string
|
||||||
|
description: Official vehicles use numeric ids; crowdsourced virtual vehicles use crowd:{routeId}:{clusterId}.
|
||||||
vehicle_number:
|
vehicle_number:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
@@ -2917,12 +3022,31 @@ components:
|
|||||||
accuracy_meters:
|
accuracy_meters:
|
||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
|
accuracy_m:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
source_type:
|
source_type:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
source_ref:
|
source_ref:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
enum: [official, crowdsourced]
|
||||||
|
nullable: true
|
||||||
|
confidence:
|
||||||
|
type: number
|
||||||
|
minimum: 0
|
||||||
|
maximum: 1
|
||||||
|
nullable: true
|
||||||
|
confidence_label:
|
||||||
|
type: string
|
||||||
|
enum: [LOW, MEDIUM, HIGH]
|
||||||
|
nullable: true
|
||||||
|
is_estimated:
|
||||||
|
type: boolean
|
||||||
|
nullable: true
|
||||||
last_seen_at:
|
last_seen_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -2942,3 +3066,10 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
vehicle:
|
vehicle:
|
||||||
$ref: '#/components/schemas/LiveVehicleState'
|
$ref: '#/components/schemas/LiveVehicleState'
|
||||||
|
PassengerTelemetryIngestResponse:
|
||||||
|
type: object
|
||||||
|
required: [accepted]
|
||||||
|
properties:
|
||||||
|
accepted:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
|||||||
Reference in New Issue
Block a user