feat(gps): режим «потерян GPS» — LBS-точки помечаются (is_lbs), примерная позиция на карте, пунктир в треке; OSRM map matching трека (matched endpoint + переключатель «По дорогам»)

This commit is contained in:
2026-08-28 17:22:57 +03:00
parent 5d45084af1
commit 60bd52b75a
15 changed files with 374 additions and 42 deletions

View File

@@ -305,12 +305,27 @@ export function filterTrackPoints<T extends { lat: number; lng: number; recorded
* 3. jitter — мелкое перемещение с малой скоростью.
* Отброшенная точка не пишется в gps_positions, координаты и геозоны не
* трогаются, но last_seen_at/is_online обновляются (устройство живо).
*
* Режим «потерян GPS»: идущие stale re-send'ы — сигнал, что трекер не видит
* спутники. Пока последний stale моложе 10 минут, свежие точки помечаются
* is_lbs (координаты сотовой вышки): сохраняются и отдаются клиенту
* (пунктирное отображение), но геозоны по ним не проверяются. Первый
* настоящий GPS-фикс после окна stale выключает режим (gps_lost = false).
*/
// In-memory фильтры по объектам (процесс один; при рестарте состояние
// восстанавливается из last_* объекта в БД)
const assetWalkers = new Map<number, JumpFilterWalker>();
// Время (server time, ms) последнего stale-verdict'а по объекту.
// Идущие stale re-send'ы — сигнал потери GPS: свежие точки в течение
// STALE_LBS_WINDOW_MS после последнего stale считаются LBS (координаты
// сотовой вышки) — сохраняются и показываются, но не участвуют в геозонах.
const assetLastStaleAt = new Map<number, number>();
// Окно после последнего stale re-send'а, в котором свежие точки считаются LBS
const STALE_LBS_WINDOW_MS = 10 * 60 * 1000;
function getAssetWalker(assetId: number, prev: PrevPoint | null, settings: GpsFilterSettings): JumpFilterWalker {
let walker = assetWalkers.get(assetId);
if (!walker) {
@@ -332,6 +347,13 @@ export async function processPosition(
const asset = await gpsStorage.getAsset(assetId, organizationId);
if (!asset) return;
// Гидратация in-memory состояния после рестарта: если в БД объект помечен
// «потерян GPS», а lastStaleAt пуст — считаем, что stale был только что
// (осторожный режим до свежих событий)
if (asset.gpsLost && !assetLastStaleAt.has(assetId)) {
assetLastStaleAt.set(assetId, Date.now());
}
const settings = await gpsStorage.getSettings(organizationId);
// Предыдущая принятая точка объекта (если есть) — для инициализации walker'а
@@ -349,6 +371,13 @@ export async function processPosition(
// Stale re-send = трекер сам сообщает, когда в последний раз видел спутники
// (замороженный fixTime). Фиксируем как «время последнего GPS-фикса».
await gpsStorage.updateAssetGpsFix(assetId, recordedAt);
// Идущие stale re-send'ы — сигнал потери GPS: запоминаем время последнего
// stale и включаем режим «потерян GPS» у объекта
assetLastStaleAt.set(assetId, Date.now());
if (!asset.gpsLost) {
await gpsStorage.setGpsLost(assetId, true);
console.log(`[GPS] gps_lost ON: asset=${assetId} (stale re-send)`);
}
console.log(
`[GPS] skipped stale: asset=${assetId} recordedAt=${recordedAt.toISOString()} <= lastRecordedAt=${prev?.recordedAt.toISOString()}`
);
@@ -372,6 +401,13 @@ export async function processPosition(
// Точка прошла фильтры — GPS-фикс жив, двигаем время последнего фикса вперёд
await gpsStorage.updateAssetGpsFix(assetId, recordedAt, true);
// Режим «потерян GPS»: если последний stale re-send был свежее 10 минут,
// свежая точка — это координаты сотовой вышки (LBS), а не GPS-фикс.
// Такие точки сохраняем и показываем (пунктир на карте), но в логику
// геозон не пускаем. jump/echo/джиттер-отбросы режим не переключают.
const lastStaleAt = assetLastStaleAt.get(assetId);
const isLbs = lastStaleAt !== undefined && Date.now() - lastStaleAt < STALE_LBS_WINDOW_MS;
// Фильтр джиттера (округление мелких перемещений)
if (settings.jitterMeters > 0 && prev) {
const distance = haversineMeters(prev.lat, prev.lng, input.lat, input.lng);
@@ -391,6 +427,7 @@ export async function processPosition(
speed: input.speed ?? null,
course: input.course ?? null,
accuracy: input.accuracy ?? null,
isLbs,
recordedAt,
});
@@ -401,8 +438,15 @@ export async function processPosition(
speed: input.speed,
course: input.course,
recordedAt,
isLbs,
});
// Настоящий GPS-фикс после режима «потерян GPS» — выключаем режим
if (!isLbs && asset.gpsLost) {
await gpsStorage.setGpsLost(assetId, false);
console.log(`[GPS] gps_lost OFF: asset=${assetId} (свежий GPS-фикс)`);
}
// 3. SSE-событие о перемещении — broadcast по организации,
// чтобы карта у всех открытых клиентов обновилась в реальном времени
eventBus.publishEvent({
@@ -414,12 +458,16 @@ export async function processPosition(
speed: input.speed ?? null,
course: input.course ?? null,
recordedAt: recordedAt.toISOString(),
isLbs,
},
organizationId,
});
// 4. Проверка геозон
await checkGeozones(asset, input, recordedAt);
// 4. Проверка геозон — только для настоящих GPS-точек;
// LBS-координаты вышек в логику геозон не пускаем
if (!isLbs) {
await checkGeozones(asset, input, recordedAt);
}
}
async function checkGeozones(

View File

@@ -15,6 +15,124 @@ import { storage } from "../storage";
const MAX_TRACK_POINTS = 2000;
// =====================
// OSRM map matching (привязка трека к дорогам)
// =====================
// URL OSRM-сервиса (контейнер во внутренней Docker-сети)
const OSRM_URL = process.env.OSRM_URL || "http://osrm:5000";
// Лимит OSRM Match API — 100 координат на запрос; берём с запасом
const OSRM_CHUNK_SIZE = 95;
// Таймаут запроса к OSRM
const OSRM_TIMEOUT_MS = 5000;
// TTL in-memory кэша привязанных треков (точки меняются по мере поступления новых)
const MATCHED_CACHE_TTL_MS = 10 * 60 * 1000;
interface MatchedTrack {
/** Геометрия привязанного трека в [lat, lng] (для единообразия с остальными точками) */
points: Array<[number, number]>;
/** Суммарная длина по дорогам, метры */
distance: number;
/** Суммарная длительность по дорогам, секунды */
duration: number;
}
// In-memory кэш: ключ `${assetId}:${from}:${to}` → результат (или null при недоступности OSRM)
const matchedTrackCache = new Map<string, { data: MatchedTrack | null; expiresAt: number }>();
// Warn про недоступность OSRM пишем один раз до следующего успешного ответа
let osrmUnavailableWarned = false;
interface OsrmMatchResponse {
code?: string;
matchings?: Array<{
geometry?: { coordinates?: Array<[number, number]> };
distance?: number;
duration?: number;
}>;
}
/**
* Привязка трека к дорогам через OSRM Match API.
* Возвращает null, если OSRM недоступен (endpoint при этом не падает).
* LBS-точки сюда не передаём — привязываем только точные GPS-данные.
*/
async function matchTrack(points: Array<{ lat: number; lng: number }>): Promise<MatchedTrack | null> {
const allCoords: Array<[number, number]> = [];
let distance = 0;
let duration = 0;
for (let i = 0; i < points.length; i += OSRM_CHUNK_SIZE) {
const chunk = points.slice(i, i + OSRM_CHUNK_SIZE);
if (chunk.length < 2) continue;
// ВНИМАНИЕ: OSRM ждёт координаты в порядке lng,lat
const coordsParam = chunk.map((p) => `${p.lng},${p.lat}`).join(";");
const url = `${OSRM_URL}/match/v1/driving/${coordsParam}?overview=full&geometries=geojson&gaps=split&tidy=true`;
let data: OsrmMatchResponse;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(OSRM_TIMEOUT_MS) });
data = (await res.json()) as OsrmMatchResponse;
if (osrmUnavailableWarned) {
console.log("[GPS] OSRM снова доступен");
osrmUnavailableWarned = false;
}
} catch (err) {
// OSRM недоступен (ECONNREFUSED/таймаут) — трек без привязки, endpoint не падает
if (!osrmUnavailableWarned) {
console.warn(`[GPS] OSRM недоступен (${OSRM_URL}):`, err instanceof Error ? err.message : err);
osrmUnavailableWarned = true;
}
return null;
}
// Чанк не смэтчился (code != 'Ok') — пропускаем его и продолжаем
if (data.code !== "Ok" || !Array.isArray(data.matchings)) continue;
for (const matching of data.matchings) {
const coords = matching.geometry?.coordinates;
if (Array.isArray(coords)) {
for (const [lng, lat] of coords) {
// Дедупликация стыковочной точки между чанками/matching'ами
const prev = allCoords[allCoords.length - 1];
if (prev && prev[0] === lat && prev[1] === lng) continue;
// Храним в [lat, lng] для единообразия с точками трека
allCoords.push([lat, lng]);
}
}
distance += matching.distance ?? 0;
duration += matching.duration ?? 0;
}
}
if (allCoords.length < 2) return null;
return { points: allCoords, distance: Math.round(distance), duration: Math.round(duration) };
}
/** Привязка трека с in-memory кэшем (TTL 10 минут) */
async function matchTrackCached(
assetId: number,
from: Date | null,
to: Date | null,
points: Array<{ lat: number; lng: number }>
): Promise<MatchedTrack | null> {
const key = `${assetId}:${from?.toISOString() ?? ""}:${to?.toISOString() ?? ""}`;
const cached = matchedTrackCache.get(key);
if (cached && cached.expiresAt > Date.now()) return cached.data;
const data = await matchTrack(points);
matchedTrackCache.set(key, { data, expiresAt: Date.now() + MATCHED_CACHE_TTL_MS });
// Простая уборка протухших записей, чтобы Map не рос бесконечно
if (matchedTrackCache.size > 100) {
const now = Date.now();
for (const [k, v] of matchedTrackCache) {
if (v.expiresAt <= now) matchedTrackCache.delete(k);
}
}
return data;
}
function parseDateParam(value: unknown): Date | null {
if (typeof value !== "string" || !value) return null;
const d = new Date(value);
@@ -473,6 +591,8 @@ export function registerGpsRoutes(app: Express) {
lastSeenAt: a.lastSeenAt,
lastRecordedAt: a.lastRecordedAt, // время последней точки по устройству
lastGpsFixAt: a.lastGpsFixAt, // время последнего GPS-фикса (для индикатора потери спутников)
gpsLost: a.gpsLost, // режим «потерян GPS»: идут stale re-send'ы, свежие точки — LBS
lastIsLbs: a.lastIsLbs, // последняя точка — координаты вышки (примерная позиция)
})),
});
});
@@ -509,7 +629,23 @@ export function registerGpsRoutes(app: Express) {
sampled = true;
}
res.json({ success: true, points, total, filteredCount, sampled });
// Привязка трека к дорогам через OSRM (?matched=1): только точные
// GPS-точки (isLbs исключаем). При недоступности OSRM — matched: null,
// endpoint не падает.
let matched: MatchedTrack | null | undefined;
if (req.query.matched === "1") {
const gpsOnly = points.filter((p) => !p.isLbs);
matched = gpsOnly.length >= 2 ? await matchTrackCached(id, from, to, gpsOnly) : null;
}
res.json({
success: true,
points,
total,
filteredCount,
sampled,
...(matched !== undefined ? { matched } : {}),
});
});
// =====================

