diff --git a/client/src/App.css b/client/src/App.css index 11c1729..25df6e8 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -611,6 +611,7 @@ html.scheme-dark .themed-select-option.is-selected { width: 100%; max-width: 560px; max-height: min(90vh, 820px); + overflow-y: auto; } .feedback-modal { @@ -662,6 +663,182 @@ html.scheme-dark .themed-select-option.is-selected { 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 { display: flex; flex-direction: column; diff --git a/client/src/components/LogEntryEditor.tsx b/client/src/components/LogEntryEditor.tsx index 2ace57d..6e45f31 100644 --- a/client/src/components/LogEntryEditor.tsx +++ b/client/src/components/LogEntryEditor.tsx @@ -37,8 +37,16 @@ import { deleteTrack, downloadTrackFile, parseTrackFile, - type SavedTrack + type SavedTrack, + type TrackWaypoint } 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 { computeFuelPerMotorHour, formatFuelPerMotorHour } from '../utils/fuelStats.js' import { useRegisterUnsavedChanges } from '../context/UnsavedChangesContext.tsx' @@ -210,6 +218,8 @@ export default function LogEntryEditor({ const [savedTrack, setSavedTrack] = useState(null) const [dragOver, setDragOver] = useState(false) const [uploadError, setUploadError] = useState(null) + const [nmeaWizardOpen, setNmeaWizardOpen] = useState(false) + const [nmeaArchive, setNmeaArchive] = useState(null) const fileInputRef = useRef(null) const lockedContentHashRef = useRef(null) const contentReadyRef = useRef(false) @@ -762,6 +772,45 @@ export default function LogEntryEditor({ loadTrack() }, [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) => ` `) + .join('\n') + const content = `\n${gpxLike}\n` + 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(() => { if (!savedTrack || savedTrack.waypoints.length < 2) return if (trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) return @@ -1925,6 +1974,31 @@ export default function LogEntryEditor({ )} + {!readOnly && ( +
+ + {nmeaArchive && ( +
+ {t('logs.nmea_archive_stored', { name: nmeaArchive.filename })} + + +
+ )} +
+ )} + {(savedTrack || trackDistanceNm || trackSpeedMaxKn || trackSpeedAvgKn) && (
@@ -2030,6 +2104,19 @@ export default function LogEntryEditor({
)} + + { + setNmeaWizardOpen(false) + void loadNmeaArchive() + }} + logbookId={logbookId} + entryId={entryId} + entryDate={date} + nmeaArchive={nmeaArchive} + onImport={handleNmeaImport} + />
) } diff --git a/client/src/components/NmeaImportWizard.tsx b/client/src/components/NmeaImportWizard.tsx new file mode 100644 index 0000000..583b42b --- /dev/null +++ b/client/src/components/NmeaImportWizard.tsx @@ -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('config') + const [parseResult, setParseResult] = useState(null) + const [mode, setMode] = useState('both') + const [intervalMinutes, setIntervalMinutes] = useState(60) + const [importTrack, setImportTrack] = useState(true) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [error, setError] = useState(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( +
+
e.stopPropagation()}> +
+ + +
+ +

{t('logs.nmea_import_title')}

+
+ + {error &&
{error}
} + + {duplicateFile && ( +
+ {t('logs.nmea_warn_duplicate_file')} +
+ )} + + {step === 'config' && ( + <> +

{t('logs.nmea_import_intro')}

+ + + {parseResult && ( +
+

{t('logs.nmea_stats', { + lines: parseResult.stats.parsedLines, + types: parseResult.stats.sentenceTypes.join(', ') + })}

+ {parseResult.warnings.includes('no_position') && ( +

{t('logs.nmea_warn_no_position')}

+ )} +
+ )} + +
+ {t('logs.nmea_mode_label')} + + + +
+ + {(mode === 'interval' || mode === 'both') && ( + + )} + + + +
+ + +
+ + )} + + {step === 'preview' && ( + <> +

{t('logs.nmea_preview_hint', { count: candidates.length })}

+
+ + +
+
+ {candidates.map((c) => ( + + ))} +
+
+ + +
+ + )} + + {step === 'archive' && ( + <> +

{t('logs.nmea_archive_question')}

+
+ + +
+ + )} +
+
+
, + document.body + ) +} diff --git a/client/src/i18n/locales/da.json b/client/src/i18n/locales/da.json index 6fc6476..4696886 100644 --- a/client/src/i18n/locales/da.json +++ b/client/src/i18n/locales/da.json @@ -283,7 +283,57 @@ "revoke": "Fjerne", "revoke_confirm": "Er du sikker på, at du vil tilbagekalde dette besætningsmedlems adgang?", "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": { "title": "Dine logbøger", diff --git a/client/src/i18n/locales/de.json b/client/src/i18n/locales/de.json index 3dd5ce6..7d750a0 100644 --- a/client/src/i18n/locales/de.json +++ b/client/src/i18n/locales/de.json @@ -273,6 +273,56 @@ "track_map_end": "Ziel", "track_map_speed_slow": "langsam", "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.", "exporting": "Exportiere...", "share_unsupported": "Teilen wird auf diesem Gerät nicht unterstützt. Datei wurde stattdessen heruntergeladen.", diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index aa3ff79..62296d2 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -273,6 +273,56 @@ "track_map_end": "End", "track_map_speed_slow": "slow", "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.", "exporting": "Exporting...", "share_unsupported": "Web sharing is not supported on this device. File downloaded instead.", diff --git a/client/src/i18n/locales/nb.json b/client/src/i18n/locales/nb.json index b2cf09c..d1245ce 100644 --- a/client/src/i18n/locales/nb.json +++ b/client/src/i18n/locales/nb.json @@ -283,7 +283,57 @@ "revoke": "Fjern", "revoke_confirm": "Er du sikker på at du vil oppheve dette besetningsmedlemmets tilgang?", "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": { "title": "Loggbøkene dine", diff --git a/client/src/i18n/locales/sv.json b/client/src/i18n/locales/sv.json index 4c3505a..4d86b8d 100644 --- a/client/src/i18n/locales/sv.json +++ b/client/src/i18n/locales/sv.json @@ -283,7 +283,57 @@ "revoke": "Ta bort", "revoke_confirm": "Är du säker på att du vill återkalla den här besättningsmedlemmens åtkomst?", "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": { "title": "Dina loggböcker", diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index d8a23c0..a055bdc 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -35,7 +35,8 @@ export const PlausibleEvents = { LOCAL_PIN_REMOVED: 'Local PIN Removed', DEVICE_FORGOTTEN: 'Device Forgotten', RECOVERY_ROTATED: 'Recovery Rotated', - LANGUAGE_CHANGED: 'Language Changed' + LANGUAGE_CHANGED: 'Language Changed', + NMEA_IMPORTED: 'NMEA Imported' } as const export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents] diff --git a/client/src/services/db.ts b/client/src/services/db.ts index 46fb94f..8ef4dcc 100644 --- a/client/src/services/db.ts +++ b/client/src/services/db.ts @@ -64,6 +64,15 @@ export interface LocalGpsTrack { updatedAt: string } +export interface LocalNmeaArchive { + entryId: string + logbookId: string + encryptedData: string + iv: string + tag: string + updatedAt: string +} + export interface LocalLogbookKey { logbookId: string encryptedKey: string @@ -89,6 +98,7 @@ class DaagboxDatabase extends Dexie { entries!: Table photos!: Table gpsTracks!: Table + nmeaArchives!: Table logbookKeys!: Table syncQueue!: Table @@ -145,6 +155,18 @@ class DaagboxDatabase extends Dexie { gpsTracks: 'entryId, logbookId, updatedAt', 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' + }) } } diff --git a/client/src/services/nmea/kielerFoerdeTestdata.test.ts b/client/src/services/nmea/kielerFoerdeTestdata.test.ts new file mode 100644 index 0000000..5e5ac28 --- /dev/null +++ b/client/src/services/nmea/kielerFoerdeTestdata.test.ts @@ -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) + }) +}) diff --git a/client/src/services/nmea/nmeaChangeDetection.test.ts b/client/src/services/nmea/nmeaChangeDetection.test.ts new file mode 100644 index 0000000..3504d98 --- /dev/null +++ b/client/src/services/nmea/nmeaChangeDetection.test.ts @@ -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 { + 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) + }) +}) diff --git a/client/src/services/nmea/nmeaChangeDetection.ts b/client/src/services/nmea/nmeaChangeDetection.ts new file mode 100644 index 0000000..ce7937d --- /dev/null +++ b/client/src/services/nmea/nmeaChangeDetection.ts @@ -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) +} diff --git a/client/src/services/nmea/nmeaJournalGenerator.ts b/client/src/services/nmea/nmeaJournalGenerator.ts new file mode 100644 index 0000000..4437ded --- /dev/null +++ b/client/src/services/nmea/nmeaJournalGenerator.ts @@ -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 +} + +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, + windowMs: number +): Array { + 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 = [] + + 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 } +} diff --git a/client/src/services/nmea/nmeaParse.test.ts b/client/src/services/nmea/nmeaParse.test.ts new file mode 100644 index 0000000..0f61459 --- /dev/null +++ b/client/src/services/nmea/nmeaParse.test.ts @@ -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) + }) +}) diff --git a/client/src/services/nmea/nmeaParse.ts b/client/src/services/nmea/nmeaParse.ts new file mode 100644 index 0000000..df38fd6 --- /dev/null +++ b/client/src/services/nmea/nmeaParse.ts @@ -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() + 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 + })) +} diff --git a/client/src/services/nmea/nmeaTimeSeries.ts b/client/src/services/nmea/nmeaTimeSeries.ts new file mode 100644 index 0000000..986508a --- /dev/null +++ b/client/src/services/nmea/nmeaTimeSeries.ts @@ -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 +} diff --git a/client/src/services/nmea/nmeaTypes.ts b/client/src/services/nmea/nmeaTypes.ts new file mode 100644 index 0000000..06662fe --- /dev/null +++ b/client/src/services/nmea/nmeaTypes.ts @@ -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 + data?: Partial +} + +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 +} diff --git a/client/src/services/nmeaArchive.test.ts b/client/src/services/nmeaArchive.test.ts new file mode 100644 index 0000000..1c34912 --- /dev/null +++ b/client/src/services/nmeaArchive.test.ts @@ -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) + }) +}) diff --git a/client/src/services/nmeaArchive.ts b/client/src/services/nmeaArchive.ts new file mode 100644 index 0000000..f03e8f9 --- /dev/null +++ b/client/src/services/nmeaArchive.ts @@ -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 { + 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 { + 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 { + 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 + ) + } 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 { + 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 { + 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 { + 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) +} diff --git a/client/src/utils/crc32.test.ts b/client/src/utils/crc32.test.ts new file mode 100644 index 0000000..af0a1be --- /dev/null +++ b/client/src/utils/crc32.test.ts @@ -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') + }) +}) diff --git a/client/src/utils/crc32.ts b/client/src/utils/crc32.ts new file mode 100644 index 0000000..9141e74 --- /dev/null +++ b/client/src/utils/crc32.ts @@ -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)) +} diff --git a/docs/nmea-import-notes.md b/docs/nmea-import-notes.md index 020c246..0baea5d 100644 --- a/docs/nmea-import-notes.md +++ b/docs/nmea-import-notes.md @@ -1,6 +1,6 @@ # 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. diff --git a/docs/plausible-events.md b/docs/plausible-events.md index 099946b..597cd78 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -21,6 +21,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri | Travel Day Saved | Reisetag gespeichert (`LogEntryEditor.tsx`) | — | | Entry Signed | Passkey-Signatur Skipper oder Crew (`LogEntryEditor.tsx`) | `role`: `skipper` \| `crew` | | 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`) | — | | Crew Saved | Skipper- oder Crew-Profil gespeichert (`CrewForm.tsx`) | `role`: `skipper` \| `crew`, `action`: `create` \| `update` | | 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 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) +9. **NMEA-Import:** NMEA Imported (Modus, Anzahl übernommener Ereignisse, optional Track) ## Entwicklung diff --git a/testdata/tracks/kieler-foerde-5sm.nmea b/testdata/tracks/kieler-foerde-5sm.nmea new file mode 100644 index 0000000..4d4a17a --- /dev/null +++ b/testdata/tracks/kieler-foerde-5sm.nmea @@ -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 +