feat(gps): фильтр «далеко от дороги» в LBS-режиме (offRoadMeters, OSRM nearest); пробег «точно + примерно (по вышкам)» в Маршрутах

This commit is contained in:
2026-08-29 21:12:09 +03:00
parent 9eaff65500
commit a302f0c0ea
8 changed files with 135 additions and 12 deletions

View File

@@ -1,4 +1,5 @@
import { gpsStorage } from "./storage";
import { OSRM_URL } from "./osrm";
import { storage } from "../storage";
import { webPushService } from "../services/web-push.service";
import { eventBus, publishNotificationSSE } from "../routes/shared";
@@ -309,8 +310,10 @@ export function filterTrackPoints<T extends { lat: number; lng: number; recorded
* Режим «потерян GPS»: идущие stale re-send'ы — сигнал, что трекер не видит
* спутники. Пока последний stale моложе 10 минут, свежие точки помечаются
* is_lbs (координаты сотовой вышки): сохраняются и отдаются клиенту
* (пунктирное отображение), но геозоны по ним не проверяются. Первый
* настоящий GPS-фикс после окна stale выключает режим (gps_lost = false).
* (пунктирное отображение), но геозоны по ним не проверяются. Дополнительно
* LBS-точки дальше off_road_meters от ближайшей дороги (OSRM nearest)
* отбрасываются совсем. Первый настоящий GPS-фикс после окна stale
* выключает режим (gps_lost = false).
*/
// In-memory фильтры по объектам (процесс один; при рестарте состояние
@@ -326,6 +329,49 @@ const assetLastStaleAt = new Map<number, number>();
// Окно после последнего stale re-send'а, в котором свежие точки считаются LBS
const STALE_LBS_WINDOW_MS = 10 * 60 * 1000;
// Таймаут запроса к OSRM Nearest API (фильтр «далеко от дороги»)
const OSRM_NEAREST_TIMEOUT_MS = 2000;
// Warn про недоступность OSRM для nearest пишем один раз до следующего успешного ответа
let osrmNearestWarned = false;
/**
* Проверка «далеко от дороги» через OSRM Nearest API (только для LBS-режима).
* Возвращает расстояние до ближайшей дороги (м), если оно БОЛЬШЕ порога
* (точку надо отбросить), иначе null.
* При недоступности OSRM/таймауте — null: приём точки не блокируем.
* code=NoSegment — ближайшая дорога вне радиуса поиска OSRM (порядка км),
* надёжный сигнал LBS: возвращаем Infinity (точно offroad).
*/
async function offRoadDistance(lat: number, lng: number, thresholdMeters: number): Promise<number | null> {
let data: { code?: string; waypoints?: Array<{ distance?: number }> };
try {
// ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
const res = await fetch(`${OSRM_URL}/nearest/v1/driving/${lng},${lat}`, {
signal: AbortSignal.timeout(OSRM_NEAREST_TIMEOUT_MS),
});
data = (await res.json()) as typeof data;
if (osrmNearestWarned) {
console.log("[GPS] OSRM снова доступен (nearest)");
osrmNearestWarned = false;
}
} catch (err) {
// OSRM недоступен — пропускаем проверку, точка идёт обычным путём
if (!osrmNearestWarned) {
console.warn(`[GPS] OSRM недоступен для nearest (${OSRM_URL}):`, err instanceof Error ? err.message : err);
osrmNearestWarned = true;
}
return null;
}
// Дорога вне радиуса поиска — точно далеко от дороги
if (data.code === "NoSegment") return Infinity;
if (data.code !== "Ok") return null;
const dist = data.waypoints?.[0]?.distance;
if (typeof dist !== "number") return null;
return dist > thresholdMeters ? dist : null;
}
function getAssetWalker(assetId: number, prev: PrevPoint | null, settings: GpsFilterSettings): JumpFilterWalker {
let walker = assetWalkers.get(assetId);
if (!walker) {
@@ -408,6 +454,22 @@ export async function processPosition(
const lastStaleAt = assetLastStaleAt.get(assetId);
const isLbs = lastStaleAt !== undefined && Date.now() - lastStaleAt < STALE_LBS_WINDOW_MS;
// Фильтр «далеко от дороги» — ТОЛЬКО в режиме потери GPS (LBS-точки):
// координаты сотовых вышек обычно >100 м от ближайшей дороги, настоящий
// GPS — почти никогда (p90 ≈ 7 м). Вне LBS-режима проверку не выполняем:
// при живом GPS заезды в поля/дворы легальны.
if (isLbs && settings.offRoadMeters > 0) {
const distToRoad = await offRoadDistance(input.lat, input.lng, settings.offRoadMeters);
if (distToRoad !== null) {
console.log(
`[GPS] skipped offroad: asset=${assetId} dist_to_road=${distToRoad === Infinity ? "> радиуса поиска OSRM" : `${Math.round(distToRoad)}m`} > порога ${settings.offRoadMeters}m`
);
// Точку не сохраняем, координаты и геозоны не трогаем — только «устройство живо»
await gpsStorage.touchAssetSeen(assetId);
return;
}
}
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);

5
server/gps/osrm.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* Общая конфигурация OSRM-сервиса (map matching / nearest).
* Контейнер во внутренней Docker-сети; адрес переопределяется env OSRM_URL.
*/
export const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";

View File

@@ -5,6 +5,7 @@ import { gpsStorage } from "./storage";
import { processPosition, filterTrackPoints } from "./geozone.service";
import { hasGpsAccess } from "./access";
import { ensureDevice, deleteDevice } from "./traccar.client";
import { OSRM_URL } from "./osrm";
import { storage } from "../storage";
/**
@@ -19,8 +20,7 @@ const MAX_TRACK_POINTS = 2000;
// OSRM map matching (привязка трека к дорогам)
// =====================
// URL OSRM-сервиса (контейнер во внутренней Docker-сети)
const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
// URL OSRM-сервиса — см. ./osrm (общий модуль, используется и в geozone.service)
// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
const OSRM_CHUNK_SIZE = 95;
// Таймаут запроса к OSRM
@@ -432,6 +432,11 @@ export function registerGpsRoutes(app: Express) {
if (!Number.isInteger(gpsLostHours) || gpsLostHours < 1 || gpsLostHours > 72) {
return badRequest(res, "gpsLostHours: целое число от 1 до 72");
}
const offRoadMeters =
req.body?.offRoadMeters !== undefined ? Number(req.body.offRoadMeters) : current.offRoadMeters;
if (!Number.isInteger(offRoadMeters) || offRoadMeters < 0 || offRoadMeters > 2000) {
return badRequest(res, "offRoadMeters: целое число от 0 до 2000 (0 = фильтр выключен)");
}
// Центр карты по умолчанию — только для admin (у остальных молча игнорируется)
let defaultLat = current.defaultLat;
@@ -471,6 +476,7 @@ export function registerGpsRoutes(app: Express) {
maxJumpSpeedKmh,
tripStopMinutes,
gpsLostHours,
offRoadMeters,
defaultLat,
defaultLng,
defaultZoom,
@@ -481,6 +487,7 @@ export function registerGpsRoutes(app: Express) {
maxJumpSpeedKmh: row.maxJumpSpeedKmh,
tripStopMinutes: row.tripStopMinutes,
gpsLostHours: row.gpsLostHours,
offRoadMeters: row.offRoadMeters,
defaultLat: row.defaultLat,
defaultLng: row.defaultLng,
defaultZoom: row.defaultZoom,

View File

@@ -504,6 +504,7 @@ export class GpsStorage {
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
offRoadMeters: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;
@@ -517,6 +518,7 @@ export class GpsStorage {
maxJumpSpeedKmh: row?.maxJumpSpeedKmh ?? 250,
tripStopMinutes: row?.tripStopMinutes ?? 10,
gpsLostHours: row?.gpsLostHours ?? 5,
offRoadMeters: row?.offRoadMeters ?? 200,
defaultLat: row?.defaultLat ?? null,
defaultLng: row?.defaultLng ?? null,
defaultZoom: row?.defaultZoom ?? null,
@@ -530,6 +532,7 @@ export class GpsStorage {
maxJumpSpeedKmh: number;
tripStopMinutes: number;
gpsLostHours: number;
offRoadMeters: number;
defaultLat: number | null;
defaultLng: number | null;
defaultZoom: number | null;