View File

@@ -146,10 +146,11 @@ export class GpsStorage {
* Обновление последней позиции объекта (и перевод в online).
* last_seen_at = время ПРИЁМА сервером (new Date() здесь),
* last_recorded_at = время точки по устройству (recordedAt).
* isLbs = последняя точка — LBS (координаты вышки), см. режим «потерян GPS».
*/
async updateAssetLastPosition(
assetId: number,
data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date }
data: { lat: number; lng: number; speed?: number | null; course?: number | null; recordedAt: Date; isLbs?: boolean }
): Promise<void> {
await db
.update(gpsAssets)
@@ -160,11 +161,24 @@ export class GpsStorage {
lastCourse: data.course ?? null,
lastSeenAt: new Date(),
lastRecordedAt: data.recordedAt,
lastIsLbs: data.isLbs ?? false,
isOnline: true,
})
.where(eq(gpsAssets.id, assetId));
}
/**
* Переключение режима «потерян GPS» у объекта.
* true — трекер присылает stale re-send'ы (не видит спутники), свежие точки
* помечаются как LBS; false — нормальный режим.
*/
async setGpsLost(assetId: number, lost: boolean): Promise<void> {
await db
.update(gpsAssets)
.set({ gpsLost: lost })
.where(eq(gpsAssets.id, assetId));
}
/**
* Отметка «устройство живо» без обновления координат
* (точка отброшена фильтром джиттера, но приём был).