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:
2026-05-31 20:41:42 +02:00
parent bb667afec8
commit 6c866dbad5
25 changed files with 2475 additions and 6 deletions
@@ -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)
})
})
+283
View File
@@ -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
}
+102
View File
@@ -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
}