Add NMEA journal import with wizard and CRC-based duplicate detection.
Enables importing .nmea logs into travel-day events with interval/change modes, optional GPS track, local encrypted archive, and a test fixture for the Kieler Förde route. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseNmeaFile } from './nmeaParse.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { generateNmeaJournalCandidates } from './nmeaJournalGenerator.js'
|
||||
|
||||
const nmeaPath = resolve(import.meta.dirname, '../../../../testdata/tracks/kieler-foerde-5sm.nmea')
|
||||
|
||||
describe('kieler-foerde testdata', () => {
|
||||
it('parses the sample NMEA log and yields journal candidates', () => {
|
||||
const text = readFileSync(nmeaPath, 'utf8')
|
||||
const result = parseNmeaFile(text, 'kieler-foerde-5sm.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(0)
|
||||
expect(result.points.length).toBeGreaterThan(30)
|
||||
expect(result.stats.sentenceTypes).toEqual(expect.arrayContaining(['RMC', 'GGA', 'MWV', 'DPT', 'MDA']))
|
||||
|
||||
const changes = detectNmeaChanges(result.points)
|
||||
expect(changes.length).toBeGreaterThan(0)
|
||||
expect(changes.some((c) => ['wind', 'engine_start', 'departure', 'speed', 'depth'].includes(c.type))).toBe(true)
|
||||
|
||||
const journal = generateNmeaJournalCandidates({
|
||||
points: result.points,
|
||||
mode: 'both',
|
||||
intervalMinutes: 60,
|
||||
t: (key) => key
|
||||
})
|
||||
expect(journal.candidates.length).toBeGreaterThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
|
||||
function point(
|
||||
timestamp: number,
|
||||
overrides: Partial<NmeaTimePoint> = {}
|
||||
): NmeaTimePoint {
|
||||
return { timestamp, ...overrides }
|
||||
}
|
||||
|
||||
describe('detectNmeaChanges', () => {
|
||||
it('detects significant course changes while underway', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(60_000, { cog: 45, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 60_000
|
||||
})
|
||||
|
||||
expect(events.some((e) => e.type === 'course')).toBe(true)
|
||||
const course = events.find((e) => e.type === 'course')
|
||||
expect(course?.summaryParams).toMatchObject({ from: 0, to: 45 })
|
||||
})
|
||||
|
||||
it('detects engine start when RPM rises above threshold', () => {
|
||||
const points = [
|
||||
point(0, { sog: 0, rpm: 0 }),
|
||||
point(30_000, { sog: 3, rpm: 1200 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points)
|
||||
expect(events.some((e) => e.type === 'engine_start')).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes repeated events within the configured window', () => {
|
||||
const points = [
|
||||
point(0, { cog: 0, sog: 5 }),
|
||||
point(10_000, { cog: 50, sog: 5 }),
|
||||
point(20_000, { cog: 100, sog: 5 })
|
||||
]
|
||||
|
||||
const events = detectNmeaChanges(points, {
|
||||
courseDeltaDeg: 30,
|
||||
windDirDeltaDeg: 30,
|
||||
windSpeedDeltaKnots: 5,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 1,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 2,
|
||||
dedupeWindowMs: 120_000
|
||||
})
|
||||
|
||||
const courseEvents = events.filter((e) => e.type === 'course')
|
||||
expect(courseEvents.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { NmeaChangeEvent, NmeaDetectionConfig, NmeaTimePoint } from './nmeaTypes.js'
|
||||
import { DEFAULT_NMEA_DETECTION_CONFIG } from './nmeaTypes.js'
|
||||
import { angularDelta } from './nmeaTimeSeries.js'
|
||||
|
||||
function pushUnique(events: NmeaChangeEvent[], event: NmeaChangeEvent, minGapMs: number) {
|
||||
const last = events[events.length - 1]
|
||||
if (last && last.type === event.type && event.timestamp - last.timestamp < minGapMs) return
|
||||
events.push(event)
|
||||
}
|
||||
|
||||
export function detectNmeaChanges(
|
||||
points: NmeaTimePoint[],
|
||||
config: NmeaDetectionConfig = DEFAULT_NMEA_DETECTION_CONFIG
|
||||
): NmeaChangeEvent[] {
|
||||
const events: NmeaChangeEvent[] = []
|
||||
if (points.length < 2) return events
|
||||
|
||||
let lastCourse: number | undefined
|
||||
let lastWindDir: number | undefined
|
||||
let lastWindSpeed: number | undefined
|
||||
let lastPressure: number | undefined
|
||||
let lastDepth: number | undefined
|
||||
let lastWaterTemp: number | undefined
|
||||
let lastFix: boolean | undefined
|
||||
let engineRunning = false
|
||||
let autopilot: boolean | undefined
|
||||
let underWay = false
|
||||
let stoppedSince: number | null = null
|
||||
let lastSog: number | undefined
|
||||
|
||||
for (const p of points) {
|
||||
const course = p.cog ?? p.hdt ?? p.hdm
|
||||
if (course != null && lastCourse != null && (p.sog ?? 0) > 1) {
|
||||
if (angularDelta(course, lastCourse) >= config.courseDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'course',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_course',
|
||||
summaryParams: { from: Math.round(lastCourse), to: Math.round(course) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (course != null) lastCourse = course
|
||||
|
||||
if (p.windDir != null && lastWindDir != null) {
|
||||
if (angularDelta(p.windDir, lastWindDir) >= config.windDirDeltaDeg) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_wind',
|
||||
summaryParams: { from: Math.round(lastWindDir), to: Math.round(p.windDir) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
} else if (
|
||||
p.windSpeedKnots != null &&
|
||||
lastWindSpeed != null &&
|
||||
Math.abs(p.windSpeedKnots - lastWindSpeed) >= config.windSpeedDeltaKnots
|
||||
) {
|
||||
pushUnique(events, {
|
||||
type: 'wind',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_wind_speed',
|
||||
summaryParams: { from: lastWindSpeed.toFixed(1), to: p.windSpeedKnots.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.windDir != null) lastWindDir = p.windDir
|
||||
if (p.windSpeedKnots != null) lastWindSpeed = p.windSpeedKnots
|
||||
|
||||
if (p.pressureHpa != null && lastPressure != null) {
|
||||
if (Math.abs(p.pressureHpa - lastPressure) >= config.pressureDeltaHpa) {
|
||||
pushUnique(events, {
|
||||
type: 'pressure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_pressure',
|
||||
summaryParams: { from: lastPressure.toFixed(1), to: p.pressureHpa.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.pressureHpa != null) lastPressure = p.pressureHpa
|
||||
|
||||
if (p.depthM != null && lastDepth != null) {
|
||||
const delta = Math.abs(p.depthM - lastDepth)
|
||||
const rel = lastDepth > 0 ? (delta / lastDepth) * 100 : 100
|
||||
if (delta >= config.depthDeltaM || rel >= config.depthDeltaPercent) {
|
||||
pushUnique(events, {
|
||||
type: 'depth',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_depth',
|
||||
summaryParams: { from: lastDepth.toFixed(1), to: p.depthM.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.depthM != null) lastDepth = p.depthM
|
||||
|
||||
if (p.rpm != null) {
|
||||
const running = p.rpm >= config.rpmRunning
|
||||
const idle = p.rpm <= config.rpmIdle
|
||||
if (running && !engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_start',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_start',
|
||||
summaryParams: { rpm: Math.round(p.rpm) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = true
|
||||
} else if (idle && engineRunning) {
|
||||
pushUnique(events, {
|
||||
type: 'engine_stop',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: 'logs.nmea_change_engine_stop',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
engineRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
if (p.autopilotEngaged != null && autopilot != null && p.autopilotEngaged !== autopilot) {
|
||||
pushUnique(events, {
|
||||
type: p.autopilotEngaged ? 'autopilot_on' : 'autopilot_off',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.autopilotEngaged ? 'logs.nmea_change_autopilot_on' : 'logs.nmea_change_autopilot_off',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.autopilotEngaged != null) autopilot = p.autopilotEngaged
|
||||
|
||||
if (p.fixValid != null && lastFix != null && p.fixValid !== lastFix) {
|
||||
pushUnique(events, {
|
||||
type: p.fixValid ? 'gps_fix_regained' : 'gps_fix_lost',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'high',
|
||||
summaryKey: p.fixValid ? 'logs.nmea_change_gps_regained' : 'logs.nmea_change_gps_lost',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
if (p.fixValid != null) lastFix = p.fixValid
|
||||
|
||||
if (p.waterTempC != null && lastWaterTemp != null) {
|
||||
if (Math.abs(p.waterTempC - lastWaterTemp) >= 2) {
|
||||
pushUnique(events, {
|
||||
type: 'water_temp',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_water_temp',
|
||||
summaryParams: { from: lastWaterTemp.toFixed(1), to: p.waterTempC.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
}
|
||||
if (p.waterTempC != null) lastWaterTemp = p.waterTempC
|
||||
|
||||
const sog = p.sog ?? 0
|
||||
if (sog >= config.sogUnderWayKn && !underWay) {
|
||||
if (stoppedSince != null && p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'departure',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_departure',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
underWay = true
|
||||
stoppedSince = null
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && underWay) {
|
||||
underWay = false
|
||||
stoppedSince = p.timestamp
|
||||
}
|
||||
if (sog <= config.sogStoppedKn && stoppedSince != null && !underWay) {
|
||||
if (p.timestamp - stoppedSince >= config.anchorMinutes * 60 * 1000) {
|
||||
pushUnique(events, {
|
||||
type: 'anchor',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'medium',
|
||||
summaryKey: 'logs.nmea_change_anchor',
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
stoppedSince = null
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSog != null && Math.abs(sog - lastSog) >= config.speedDeltaKn) {
|
||||
pushUnique(events, {
|
||||
type: 'speed',
|
||||
timestamp: p.timestamp,
|
||||
confidence: 'low',
|
||||
summaryKey: 'logs.nmea_change_speed',
|
||||
summaryParams: { from: lastSog.toFixed(1), to: sog.toFixed(1) },
|
||||
data: p
|
||||
}, config.dedupeWindowMs)
|
||||
}
|
||||
lastSog = sog
|
||||
}
|
||||
|
||||
return events.sort((a, b) => a.timestamp - b.timestamp)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { LogEventPayload } from '../../utils/logEntryPayload.js'
|
||||
import { normalizeLogEvent } from '../../utils/logEntryPayload.js'
|
||||
import { formatCourseAngle } from '../../utils/courseAngle.js'
|
||||
import { degreesToCardinal } from '../../utils/courseAngle.js'
|
||||
import type {
|
||||
NmeaChangeEvent,
|
||||
NmeaImportMode,
|
||||
NmeaJournalCandidate,
|
||||
NmeaTimePoint
|
||||
} from './nmeaTypes.js'
|
||||
import { detectNmeaChanges } from './nmeaChangeDetection.js'
|
||||
import { intervalTimestamps, sampleAt, timestampToHHMM } from './nmeaTimeSeries.js'
|
||||
|
||||
export interface GeneratedNmeaJournal {
|
||||
candidates: Array<NmeaJournalCandidate & { event: LogEventPayload }>
|
||||
}
|
||||
|
||||
function pointToLogEvent(
|
||||
point: NmeaTimePoint,
|
||||
remarks: string,
|
||||
sailsOrMotor: string
|
||||
): LogEventPayload {
|
||||
const course = point.cog ?? point.hdt ?? point.hdm
|
||||
const mgk = course != null ? formatCourseAngle(course) : ''
|
||||
const windDir =
|
||||
point.windDir != null ? degreesToCardinal(point.windDir) : ''
|
||||
|
||||
return normalizeLogEvent({
|
||||
time: timestampToHHMM(point.timestamp),
|
||||
mgk,
|
||||
rwk: '',
|
||||
windDirection: windDir,
|
||||
windStrength: point.windSpeedKnots != null ? String(point.windSpeedKnots) : '',
|
||||
windPressure: point.pressureHpa != null ? String(Math.round(point.pressureHpa)) : '',
|
||||
gpsLat: point.lat != null ? point.lat.toFixed(6) : '',
|
||||
gpsLng: point.lng != null ? point.lng.toFixed(6) : '',
|
||||
logReading: point.logDistanceNm != null ? point.logDistanceNm.toFixed(2) : '',
|
||||
sailsOrMotor,
|
||||
remarks
|
||||
})
|
||||
}
|
||||
|
||||
function changeToSailsOrMotor(type: NmeaChangeEvent['type']): string {
|
||||
if (type === 'engine_start') return 'Motor'
|
||||
if (type === 'engine_stop') return 'Segel'
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildRemarks(change: NmeaChangeEvent, t: TFunction): string {
|
||||
const parts: string[] = []
|
||||
parts.push(t(change.summaryKey, change.summaryParams ?? {}))
|
||||
if (change.data?.depthM != null) {
|
||||
parts.push(t('logs.nmea_remark_depth', { depth: change.data.depthM.toFixed(1) }))
|
||||
}
|
||||
if (change.confidence === 'low') {
|
||||
parts.push(t('logs.nmea_remark_uncertain'))
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function dedupeCandidates(
|
||||
items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }>,
|
||||
windowMs: number
|
||||
): Array<NmeaJournalCandidate & { event: LogEventPayload }> {
|
||||
const sorted = [...items].sort((a, b) => a.timestamp - b.timestamp)
|
||||
const kept: typeof sorted = []
|
||||
|
||||
for (const item of sorted) {
|
||||
const near = kept.find((k) => Math.abs(k.timestamp - item.timestamp) <= windowMs)
|
||||
if (!near) {
|
||||
kept.push(item)
|
||||
continue
|
||||
}
|
||||
if (item.source === 'change' && near.source === 'interval') {
|
||||
const idx = kept.indexOf(near)
|
||||
kept[idx] = {
|
||||
...item,
|
||||
event: {
|
||||
...near.event,
|
||||
remarks: [item.event.remarks, near.event.remarks].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
export function generateNmeaJournalCandidates(options: {
|
||||
points: NmeaTimePoint[]
|
||||
mode: NmeaImportMode
|
||||
intervalMinutes: number
|
||||
t: TFunction
|
||||
}): GeneratedNmeaJournal {
|
||||
const { points, mode, intervalMinutes, t } = options
|
||||
const items: Array<NmeaJournalCandidate & { event: LogEventPayload; timestamp: number }> = []
|
||||
|
||||
if (mode === 'interval' || mode === 'both') {
|
||||
for (const ts of intervalTimestamps(points, intervalMinutes)) {
|
||||
const sample = sampleAt(points, ts)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `interval-${ts}`,
|
||||
timestamp: ts,
|
||||
source: 'interval',
|
||||
selected: true,
|
||||
event: pointToLogEvent(sample, t('logs.nmea_remark_interval'), '')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'change' || mode === 'both') {
|
||||
const changes = detectNmeaChanges(points)
|
||||
for (const change of changes) {
|
||||
const sample = change.data ?? sampleAt(points, change.timestamp)
|
||||
if (!sample) continue
|
||||
items.push({
|
||||
id: `change-${change.type}-${change.timestamp}`,
|
||||
timestamp: change.timestamp,
|
||||
source: 'change',
|
||||
changeType: change.type,
|
||||
confidence: change.confidence,
|
||||
selected: true,
|
||||
event: pointToLogEvent(
|
||||
{ ...sample, timestamp: change.timestamp },
|
||||
buildRemarks(change, t),
|
||||
changeToSailsOrMotor(change.type)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deduped = mode === 'both'
|
||||
? dedupeCandidates(items, 15 * 60 * 1000)
|
||||
: items
|
||||
|
||||
return { candidates: deduped }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nmeaPointsToWaypoints, parseNmeaFile } from './nmeaParse.js'
|
||||
|
||||
describe('parseNmeaFile', () => {
|
||||
it('parses RMC position, course and speed', () => {
|
||||
const text = [
|
||||
'$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W',
|
||||
'$GPRMC,133519,A,4808.038,N,01132.000,E,025.0,090.0,230394,003.1,W'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'test.nmea')
|
||||
|
||||
expect(result.stats.parsedLines).toBe(2)
|
||||
expect(result.stats.sentenceTypes).toContain('RMC')
|
||||
expect(result.points.length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
const first = result.points[0]
|
||||
expect(first.lat).toBeCloseTo(48.1173, 3)
|
||||
expect(first.lng).toBeCloseTo(11.516667, 3)
|
||||
expect(first.sog).toBe(22.4)
|
||||
expect(first.cog).toBe(84.4)
|
||||
expect(first.fixValid).toBe(true)
|
||||
})
|
||||
|
||||
it('merges wind and depth sentences onto the same timestamp', () => {
|
||||
const text = [
|
||||
'$GPRMC,100000,A,5400.000,N,01000.000,E,5.0,180.0,010124,003.0,E',
|
||||
'$IIMWV,270.0,R,12.5,N,A',
|
||||
'$SDDPT,4.5,0.0'
|
||||
].join('\n')
|
||||
|
||||
const result = parseNmeaFile(text, 'merged.nmea')
|
||||
const last = result.points[result.points.length - 1]
|
||||
|
||||
expect(last.windDir).toBe(270)
|
||||
expect(last.windSpeedKnots).toBe(12.5)
|
||||
expect(last.depthM).toBe(4.5)
|
||||
})
|
||||
|
||||
it('skips lines with invalid checksum', () => {
|
||||
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*FF'
|
||||
const result = parseNmeaFile(text, 'bad.nmea')
|
||||
|
||||
expect(result.stats.checksumErrors).toBe(1)
|
||||
expect(result.points).toHaveLength(0)
|
||||
expect(result.warnings).toContain('no_samples')
|
||||
})
|
||||
|
||||
it('warns when no position sentences are present', () => {
|
||||
const text = '$IIMWV,090.0,R,8.0,N,A'
|
||||
const result = parseNmeaFile(text, 'wind-only.nmea')
|
||||
|
||||
expect(result.warnings).toContain('no_position')
|
||||
})
|
||||
})
|
||||
|
||||
describe('nmeaPointsToWaypoints', () => {
|
||||
it('maps points with coordinates to track waypoints', () => {
|
||||
const waypoints = nmeaPointsToWaypoints([
|
||||
{ timestamp: 1, lat: 54.0, lng: 10.0, sog: 6, cog: 90 },
|
||||
{ timestamp: 2, windDir: 180 },
|
||||
{ timestamp: 3, lat: 54.01, lng: 10.01, hdt: 95 }
|
||||
])
|
||||
|
||||
expect(waypoints).toHaveLength(2)
|
||||
expect(waypoints[0]).toMatchObject({ lat: 54, lng: 10, speedKnots: 6, heading: 90 })
|
||||
expect(waypoints[1].heading).toBe(95)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import type { NmeaParseResult, NmeaParseStats, NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
function parseChecksum(line: string): boolean {
|
||||
const star = line.lastIndexOf('*')
|
||||
if (star < 0) return true
|
||||
const expected = line.slice(star + 1, star + 3)
|
||||
if (!/^[0-9A-Fa-f]{2}$/.test(expected)) return false
|
||||
let sum = 0
|
||||
for (let i = 1; i < star; i++) sum ^= line.charCodeAt(i)
|
||||
return sum.toString(16).toUpperCase().padStart(2, '0') === expected.toUpperCase()
|
||||
}
|
||||
|
||||
function sentenceType(field0: string): string {
|
||||
return field0.length >= 3 ? field0.slice(-3) : field0
|
||||
}
|
||||
|
||||
function parseLatLon(latStr: string, latHem: string, lonStr: string, lonHem: string): { lat?: number; lng?: number } {
|
||||
const latVal = parseFloat(latStr)
|
||||
const lonVal = parseFloat(lonStr)
|
||||
if (Number.isNaN(latVal) || Number.isNaN(lonVal)) return {}
|
||||
const latDeg = Math.floor(latVal / 100)
|
||||
const latMin = latVal - latDeg * 100
|
||||
let lat = latDeg + latMin / 60
|
||||
if (latHem === 'S') lat = -lat
|
||||
|
||||
const lonDeg = Math.floor(lonVal / 100)
|
||||
const lonMin = lonVal - lonDeg * 100
|
||||
let lng = lonDeg + lonMin / 60
|
||||
if (lonHem === 'W') lng = -lng
|
||||
|
||||
return { lat: Number(lat.toFixed(6)), lng: Number(lng.toFixed(6)) }
|
||||
}
|
||||
|
||||
function parseRmcDateTime(timeStr: string, dateStr: string, baseYear = new Date().getFullYear()): number | null {
|
||||
if (!timeStr || timeStr.length < 6) return null
|
||||
const hh = parseInt(timeStr.slice(0, 2), 10)
|
||||
const mm = parseInt(timeStr.slice(2, 4), 10)
|
||||
const ss = parseInt(timeStr.slice(4, 6), 10)
|
||||
if ([hh, mm, ss].some((n) => Number.isNaN(n))) return null
|
||||
|
||||
let year = baseYear
|
||||
let month = 0
|
||||
let day = 1
|
||||
if (dateStr && dateStr.length >= 6) {
|
||||
day = parseInt(dateStr.slice(0, 2), 10)
|
||||
month = parseInt(dateStr.slice(2, 4), 10) - 1
|
||||
const yy = parseInt(dateStr.slice(4, 6), 10)
|
||||
year = yy >= 70 ? 1900 + yy : 2000 + yy
|
||||
}
|
||||
|
||||
return Date.UTC(year, month, day, hh, mm, ss)
|
||||
}
|
||||
|
||||
function parseWindSpeed(value: string, unit: string): number | undefined {
|
||||
const speed = parseFloat(value)
|
||||
if (Number.isNaN(speed)) return undefined
|
||||
if (unit === 'N') return speed
|
||||
if (unit === 'M') return speed * 1.94384
|
||||
if (unit === 'K') return speed * 0.539957
|
||||
return speed
|
||||
}
|
||||
|
||||
interface MutableState extends NmeaTimePoint {
|
||||
lastTimestamp: number | null
|
||||
}
|
||||
|
||||
function snapshot(state: MutableState): NmeaTimePoint | null {
|
||||
if (state.lastTimestamp == null) return null
|
||||
const { lastTimestamp, ...rest } = state
|
||||
void lastTimestamp
|
||||
if (
|
||||
rest.lat == null &&
|
||||
rest.lng == null &&
|
||||
rest.cog == null &&
|
||||
rest.sog == null &&
|
||||
rest.hdt == null &&
|
||||
rest.windDir == null &&
|
||||
rest.windSpeedKnots == null &&
|
||||
rest.depthM == null &&
|
||||
rest.rpm == null
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return rest as NmeaTimePoint
|
||||
}
|
||||
|
||||
function pushPoint(points: NmeaTimePoint[], state: MutableState) {
|
||||
const snap = snapshot(state)
|
||||
if (!snap) return
|
||||
const last = points[points.length - 1]
|
||||
if (last && last.timestamp === snap.timestamp) {
|
||||
points[points.length - 1] = { ...last, ...snap }
|
||||
return
|
||||
}
|
||||
points.push(snap)
|
||||
}
|
||||
|
||||
function applySentence(state: MutableState, type: string, fields: string[], points: NmeaTimePoint[]) {
|
||||
switch (type) {
|
||||
case 'RMC': {
|
||||
const status = fields[2]
|
||||
const ts = parseRmcDateTime(fields[1], fields[9])
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
if (status === 'A') {
|
||||
Object.assign(state, parseLatLon(fields[3], fields[4], fields[5], fields[6]))
|
||||
state.fixValid = true
|
||||
const sog = parseFloat(fields[7])
|
||||
const cog = parseFloat(fields[8])
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
} else {
|
||||
state.fixValid = false
|
||||
}
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GGA': {
|
||||
const ts = parseRmcDateTime(fields[1], '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[2], fields[3], fields[4], fields[5]))
|
||||
const quality = parseInt(fields[6], 10)
|
||||
state.fixValid = !Number.isNaN(quality) && quality > 0
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'GLL': {
|
||||
const ts = parseRmcDateTime(fields[5], fields[6] ?? '')
|
||||
if (ts != null) {
|
||||
state.timestamp = ts
|
||||
state.lastTimestamp = ts
|
||||
}
|
||||
Object.assign(state, parseLatLon(fields[1], fields[2], fields[3], fields[4]))
|
||||
state.fixValid = fields[7] === 'A'
|
||||
pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VTG': {
|
||||
const cog = parseFloat(fields[1])
|
||||
const sog = parseFloat(fields[5] || fields[7])
|
||||
if (!Number.isNaN(cog)) state.cog = cog
|
||||
if (!Number.isNaN(sog)) state.sog = sog
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'HDT':
|
||||
state.hdt = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDM':
|
||||
state.hdm = parseFloat(fields[1])
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
case 'HDG': {
|
||||
const hdg = parseFloat(fields[1])
|
||||
if (!Number.isNaN(hdg)) state.hdm = hdg
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWV': {
|
||||
if (fields[5] !== 'A') break
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseWindSpeed(fields[3], fields[4])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (speed != null) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MWD': {
|
||||
const dir = parseFloat(fields[1])
|
||||
const speed = parseFloat(fields[5])
|
||||
if (!Number.isNaN(dir)) state.windDir = dir
|
||||
if (!Number.isNaN(speed)) state.windSpeedKnots = Number(speed.toFixed(1))
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'DPT':
|
||||
case 'DBT': {
|
||||
const depth = parseFloat(fields[1])
|
||||
if (!Number.isNaN(depth)) state.depthM = depth
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'RPM': {
|
||||
const rpm = parseFloat(fields[3] ?? fields[2])
|
||||
if (!Number.isNaN(rpm)) state.rpm = rpm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MDA': {
|
||||
const inchHg = parseFloat(fields[3])
|
||||
const hpaField = parseFloat(fields[15] ?? fields[4])
|
||||
if (!Number.isNaN(hpaField) && hpaField > 800) state.pressureHpa = hpaField
|
||||
else if (!Number.isNaN(inchHg)) state.pressureHpa = inchHg * 33.8639
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'MTW': {
|
||||
const temp = parseFloat(fields[1])
|
||||
if (!Number.isNaN(temp)) state.waterTempC = temp
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'VLW': {
|
||||
const nm = parseFloat(fields[1] ?? fields[2])
|
||||
if (!Number.isNaN(nm)) state.logDistanceNm = nm
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
case 'APA': {
|
||||
const mode = fields[1]
|
||||
state.autopilotEngaged = mode === '1' || mode?.toUpperCase() === 'A'
|
||||
if (state.lastTimestamp != null) pushPoint(points, state)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export function parseNmeaFile(text: string, filename: string): NmeaParseResult {
|
||||
const warnings: string[] = []
|
||||
const points: NmeaTimePoint[] = []
|
||||
const typesSeen = new Set<string>()
|
||||
let totalLines = 0
|
||||
let parsedLines = 0
|
||||
let checksumErrors = 0
|
||||
|
||||
const state: MutableState = { timestamp: 0, lastTimestamp: null }
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || (!line.startsWith('$') && !line.startsWith('!'))) continue
|
||||
totalLines++
|
||||
if (!parseChecksum(line)) {
|
||||
checksumErrors++
|
||||
continue
|
||||
}
|
||||
|
||||
const star = line.indexOf('*')
|
||||
const body = star >= 0 ? line.slice(0, star) : line
|
||||
const fields = body.slice(1).split(',')
|
||||
if (fields.length < 2) continue
|
||||
|
||||
const type = sentenceType(fields[0])
|
||||
typesSeen.add(type)
|
||||
applySentence(state, type, fields, points)
|
||||
parsedLines++
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
warnings.push('no_samples')
|
||||
}
|
||||
if (!typesSeen.has('RMC') && !typesSeen.has('GGA') && !typesSeen.has('GLL')) {
|
||||
warnings.push('no_position')
|
||||
}
|
||||
|
||||
const stats: NmeaParseStats = {
|
||||
totalLines,
|
||||
parsedLines,
|
||||
checksumErrors,
|
||||
sentenceTypes: [...typesSeen].sort()
|
||||
}
|
||||
|
||||
return { points, stats, warnings, rawText: text, filename }
|
||||
}
|
||||
|
||||
export function nmeaPointsToWaypoints(points: NmeaTimePoint[]): import('../trackUpload.js').TrackWaypoint[] {
|
||||
return points
|
||||
.filter((p) => p.lat != null && p.lng != null)
|
||||
.map((p) => ({
|
||||
timestamp: p.timestamp,
|
||||
lat: p.lat!,
|
||||
lng: p.lng!,
|
||||
speedKnots: p.sog,
|
||||
heading: p.cog ?? p.hdt ?? p.hdm
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { NmeaTimePoint } from './nmeaTypes.js'
|
||||
|
||||
/** Nearest sample at or before timestamp (carry-forward). */
|
||||
export function sampleAt(points: NmeaTimePoint[], timestamp: number): NmeaTimePoint | null {
|
||||
if (points.length === 0) return null
|
||||
let best: NmeaTimePoint | null = null
|
||||
for (const p of points) {
|
||||
if (p.timestamp <= timestamp) best = p
|
||||
else break
|
||||
}
|
||||
return best ?? points[0]
|
||||
}
|
||||
|
||||
export function filterPointsForDate(points: NmeaTimePoint[], dateYmd: string): NmeaTimePoint[] {
|
||||
if (!dateYmd || points.length === 0) return points
|
||||
const [y, m, d] = dateYmd.split('-').map((v) => parseInt(v, 10))
|
||||
if ([y, m, d].some((n) => Number.isNaN(n))) return points
|
||||
|
||||
const start = Date.UTC(y, m - 1, d, 0, 0, 0)
|
||||
const end = Date.UTC(y, m - 1, d, 23, 59, 59)
|
||||
|
||||
const filtered = points.filter((p) => p.timestamp >= start && p.timestamp <= end)
|
||||
return filtered.length > 0 ? filtered : points
|
||||
}
|
||||
|
||||
export function timestampToHHMM(timestamp: number, timeZone?: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: timeZone ?? undefined
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-GB', opts).formatToParts(new Date(timestamp))
|
||||
const hh = parts.find((p) => p.type === 'hour')?.value ?? '00'
|
||||
const mm = parts.find((p) => p.type === 'minute')?.value ?? '00'
|
||||
return `${hh}:${mm}`
|
||||
}
|
||||
|
||||
export function angularDelta(a: number, b: number): number {
|
||||
const diff = Math.abs(a - b) % 360
|
||||
return diff > 180 ? 360 - diff : diff
|
||||
}
|
||||
|
||||
export function intervalTimestamps(
|
||||
points: NmeaTimePoint[],
|
||||
intervalMinutes: number
|
||||
): number[] {
|
||||
if (points.length === 0) return []
|
||||
const start = points[0].timestamp
|
||||
const end = points[points.length - 1].timestamp
|
||||
const stepMs = intervalMinutes * 60 * 1000
|
||||
const stamps: number[] = []
|
||||
for (let t = start; t <= end; t += stepMs) {
|
||||
stamps.push(t)
|
||||
}
|
||||
if (stamps[stamps.length - 1] !== end) stamps.push(end)
|
||||
return stamps
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export type NmeaChangeType =
|
||||
| 'course'
|
||||
| 'wind'
|
||||
| 'pressure'
|
||||
| 'engine_start'
|
||||
| 'engine_stop'
|
||||
| 'autopilot_on'
|
||||
| 'autopilot_off'
|
||||
| 'depth'
|
||||
| 'anchor'
|
||||
| 'departure'
|
||||
| 'speed'
|
||||
| 'gps_fix_lost'
|
||||
| 'gps_fix_regained'
|
||||
| 'water_temp'
|
||||
| 'wind_shift'
|
||||
|
||||
export interface NmeaParseStats {
|
||||
totalLines: number
|
||||
parsedLines: number
|
||||
checksumErrors: number
|
||||
sentenceTypes: string[]
|
||||
}
|
||||
|
||||
export interface NmeaTimePoint {
|
||||
timestamp: number
|
||||
lat?: number
|
||||
lng?: number
|
||||
cog?: number
|
||||
sog?: number
|
||||
hdt?: number
|
||||
hdm?: number
|
||||
windDir?: number
|
||||
windSpeedKnots?: number
|
||||
depthM?: number
|
||||
rpm?: number
|
||||
pressureHpa?: number
|
||||
waterTempC?: number
|
||||
logDistanceNm?: number
|
||||
fixValid?: boolean
|
||||
autopilotEngaged?: boolean
|
||||
}
|
||||
|
||||
export interface NmeaChangeEvent {
|
||||
type: NmeaChangeType
|
||||
timestamp: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
summaryKey: string
|
||||
summaryParams?: Record<string, string | number>
|
||||
data?: Partial<NmeaTimePoint>
|
||||
}
|
||||
|
||||
export interface NmeaParseResult {
|
||||
points: NmeaTimePoint[]
|
||||
stats: NmeaParseStats
|
||||
warnings: string[]
|
||||
rawText: string
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type NmeaImportMode = 'interval' | 'change' | 'both'
|
||||
|
||||
export interface NmeaJournalCandidate {
|
||||
id: string
|
||||
timestamp: number
|
||||
source: 'interval' | 'change'
|
||||
changeType?: NmeaChangeType
|
||||
confidence?: 'high' | 'medium' | 'low'
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export interface NmeaDetectionConfig {
|
||||
courseDeltaDeg: number
|
||||
windDirDeltaDeg: number
|
||||
windSpeedDeltaKnots: number
|
||||
pressureDeltaHpa: number
|
||||
depthDeltaM: number
|
||||
depthDeltaPercent: number
|
||||
rpmIdle: number
|
||||
rpmRunning: number
|
||||
sogUnderWayKn: number
|
||||
sogStoppedKn: number
|
||||
anchorMinutes: number
|
||||
speedDeltaKn: number
|
||||
dedupeWindowMs: number
|
||||
}
|
||||
|
||||
export const DEFAULT_NMEA_DETECTION_CONFIG: NmeaDetectionConfig = {
|
||||
courseDeltaDeg: 28,
|
||||
windDirDeltaDeg: 35,
|
||||
windSpeedDeltaKnots: 4,
|
||||
pressureDeltaHpa: 2,
|
||||
depthDeltaM: 2,
|
||||
depthDeltaPercent: 25,
|
||||
rpmIdle: 400,
|
||||
rpmRunning: 800,
|
||||
sogUnderWayKn: 2,
|
||||
sogStoppedKn: 0.5,
|
||||
anchorMinutes: 10,
|
||||
speedDeltaKn: 3,
|
||||
dedupeWindowMs: 5 * 60 * 1000
|
||||
}
|
||||
Reference in New Issue
Block a user