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
+177
View File
@@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected {
width: 100%; width: 100%;
max-width: 560px; max-width: 560px;
max-height: min(90vh, 820px); max-height: min(90vh, 820px);
overflow-y: auto;
} }
.feedback-modal { .feedback-modal {
@@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected {
margin-top: 0; margin-top: 0;
} }
.registration-disclaimer.feedback-modal {
align-items: stretch;
width: 100%;
}
.registration-disclaimer.feedback-modal .auth-header,
.registration-disclaimer.feedback-modal > p,
.registration-disclaimer.feedback-modal .nmea-import-summary,
.registration-disclaimer.feedback-modal .nmea-import-warning,
.registration-disclaimer.feedback-modal .nmea-import-mode,
.registration-disclaimer.feedback-modal .feedback-form__field,
.registration-disclaimer.feedback-modal .nmea-import-checkbox,
.registration-disclaimer.feedback-modal .nmea-preview-actions,
.registration-disclaimer.feedback-modal .nmea-preview-list,
.registration-disclaimer.feedback-modal .auth-actions {
width: 100%;
box-sizing: border-box;
}
.nmea-import-warning {
width: 100%;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
text-align: left;
color: var(--app-warning-text, #fcd34d);
background: var(--app-warning-bg, rgba(251, 191, 36, 0.1));
border: 1px solid var(--app-warning-border, rgba(251, 191, 36, 0.35));
box-sizing: border-box;
}
.nmea-import-summary {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
background: var(--app-surface-inset);
border: 1px solid var(--app-border-muted);
text-align: left;
font-size: 13px;
line-height: 1.5;
}
.nmea-import-summary p {
margin: 0;
}
.nmea-import-summary p + p {
margin-top: 6px;
}
.nmea-import-mode {
border: 1px solid var(--app-border-muted);
border-radius: 8px;
padding: 12px 16px;
margin: 0 0 16px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
}
.nmea-import-mode legend {
padding: 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--app-text-heading, #f1f5f9);
}
.nmea-import-mode label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
}
.nmea-import-checkbox {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
cursor: pointer;
text-align: left;
font-size: 14px;
}
.nmea-preview-actions {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.nmea-preview-actions .btn {
flex: 1;
margin: 0;
}
.nmea-preview-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(45vh, 360px);
overflow-y: auto;
margin-bottom: 16px;
padding: 2px;
}
.nmea-preview-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--app-border-muted);
border-radius: 8px;
background: var(--app-surface-inset);
cursor: pointer;
text-align: left;
transition: border-color 0.15s ease;
}
.nmea-preview-row:hover {
border-color: var(--app-accent-border, rgba(212, 175, 55, 0.35));
}
.nmea-preview-row__check {
flex-shrink: 0;
margin: 2px 0 0;
width: 18px;
height: 18px;
accent-color: var(--app-accent-light, #d4af37);
}
.nmea-preview-row__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nmea-preview-row__meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.nmea-preview-time {
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 14px;
color: var(--app-accent-light, #d4af37);
min-width: 3.25rem;
}
.nmea-preview-source {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.15);
color: var(--app-text-muted, #94a3b8);
}
.nmea-preview-remarks {
font-size: 13px;
line-height: 1.45;
color: var(--app-text, #e2e8f0);
word-break: break-word;
}
.feedback-form { .feedback-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+88 -1
View File
@@ -37,8 +37,16 @@ import {
deleteTrack, deleteTrack,
downloadTrackFile, downloadTrackFile,
parseTrackFile, parseTrackFile,
type SavedTrack type SavedTrack,
type TrackWaypoint
} from '../services/trackUpload.js' } from '../services/trackUpload.js'
import NmeaImportWizard from './NmeaImportWizard.tsx'
import {
deleteNmeaArchive,
downloadNmeaArchive,
getNmeaArchive,
type NmeaArchiveRecord
} from '../services/nmeaArchive.js'
import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js' import { computeTrackStats, formatTrackStats } from '../utils/trackStats.js'
import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js'
import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx'
@@ -210,6 +218,8 @@ export default function LogEntryEditor({
const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null) const [savedTrack, setSavedTrack] = useState<SavedTrack | null>(null)
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [uploadError, setUploadError] = useState<string | null>(null) const [uploadError, setUploadError] = useState<string | null>(null)
const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false)
const [nmeaArchive, setNmeaArchive] = useState<NmeaArchiveRecord | null>(null)
const fileInputRef = useRef<HTMLInputElement | null>(null) const fileInputRef = useRef<HTMLInputElement | null>(null)
const lockedContentHashRef = useRef<string | null>(null) const lockedContentHashRef = useRef<string | null>(null)
const contentReadyRef = useRef(false) const contentReadyRef = useRef(false)
@@ -762,6 +772,45 @@ export default function LogEntryEditor({
loadTrack() loadTrack()
}, [entryId, preloadedTrack]) }, [entryId, preloadedTrack])
const loadNmeaArchive = async () => {
if (readOnly) return
try {
const archive = await getNmeaArchive(entryId)
setNmeaArchive(archive)
} catch {
setNmeaArchive(null)
}
}
useEffect(() => {
loadNmeaArchive()
}, [entryId, readOnly])
const handleNmeaImport = async (importedEvents: LogEventPayload[], waypoints?: TrackWaypoint[]) => {
setEvents((prev) => sortLogEventsByTime([...prev, ...importedEvents]))
if (waypoints && waypoints.length > 0) {
try {
const gpxLike = waypoints
.map((wp) => ` <trkpt lat="${wp.lat}" lon="${wp.lng}"><time>${new Date(wp.timestamp).toISOString()}</time></trkpt>`)
.join('\n')
const content = `<?xml version="1.0"?><gpx><trk><trkseg>\n${gpxLike}\n</trkseg></trk></gpx>`
await saveUploadedTrack(logbookId, entryId, content, waypoints, 'imported-from-nmea.nmea', 'nmea')
applyTrackStats(waypoints)
await loadTrack()
trackPlausibleEvent(PlausibleEvents.GPS_TRACK_UPLOADED)
} catch (err: unknown) {
console.warn('Failed to save NMEA track:', err)
}
}
await loadNmeaArchive()
}
const handleDeleteNmeaArchive = async () => {
if (!window.confirm(t('logs.nmea_archive_delete_confirm'))) return
await deleteNmeaArchive(entryId)
setNmeaArchive(null)
}
useEffect(() => { useEffect(() => {
if (!savedTrack || savedTrack.waypoints.length < 2) return if (!savedTrack || savedTrack.waypoints.length < 2) return
if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return
@@ -1925,6 +1974,31 @@ export default function LogEntryEditor({
</> </>
)} )}
{!readOnly && (
<div className="nmea-import-section" style={{ marginTop: '12px' }}>
<button
type="button"
className="btn secondary"
onClick={() => setNmeaWizardOpen(true)}
style={{ width: 'auto', padding: '8px 14px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}
>
<FileText size={16} />
{t('logs.nmea_import_btn')}
</button>
{nmeaArchive && (
<div className="nmea-archive-info" style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<span>{t('logs.nmea_archive_stored', { name: nmeaArchive.filename })}</span>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={() => downloadNmeaArchive(nmeaArchive)}>
<Download size={14} />
</button>
<button type="button" className="btn secondary" style={{ width: 'auto', padding: '4px 10px', fontSize: '13px' }} onClick={handleDeleteNmeaArchive}>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)}
{(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && ( {(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
<div className="form-grid track-stats-grid"> <div className="form-grid track-stats-grid">
<div className="input-group"> <div className="input-group">
@@ -2030,6 +2104,19 @@ export default function LogEntryEditor({
</div> </div>
)} )}
</form> </form>
<NmeaImportWizard
open={nmeaWizardOpen}
onClose={() => {
setNmeaWizardOpen(false)
void loadNmeaArchive()
}}
logbookId={logbookId}
entryId={entryId}
entryDate={date}
nmeaArchive={nmeaArchive}
onImport={handleNmeaImport}
/>
</div> </div>
) )
} }
+327
View File
@@ -0,0 +1,327 @@
import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { FileText, X } from 'lucide-react'
import type { LogEventPayload } from '../utils/logEntryPayload.js'
import { sortLogEventsByTime } from '../utils/logEntryPayload.js'
import { parseNmeaFile, nmeaPointsToWaypoints } from '../services/nmea/nmeaParse.js'
import { filterPointsForDate } from '../services/nmea/nmeaTimeSeries.js'
import { generateNmeaJournalCandidates } from '../services/nmea/nmeaJournalGenerator.js'
import type { NmeaImportMode, NmeaParseResult } from '../services/nmea/nmeaTypes.js'
import { saveNmeaArchive, recordNmeaFileImport, type NmeaArchiveRecord } from '../services/nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import type { TrackWaypoint } from '../services/trackUpload.js'
interface NmeaImportWizardProps {
open: boolean
onClose: () => void
logbookId: string
entryId: string
entryDate: string
nmeaArchive: NmeaArchiveRecord | null
onImport: (events: LogEventPayload[], waypoints?: TrackWaypoint[]) => void
}
type WizardStep = 'config' | 'preview' | 'archive'
export default function NmeaImportWizard({
open,
onClose,
logbookId,
entryId,
entryDate,
nmeaArchive,
onImport
}: NmeaImportWizardProps) {
const { t } = useTranslation()
const [step, setStep] = useState<WizardStep>('config')
const [parseResult, setParseResult] = useState<NmeaParseResult | null>(null)
const [mode, setMode] = useState<NmeaImportMode>('both')
const [intervalMinutes, setIntervalMinutes] = useState(60)
const [importTrack, setImportTrack] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [pendingRaw, setPendingRaw] = useState<{ filename: string; text: string } | null>(null)
const [duplicateFile, setDuplicateFile] = useState(false)
const filteredPoints = useMemo(() => {
if (!parseResult) return []
return filterPointsForDate(parseResult.points, entryDate)
}, [parseResult, entryDate])
const candidates = useMemo(() => {
if (!parseResult || filteredPoints.length === 0) return []
return generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
}, [parseResult, filteredPoints, mode, intervalMinutes, t])
const reset = () => {
setStep('config')
setParseResult(null)
setMode('both')
setIntervalMinutes(60)
setImportTrack(true)
setSelectedIds(new Set())
setError(null)
setDuplicateFile(false)
setPendingRaw(null)
}
const handleClose = () => {
reset()
onClose()
}
const handleFile = (file: File) => {
setError(null)
setDuplicateFile(false)
const reader = new FileReader()
reader.onload = () => {
try {
const text = String(reader.result ?? '')
const crc32 = nmeaFileCrc32(text)
const alreadyImported = nmeaArchive?.importedFiles.some((item) => item.crc32 === crc32) ?? false
setDuplicateFile(alreadyImported)
const result = parseNmeaFile(text, file.name)
if (result.points.length === 0) {
setError(t('logs.nmea_error_no_samples'))
return
}
setParseResult(result)
setPendingRaw({ filename: file.name, text })
const generated = generateNmeaJournalCandidates({
points: filterPointsForDate(result.points, entryDate),
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
} catch (err) {
setError(err instanceof Error ? err.message : t('logs.nmea_error_parse'))
}
}
reader.onerror = () => setError(t('logs.nmea_error_read'))
reader.readAsText(file)
}
const toggleAll = (checked: boolean) => {
setSelectedIds(checked ? new Set(candidates.map((c) => c.id)) : new Set())
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const goPreview = () => {
if (!parseResult) {
setError(t('logs.nmea_error_no_file'))
return
}
const generated = generateNmeaJournalCandidates({
points: filteredPoints,
mode,
intervalMinutes,
t
}).candidates
setSelectedIds(new Set(generated.map((c) => c.id)))
setStep('preview')
}
const applyImport = async () => {
const picked = candidates.filter((c) => selectedIds.has(c.id)).map((c) => c.event)
if (picked.length === 0) {
setError(t('logs.nmea_error_no_selection'))
return
}
const waypoints = importTrack ? nmeaPointsToWaypoints(filteredPoints) : undefined
onImport(sortLogEventsByTime(picked), waypoints)
if (pendingRaw) {
try {
await recordNmeaFileImport(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
} catch (err) {
console.warn('NMEA import CRC record failed:', err)
}
}
trackPlausibleEvent(PlausibleEvents.NMEA_IMPORTED, {
mode,
candidates: picked.length,
track: importTrack && (waypoints?.length ?? 0) > 0
})
setStep('archive')
}
const finishArchive = async (archive: boolean) => {
try {
if (archive && pendingRaw) {
await saveNmeaArchive(logbookId, entryId, pendingRaw.filename, pendingRaw.text)
}
} catch (err) {
console.warn('NMEA archive save failed:', err)
}
handleClose()
}
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') handleClose()
}
window.addEventListener('keydown', onKeyDown)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = prevOverflow
}
}, [open])
if (!open) return null
return createPortal(
<div className="disclaimer-modal-overlay" onClick={handleClose}>
<div className="disclaimer-modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="auth-card glass registration-disclaimer registration-disclaimer--modal feedback-modal">
<button
type="button"
className="registration-disclaimer__close feedback-modal__close"
onClick={handleClose}
aria-label={t('logs.nmea_cancel')}
>
<X size={18} />
</button>
<div className="auth-header">
<FileText className="auth-icon accent" size={40} />
<h2>{t('logs.nmea_import_title')}</h2>
</div>
{error && <div className="track-error-msg">{error}</div>}
{duplicateFile && (
<div className="nmea-import-warning" role="status">
{t('logs.nmea_warn_duplicate_file')}
</div>
)}
{step === 'config' && (
<>
<p className="registration-disclaimer__intro">{t('logs.nmea_import_intro')}</p>
<label className="feedback-form__field">
<span>{t('logs.nmea_file_label')}</span>
<input
type="file"
accept=".nmea,.log,.txt"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleFile(file)
}}
/>
</label>
{parseResult && (
<div className="nmea-import-summary">
<p>{t('logs.nmea_stats', {
lines: parseResult.stats.parsedLines,
types: parseResult.stats.sentenceTypes.join(', ')
})}</p>
{parseResult.warnings.includes('no_position') && (
<p>{t('logs.nmea_warn_no_position')}</p>
)}
</div>
)}
<fieldset className="nmea-import-mode">
<legend>{t('logs.nmea_mode_label')}</legend>
<label><input type="radio" name="nmea-mode" checked={mode === 'interval'} onChange={() => setMode('interval')} /> {t('logs.nmea_mode_interval')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'change'} onChange={() => setMode('change')} /> {t('logs.nmea_mode_change')}</label>
<label><input type="radio" name="nmea-mode" checked={mode === 'both'} onChange={() => setMode('both')} /> {t('logs.nmea_mode_both')}</label>
</fieldset>
{(mode === 'interval' || mode === 'both') && (
<label className="feedback-form__field">
<span>{t('logs.nmea_interval_label')}</span>
<select value={intervalMinutes} onChange={(e) => setIntervalMinutes(Number(e.target.value))}>
<option value={30}>30 min</option>
<option value={60}>60 min</option>
<option value={90}>90 min</option>
<option value={120}>120 min</option>
</select>
</label>
)}
<label className="nmea-import-checkbox">
<input type="checkbox" checked={importTrack} onChange={(e) => setImportTrack(e.target.checked)} />
{t('logs.nmea_import_track')}
</label>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={handleClose}>{t('logs.nmea_cancel')}</button>
<button type="button" className="btn primary" onClick={goPreview} disabled={!parseResult}>
{t('logs.nmea_preview')}
</button>
</div>
</>
)}
{step === 'preview' && (
<>
<p>{t('logs.nmea_preview_hint', { count: candidates.length })}</p>
<div className="nmea-preview-actions">
<button type="button" className="btn secondary" onClick={() => toggleAll(true)}>{t('logs.nmea_select_all')}</button>
<button type="button" className="btn secondary" onClick={() => toggleAll(false)}>{t('logs.nmea_select_none')}</button>
</div>
<div className="nmea-preview-list">
{candidates.map((c) => (
<label key={c.id} className="nmea-preview-row">
<input
type="checkbox"
className="nmea-preview-row__check"
checked={selectedIds.has(c.id)}
onChange={() => toggleOne(c.id)}
/>
<div className="nmea-preview-row__body">
<div className="nmea-preview-row__meta">
<span className="nmea-preview-time">{c.event.time}</span>
<span className="nmea-preview-source">{t(`logs.nmea_source_${c.source}`)}</span>
</div>
<span className="nmea-preview-remarks">{c.event.remarks || c.event.mgk || '—'}</span>
</div>
</label>
))}
</div>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => setStep('config')}>{t('logs.nmea_back')}</button>
<button type="button" className="btn primary" onClick={applyImport}>{t('logs.nmea_apply')}</button>
</div>
</>
)}
{step === 'archive' && (
<>
<p>{t('logs.nmea_archive_question')}</p>
<div className="auth-actions feedback-form__actions">
<button type="button" className="btn secondary" onClick={() => finishArchive(false)}>
{t('logs.nmea_archive_discard')}
</button>
<button type="button" className="btn primary" onClick={() => finishArchive(true)}>
{t('logs.nmea_archive_keep')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
document.body
)
}
+51 -1
View File
@@ -283,7 +283,57 @@
"revoke": "Fjerne", "revoke": "Fjerne",
"revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?", "revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?",
"invite_role": "Rolle", "invite_role": "Rolle",
"invite_expires": "Linket er gyldigt i 48 timer" "invite_expires": "Linket er gyldigt i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
}, },
"dashboard": { "dashboard": {
"title": "Dine logbøger", "title": "Dine logbøger",
+50
View File
@@ -273,6 +273,56 @@
"track_map_end": "Ziel", "track_map_end": "Ziel",
"track_map_speed_slow": "langsam", "track_map_speed_slow": "langsam",
"track_map_speed_fast": "schnell", "track_map_speed_fast": "schnell",
"nmea_import_title": "NMEA-Protokoll importieren",
"nmea_import_intro": "Lade eine .nmea-Datei vom Bord-Logger. Die App schlägt Journal-Einträge vor — du entscheidest, was übernommen wird.",
"nmea_import_btn": "NMEA importieren",
"nmea_file_label": "NMEA-Datei",
"nmea_stats": "{{lines}} Sätze erkannt · Typen: {{types}}",
"nmea_warn_no_position": "Keine Positions-Sätze gefunden — Track und GPS-Felder können leer bleiben.",
"nmea_warn_duplicate_file": "Diese NMEA-Datei wurde bereits importiert. Ein erneuter Import derselben Datei fügt doppelte Journal-Einträge hinzu.",
"nmea_mode_label": "Journal-Einträge erzeugen",
"nmea_mode_interval": "Nach Zeitintervall",
"nmea_mode_change": "Bei signifikanter Änderung",
"nmea_mode_both": "Beides (zusammenführen)",
"nmea_interval_label": "Intervall (Minuten)",
"nmea_import_track": "GPS-Track aus NMEA übernehmen",
"nmea_preview": "Vorschau",
"nmea_preview_hint": "{{count}} vorgeschlagene Journal-Einträge",
"nmea_select_all": "Alle auswählen",
"nmea_select_none": "Keine auswählen",
"nmea_source_interval": "Intervall",
"nmea_source_change": "Ereignis",
"nmea_apply": "In Journal übernehmen",
"nmea_back": "Zurück",
"nmea_cancel": "Abbrechen",
"nmea_archive_question": "Rohprotokoll lokal archivieren? (Nur auf diesem Gerät, nicht synchronisiert.)",
"nmea_archive_keep": "Archivieren",
"nmea_archive_discard": "Verwerfen",
"nmea_archive_stored": "NMEA archiviert: {{name}}",
"nmea_archive_delete_confirm": "Archiviertes NMEA-Protokoll von diesem Gerät löschen?",
"nmea_error_no_samples": "Keine verwertbaren NMEA-Sätze in der Datei.",
"nmea_error_parse": "NMEA-Datei konnte nicht gelesen werden.",
"nmea_error_read": "Datei konnte nicht gelesen werden.",
"nmea_error_no_file": "Bitte zuerst eine NMEA-Datei wählen.",
"nmea_error_no_selection": "Bitte mindestens einen Journal-Eintrag auswählen.",
"nmea_remark_interval": "NMEA Intervall",
"nmea_remark_uncertain": "unsicher",
"nmea_remark_depth": "Tiefe {{depth}} m",
"nmea_change_course": "Kursänderung {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Luftdruck {{from}} → {{to}} hPa",
"nmea_change_depth": "Tiefe {{from}} → {{to}} m",
"nmea_change_engine_start": "Motor an ({{rpm}} U/min)",
"nmea_change_engine_stop": "Motor aus",
"nmea_change_autopilot_on": "Autopilot ein",
"nmea_change_autopilot_off": "Autopilot aus",
"nmea_change_gps_lost": "GPS-Fix verloren",
"nmea_change_gps_regained": "GPS-Fix wiederhergestellt",
"nmea_change_water_temp": "Wassertemp. {{from}} → {{to}} °C",
"nmea_change_departure": "Abfahrt / Fahrtbeginn",
"nmea_change_anchor": "Ankern / Stop",
"nmea_change_speed": "Geschw. {{from}} → {{to}} kn",
"track_map_error": "Karte konnte nicht geladen werden.", "track_map_error": "Karte konnte nicht geladen werden.",
"exporting": "Exportiere...", "exporting": "Exportiere...",
"share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.",
+50
View File
@@ -273,6 +273,56 @@
"track_map_end": "End", "track_map_end": "End",
"track_map_speed_slow": "slow", "track_map_speed_slow": "slow",
"track_map_speed_fast": "fast", "track_map_speed_fast": "fast",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"track_map_error": "Could not load map.", "track_map_error": "Could not load map.",
"exporting": "Exporting...", "exporting": "Exporting...",
"share_unsupported": "Web sharing is not supported on this device. File downloaded instead.", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead.",
+51 -1
View File
@@ -283,7 +283,57 @@
"revoke": "Fjern", "revoke": "Fjern",
"revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?", "revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?",
"invite_role": "Rolle", "invite_role": "Rolle",
"invite_expires": "Lenken er gyldig i 48 timer" "invite_expires": "Lenken er gyldig i 48 timer",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
}, },
"dashboard": { "dashboard": {
"title": "Loggbøkene dine", "title": "Loggbøkene dine",
+51 -1
View File
@@ -283,7 +283,57 @@
"revoke": "Ta bort", "revoke": "Ta bort",
"revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?", "revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?",
"invite_role": "Roll", "invite_role": "Roll",
"invite_expires": "Länken är giltig i 48 timmar" "invite_expires": "Länken är giltig i 48 timmar",
"nmea_import_title": "Import NMEA log",
"nmea_import_intro": "Upload a .nmea file from your onboard logger. The app suggests journal entries — you choose what to import.",
"nmea_import_btn": "Import NMEA",
"nmea_file_label": "NMEA file",
"nmea_stats": "{{lines}} sentences parsed · types: {{types}}",
"nmea_warn_no_position": "No position sentences found — track and GPS fields may stay empty.",
"nmea_mode_label": "Generate journal entries",
"nmea_mode_interval": "By time interval",
"nmea_mode_change": "On significant change",
"nmea_mode_both": "Both (merge)",
"nmea_interval_label": "Interval (minutes)",
"nmea_import_track": "Import GPS track from NMEA",
"nmea_preview": "Preview",
"nmea_preview_hint": "{{count}} suggested journal entries",
"nmea_select_all": "Select all",
"nmea_select_none": "Select none",
"nmea_source_interval": "Interval",
"nmea_source_change": "Event",
"nmea_apply": "Apply to journal",
"nmea_back": "Back",
"nmea_cancel": "Cancel",
"nmea_archive_question": "Archive raw log locally? (This device only, not synced.)",
"nmea_archive_keep": "Archive",
"nmea_archive_discard": "Discard",
"nmea_archive_stored": "NMEA archived: {{name}}",
"nmea_archive_delete_confirm": "Delete archived NMEA log from this device?",
"nmea_error_no_samples": "No usable NMEA sentences in the file.",
"nmea_error_parse": "Could not read NMEA file.",
"nmea_error_read": "Could not read file.",
"nmea_error_no_file": "Please choose an NMEA file first.",
"nmea_error_no_selection": "Please select at least one journal entry.",
"nmea_remark_interval": "NMEA interval",
"nmea_remark_uncertain": "uncertain",
"nmea_remark_depth": "Depth {{depth}} m",
"nmea_change_course": "Course change {{from}}° → {{to}}°",
"nmea_change_wind": "Wind {{from}}° → {{to}}°",
"nmea_change_wind_speed": "Wind {{from}} → {{to}} kn",
"nmea_change_pressure": "Pressure {{from}} → {{to}} hPa",
"nmea_change_depth": "Depth {{from}} → {{to}} m",
"nmea_change_engine_start": "Engine on ({{rpm}} rpm)",
"nmea_change_engine_stop": "Engine off",
"nmea_change_autopilot_on": "Autopilot on",
"nmea_change_autopilot_off": "Autopilot off",
"nmea_change_gps_lost": "GPS fix lost",
"nmea_change_gps_regained": "GPS fix restored",
"nmea_change_water_temp": "Water temp. {{from}} → {{to}} °C",
"nmea_change_departure": "Departure / underway",
"nmea_change_anchor": "Anchored / stop",
"nmea_change_speed": "Speed {{from}} → {{to}} kn",
"nmea_warn_duplicate_file": "This NMEA file has already been imported. Importing the same file again will add duplicate journal entries."
}, },
"dashboard": { "dashboard": {
"title": "Dina loggböcker", "title": "Dina loggböcker",
+2 -1
View File
@@ -35,7 +35,8 @@ export const PlausibleEvents = {
LOCAL_PIN_REMOVED: 'Local PIN Removed', LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten', DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated', RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed' LANGUAGE_CHANGED: 'Language Changed',
NMEA_IMPORTED: 'NMEA Imported'
} as const } as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+22
View File
@@ -64,6 +64,15 @@ export interface LocalGpsTrack {
updatedAt: string updatedAt: string
} }
export interface LocalNmeaArchive {
entryId: string
logbookId: string
encryptedData: string
iv: string
tag: string
updatedAt: string
}
export interface LocalLogbookKey { export interface LocalLogbookKey {
logbookId: string logbookId: string
encryptedKey: string encryptedKey: string
@@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie {
entries!: Table<LocalEntry> entries!: Table<LocalEntry>
photos!: Table<LocalPhoto> photos!: Table<LocalPhoto>
gpsTracks!: Table<LocalGpsTrack> gpsTracks!: Table<LocalGpsTrack>
nmeaArchives!: Table<LocalNmeaArchive>
logbookKeys!: Table<LocalLogbookKey> logbookKeys!: Table<LocalLogbookKey>
syncQueue!: Table<SyncQueueItem> syncQueue!: Table<SyncQueueItem>
@@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie {
gpsTracks: 'entryId, logbookId, updatedAt', gpsTracks: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId' logbookKeys: 'logbookId'
}) })
this.version(6).stores({
logbooks: 'id, encryptedTitle, updatedAt, isSynced, isShared, isDemo',
yachts: 'logbookId, updatedAt',
crews: 'payloadId, logbookId, updatedAt',
deviations: 'logbookId, updatedAt',
entries: 'payloadId, logbookId, updatedAt',
syncQueue: '++id, action, type, payloadId, logbookId',
photos: 'payloadId, entryId, logbookId, updatedAt',
gpsTracks: 'entryId, logbookId, updatedAt',
nmeaArchives: 'entryId, logbookId, updatedAt',
logbookKeys: 'logbookId'
})
} }
} }
@@ -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
}
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { isNmeaCrcAlreadyImported, type NmeaArchiveRecord } from './nmeaArchive.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
describe('nmeaArchive CRC tracking', () => {
it('detects duplicate file content by CRC32', () => {
const text = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W\n'
const record: NmeaArchiveRecord = {
filename: 'a.nmea',
rawText: '',
importedAt: '2026-05-29T10:00:00.000Z',
importedFiles: [{
crc32: nmeaFileCrc32(text),
filename: 'a.nmea',
importedAt: '2026-05-29T10:00:00.000Z'
}]
}
expect(isNmeaCrcAlreadyImported(record, text)).toBe(true)
expect(isNmeaCrcAlreadyImported(record, text.replace('\n', '\r\n'))).toBe(true)
expect(isNmeaCrcAlreadyImported(record, '$GPRMC,999999,A\n')).toBe(false)
expect(isNmeaCrcAlreadyImported(null, text)).toBe(false)
})
})
+146
View File
@@ -0,0 +1,146 @@
import { db } from './db.js'
import { getActiveMasterKey } from './auth.js'
import { getLogbookKey } from './logbookKeys.js'
import { encryptJson, decryptJson } from './crypto.js'
import { nmeaFileCrc32 } from '../utils/crc32.js'
export interface NmeaImportedFile {
crc32: string
filename: string
importedAt: string
}
export interface NmeaArchiveRecord {
filename: string
rawText: string
importedAt: string
importedFiles: NmeaImportedFile[]
}
function normalizeArchiveRecord(raw: Partial<NmeaArchiveRecord>): NmeaArchiveRecord {
const importedFiles = [...(raw.importedFiles ?? [])]
if (importedFiles.length === 0 && raw.rawText) {
importedFiles.push({
crc32: nmeaFileCrc32(raw.rawText),
filename: raw.filename ?? '',
importedAt: raw.importedAt ?? ''
})
}
return {
filename: raw.filename ?? '',
rawText: raw.rawText ?? '',
importedAt: raw.importedAt ?? '',
importedFiles
}
}
async function putNmeaArchiveRecord(
logbookId: string,
entryId: string,
payload: NmeaArchiveRecord
): Promise<void> {
const masterKey = await getLogbookKey(logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
const encrypted = await encryptJson(payload, masterKey)
await db.nmeaArchives.put({
entryId,
logbookId,
encryptedData: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
updatedAt: payload.importedAt || new Date().toISOString()
})
}
export async function getNmeaArchive(entryId: string): Promise<NmeaArchiveRecord | null> {
const record = await db.nmeaArchives.get(entryId)
if (!record) return null
const masterKey = await getLogbookKey(record.logbookId) || getActiveMasterKey()
if (!masterKey) throw new Error('Encryption key not found. Please log in.')
try {
return normalizeArchiveRecord(
await decryptJson(record.encryptedData, record.iv, record.tag, masterKey) as Partial<NmeaArchiveRecord>
)
} catch {
return null
}
}
export function isNmeaCrcAlreadyImported(record: NmeaArchiveRecord | null, rawText: string): boolean {
if (!record) return false
const crc32 = nmeaFileCrc32(rawText)
return record.importedFiles.some((file) => file.crc32 === crc32)
}
/** Remember imported file by CRC (even when raw log is discarded). */
export async function recordNmeaFileImport(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<string> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename: existing?.filename ?? '',
rawText: existing?.rawText ?? '',
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
return crc32
}
export async function saveNmeaArchive(
logbookId: string,
entryId: string,
filename: string,
rawText: string
): Promise<void> {
const crc32 = nmeaFileCrc32(rawText)
const existing = await getNmeaArchive(entryId)
const importedFiles = [...(existing?.importedFiles ?? [])]
if (!importedFiles.some((file) => file.crc32 === crc32)) {
importedFiles.push({
crc32,
filename,
importedAt: new Date().toISOString()
})
}
const payload: NmeaArchiveRecord = {
filename,
rawText,
importedAt: new Date().toISOString(),
importedFiles
}
await putNmeaArchiveRecord(logbookId, entryId, payload)
}
export async function deleteNmeaArchive(entryId: string): Promise<void> {
await db.nmeaArchives.delete(entryId)
}
export function downloadNmeaArchive(record: NmeaArchiveRecord): void {
const blob = new Blob([record.rawText], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = record.filename || 'track.nmea'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { crc32Hex, nmeaFileCrc32, normalizeNmeaTextForCrc } from './crc32.js'
describe('crc32', () => {
it('hashes known test vectors', () => {
expect(crc32Hex('')).toBe('00000000')
expect(crc32Hex('123456789')).toBe('CBF43926')
})
it('normalizes line endings before hashing NMEA content', () => {
const a = nmeaFileCrc32('$GPRMC,123519,A\r\n$GPGGA,123519\r\n')
const b = nmeaFileCrc32('$GPRMC,123519,A\n$GPGGA,123519\n')
expect(a).toBe(b)
expect(normalizeNmeaTextForCrc('a\r\nb\r')).toBe('a\nb')
})
})
+30
View File
@@ -0,0 +1,30 @@
/** Normalize NMEA text so identical content hashes the same across platforms. */
export function normalizeNmeaTextForCrc(text: string): string {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trimEnd()
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256)
for (let i = 0; i < 256; i++) {
let c = i
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[i] = c >>> 0
}
return table
})()
/** CRC-32 (IEEE / Ethernet polynomial), uppercase 8-char hex. */
export function crc32Hex(text: string): string {
const bytes = new TextEncoder().encode(text)
let crc = 0xffffffff
for (const byte of bytes) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
}
return ((crc ^ 0xffffffff) >>> 0).toString(16).toUpperCase().padStart(8, '0')
}
export function nmeaFileCrc32(text: string): string {
return crc32Hex(normalizeNmeaTextForCrc(text))
}
+1 -1
View File
@@ -1,6 +1,6 @@
# NMEA-Import — Recherche & Entscheidungsnotizen # NMEA-Import — Recherche & Entscheidungsnotizen
Stand: 2026-05-31 · Status: **Backlog / später prüfen** Stand: 2026-05-31 · Status: **In Umsetzung** (`feature/nmea-journal-import`)
Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei. Anlass: Nutzeranfrage, ob Kapteins Daagbok um NMEA-Empfang erweiterbar sei.
+2
View File
@@ -21,6 +21,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — | | Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — |
| Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` | | Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` |
| GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — | | GPS Track Uploaded | GPX/KML/GeoJSON hochgeladen (`LogEntryEditor.tsx`) | — |
| NMEA Imported | NMEA-Protokoll in Journal übernommen (`NmeaImportWizard.tsx`) | `mode`: `interval` \| `change` \| `both`, `events`, `track` (Anzahlen/Flags, keine Koordinaten) |
| Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — | | Vessel Saved | Schiffsdaten gespeichert (`VesselForm.tsx`) | — |
| Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` | | Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` |
| Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — | | Account Deleted | Konto erfolgreich gelöscht (`auth.ts`) | — |
@@ -72,6 +73,7 @@ Empfohlene Goal-Ketten für Auswertung (nur Business!):
6. **Datensicherung:** Backup Exported → Backup Restored 6. **Datensicherung:** Backup Exported → Backup Restored
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig) 7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback) 8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
9. **NMEA-Import:** NMEA Imported (Modus, Anzahl übernommener Ereignisse, optional Track)
## Entwicklung ## Entwicklung
+418
View File
@@ -0,0 +1,418 @@
; Kapteins Daagbok Test-NMEA — Kieler Förde Kiellinie → Laboe, 5 sm
; Datum: 2026-05-29, passend zu testdata/tracks/kieler-foerde-5sm.gpx
; Import-Tipp: Reisetag-Datum auf 2026-05-29 setzen
; Sätze: RMC, GGA, VTG, HDT, MWV, DPT, MDA, RPM (Motorphase), MTW, VLW
$GPRMC,101500.00,A,5419.7280,N,01008.7360,E,2.5,42.2,290526,,*00
$GPGGA,101500.00,5419.7280,N,01008.7360,E,1,08,1.0,12.5,M,46.0,M,,*5B
$GPVTG,42.2,T,,M,2.5,N,4.6,K*51
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.5,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101637.00,A,5419.7815,N,01008.8193,E,2.9,42.2,290526,,*0C
$GPGGA,101637.00,5419.7815,N,01008.8193,E,1,08,1.0,12.4,M,46.0,M,,*5A
$GPVTG,42.2,T,,M,2.9,N,5.3,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.4,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,101829.00,A,5419.8529,N,01008.9305,E,3.3,42.2,290526,,*07
$GPGGA,101829.00,5419.8529,N,01008.9305,E,1,08,1.0,12.3,M,46.0,M,,*5D
$GPVTG,42.2,T,,M,3.3,N,6.2,K*50
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.3,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102006.00,A,5419.9242,N,01009.0416,E,3.8,42.2,290526,,*0C
$GPGGA,102006.00,5419.9242,N,01009.0416,E,1,08,1.0,12.2,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,3.8,N,7.1,K*59
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.2,0.0*66
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.00,N,0.00,N,,*4D
$GPRMC,102152.00,A,5420.0134,N,01009.1806,E,4.4,42.2,290526,,*0A
$GPGGA,102152.00,5420.0134,N,01009.1806,E,1,08,1.0,12.1,M,46.0,M,,*52
$GPVTG,42.2,T,,M,4.4,N,8.2,K*5E
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.1,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102329.00,A,5420.1204,N,01009.3473,E,5.9,42.2,290526,,*05
$GPGGA,102329.00,5420.1204,N,01009.3473,E,1,08,1.0,12.0,M,46.0,M,,*50
$GPVTG,42.2,T,,M,5.9,N,10.9,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,12.0,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102503.00,A,5420.2274,N,01009.5141,E,4.9,42.2,290526,,*0C
$GPGGA,102503.00,5420.2274,N,01009.5141,E,1,08,1.0,11.8,M,46.0,M,,*53
$GPVTG,42.2,T,,M,4.9,N,9.2,K*52
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.8,0.0*6F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102637.00,A,5420.3167,N,01009.6530,E,4.6,42.2,290526,,*06
$GPGGA,102637.00,5420.3167,N,01009.6530,E,1,08,1.0,11.7,M,46.0,M,,*59
$GPVTG,42.2,T,,M,4.6,N,8.5,K*5B
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.7,0.0*60
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.01,N,0.01,N,,*4D
$GPRMC,102818.00,A,5420.4236,N,01009.8197,E,5.8,42.2,290526,,*0D
$GPGGA,102818.00,5420.4236,N,01009.8197,E,1,08,1.0,11.6,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.8,N,10.8,K*60
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.6,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,102949.00,A,5420.5307,N,01009.9865,E,5.2,42.2,290526,,*05
$GPGGA,102949.00,5420.5307,N,01009.9865,E,1,08,1.0,11.4,M,46.0,M,,*5C
$GPVTG,42.2,T,,M,5.2,N,9.6,K*5C
$HEHDT,42.2,T*1B
$IIMWV,240.0,R,12.0,N,A*08
$SDDPT,11.4,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103111.00,A,5420.6100,N,01010.1100,E,4.5,32.8,290526,,*06
$GPGGA,103111.00,5420.6100,N,01010.1100,E,1,08,1.0,11.3,M,46.0,M,,*53
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.3,0.0*64
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103255.00,A,5420.7314,N,01010.2446,E,5.7,32.8,290526,,*04
$GPGGA,103255.00,5420.7314,N,01010.2446,E,1,08,1.0,11.2,M,46.0,M,,*53
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.2,0.0*65
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103425.00,A,5420.8529,N,01010.3792,E,5.4,32.8,290526,,*0A
$GPGGA,103425.00,5420.8529,N,01010.3792,E,1,08,1.0,11.0,M,46.0,M,,*5C
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,11.0,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.02,N,0.02,N,,*4D
$GPRMC,103614.00,A,5420.9744,N,01010.5137,E,4.5,32.8,290526,,*0D
$GPGGA,103614.00,5420.9744,N,01010.5137,E,1,08,1.0,10.8,M,46.0,M,,*52
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.8,0.0*6E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103758.00,A,5421.0958,N,01010.6483,E,5.7,32.8,290526,,*05
$GPGGA,103758.00,5421.0958,N,01010.6483,E,1,08,1.0,10.7,M,46.0,M,,*56
$GPVTG,32.8,T,,M,5.7,N,10.5,K*6F
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.7,0.0*61
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,103929.00,A,5421.2173,N,01010.7829,E,5.4,32.8,290526,,*00
$GPGGA,103929.00,5421.2173,N,01010.7829,E,1,08,1.0,10.5,M,46.0,M,,*52
$GPVTG,32.8,T,,M,5.4,N,10.0,K*69
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.5,0.0*63
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$GPRMC,104117.00,A,5421.3387,N,01010.9175,E,4.5,32.8,290526,,*04
$GPGGA,104117.00,5421.3387,N,01010.9175,E,1,08,1.0,10.4,M,46.0,M,,*57
$GPVTG,32.8,T,,M,4.5,N,8.4,K*54
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.4,0.0*62
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.03,N,0.03,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104257.00,A,5421.5006,N,01011.0969,E,7.8,32.8,290526,,*0C
$GPGGA,104257.00,5421.5006,N,01011.0969,E,1,08,1.0,10.1,M,46.0,M,,*54
$GPVTG,32.8,T,,M,7.8,N,14.4,K*67
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,10.1,0.0*67
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104437.00,A,5421.6626,N,01011.2764,E,4.6,32.8,290526,,*07
$GPGGA,104437.00,5421.6626,N,01011.2764,E,1,08,1.0,9.9,M,46.0,M,,*62
$GPVTG,32.8,T,,M,4.6,N,8.5,K*56
$HEHDT,32.8,T*16
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.9,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$IERPM,E,0,1850,37.0*20
$GPRMC,104607.00,A,5421.7616,N,01011.3816,E,5.0,30.2,290526,,*00
$GPGGA,104607.00,5421.7616,N,01011.3816,E,1,08,1.0,9.8,M,46.0,M,,*6B
$GPVTG,30.2,T,,M,5.0,N,9.3,K*5E
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.8,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.04,N,0.04,N,,*4D
$GPRMC,104740.00,A,5421.8866,N,01011.5066,E,5.9,30.2,290526,,*04
$GPGGA,104740.00,5421.8866,N,01011.5066,E,1,08,1.0,9.6,M,46.0,M,,*68
$GPVTG,30.2,T,,M,5.9,N,10.9,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.6,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,104918.00,A,5422.0115,N,01011.6315,E,4.7,30.2,290526,,*0A
$GPGGA,104918.00,5422.0115,N,01011.6315,E,1,08,1.0,9.5,M,46.0,M,,*6A
$GPVTG,30.2,T,,M,4.7,N,8.7,K*5D
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.5,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105049.00,A,5422.1364,N,01011.7564,E,6.9,30.2,290526,,*0E
$GPGGA,105049.00,5422.1364,N,01011.7564,E,1,08,1.0,9.3,M,46.0,M,,*64
$GPVTG,30.2,T,,M,6.9,N,12.8,K*65
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.3,0.0*5D
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105233.00,A,5422.3030,N,01011.9230,E,5.6,30.2,290526,,*05
$GPGGA,105233.00,5422.3030,N,01011.9230,E,1,08,1.0,9.1,M,46.0,M,,*61
$GPVTG,30.2,T,,M,5.6,N,10.3,K*60
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,9.1,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.05,N,0.05,N,,*4D
$GPRMC,105419.00,A,5422.4279,N,01012.0479,E,4.5,30.2,290526,,*00
$GPGGA,105419.00,5422.4279,N,01012.0479,E,1,08,1.0,8.9,M,46.0,M,,*6F
$GPVTG,30.2,T,,M,4.5,N,8.3,K*5B
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.9,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105550.00,A,5422.5320,N,01012.1520,E,5.3,30.2,290526,,*0B
$GPGGA,105550.00,5422.5320,N,01012.1520,E,1,08,1.0,8.8,M,46.0,M,,*62
$GPVTG,30.2,T,,M,5.3,N,9.8,K*56
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.8,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105721.00,A,5422.6570,N,01012.2770,E,5.8,30.2,290526,,*00
$GPGGA,105721.00,5422.6570,N,01012.2770,E,1,08,1.0,8.6,M,46.0,M,,*6C
$GPVTG,30.2,T,,M,5.8,N,10.7,K*6A
$HEHDT,30.2,T*1E
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.6,0.0*59
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,105856.00,A,5422.7731,N,01012.3907,E,4.5,29.3,290526,,*03
$GPGGA,105856.00,5422.7731,N,01012.3907,E,1,08,1.0,8.4,M,46.0,M,,*68
$GPVTG,29.3,T,,M,4.5,N,8.4,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.4,0.0*5B
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110035.00,A,5422.8992,N,01012.5122,E,5.1,29.3,290526,,*0E
$GPGGA,110035.00,5422.8992,N,01012.5122,E,1,08,1.0,8.3,M,46.0,M,,*67
$GPVTG,29.3,T,,M,5.1,N,9.5,K*50
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.3,0.0*5C
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.06,N,0.06,N,,*4D
$GPRMC,110220.00,A,5423.0252,N,01012.6336,E,4.7,29.3,290526,,*05
$GPGGA,110220.00,5423.0252,N,01012.6336,E,1,08,1.0,8.1,M,46.0,M,,*69
$GPVTG,29.3,T,,M,4.7,N,8.8,K*5B
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.1,0.0*5E
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110355.00,A,5423.1304,N,01012.7348,E,4.4,29.3,290526,,*0E
$GPGGA,110355.00,5423.1304,N,01012.7348,E,1,08,1.0,8.0,M,46.0,M,,*60
$GPVTG,29.3,T,,M,4.4,N,8.2,K*52
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,8.0,0.0*5F
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110537.00,A,5423.2354,N,01012.8360,E,4.1,29.3,290526,,*0A
$GPGGA,110537.00,5423.2354,N,01012.8360,E,1,08,1.0,7.8,M,46.0,M,,*66
$GPVTG,29.3,T,,M,4.1,N,7.5,K*5F
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.8,0.0*58
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110728.00,A,5423.3405,N,01012.9372,E,3.7,29.3,290526,,*07
$GPGGA,110728.00,5423.3405,N,01012.9372,E,1,08,1.0,7.7,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.7,N,6.9,K*53
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.7,0.0*57
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,110905.00,A,5423.4246,N,01013.0181,E,3.5,29.3,290526,,*04
$GPGGA,110905.00,5423.4246,N,01013.0181,E,1,08,1.0,7.6,M,46.0,M,,*65
$GPVTG,29.3,T,,M,3.5,N,6.4,K*5C
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.6,0.0*56
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.07,N,0.07,N,,*4D
$GPRMC,111049.00,A,5423.5087,N,01013.0991,E,3.2,29.3,290526,,*04
$GPGGA,111049.00,5423.5087,N,01013.0991,E,1,08,1.0,7.5,M,46.0,M,,*61
$GPVTG,29.3,T,,M,3.2,N,5.9,K*55
$HEHDT,29.3,T*17
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.5,0.0*55
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111229.00,A,5423.5828,N,01013.1716,E,2.9,29.7,290526,,*03
$GPGGA,111229.00,5423.5828,N,01013.1716,E,1,08,1.0,7.4,M,46.0,M,,*69
$GPVTG,29.7,T,,M,2.9,N,5.4,K*56
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.4,0.0*54
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111401.00,A,5423.6455,N,01013.2332,E,2.7,29.7,290526,,*05
$GPGGA,111401.00,5423.6455,N,01013.2332,E,1,08,1.0,7.3,M,46.0,M,,*66
$GPVTG,29.7,T,,M,2.7,N,5.1,K*5D
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.3,0.0*53
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111540.00,A,5423.7083,N,01013.2947,E,2.5,29.7,290526,,*05
$GPGGA,111540.00,5423.7083,N,01013.2947,E,1,08,1.0,7.2,M,46.0,M,,*65
$GPVTG,29.7,T,,M,2.5,N,4.7,K*58
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.2,0.0*52
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111727.00,A,5423.7711,N,01013.3563,E,2.3,29.7,290526,,*07
$GPGGA,111727.00,5423.7711,N,01013.3563,E,1,08,1.0,7.1,M,46.0,M,,*62
$GPVTG,29.7,T,,M,2.3,N,4.3,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.1,0.0*51
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,111924.00,A,5423.8339,N,01013.4179,E,2.1,29.7,290526,,*01
$GPGGA,111924.00,5423.8339,N,01013.4179,E,1,08,1.0,7.0,M,46.0,M,,*67
$GPVTG,29.7,T,,M,2.1,N,4.0,K*5B
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D
$GPRMC,112048.00,A,5423.8757,N,01013.4590,E,2.0,29.7,290526,,*0F
$GPGGA,112048.00,5423.8757,N,01013.4590,E,1,08,1.0,7.0,M,46.0,M,,*68
$GPVTG,29.7,T,,M,2.0,N,3.7,K*5A
$HEHDT,29.7,T*13
$IIMWV,280.0,R,12.0,N,A*04
$SDDPT,7.0,0.0*50
$WIMDA,,I,30.0674,B,1018.2,C,,,,,,,,,,,,,,*22
$YXMTW,14.2,C*15
$IIVLW,0.08,N,0.08,N,,*4D