add passenger telemetry

This commit is contained in:
2026-05-21 00:09:11 +03:00
parent 66c84ecdeb
commit e57fea321f
15 changed files with 1411 additions and 6 deletions

View File

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

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

View File

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

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

View File

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

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