diff --git a/package.json b/package.json index d1bb290..297d1e8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "format": "eslint --fix && prettier --write .", "check": "eslint && prettier --check . && npm run lint --workspaces --if-present", "prepare": "husky", - "start": "npm run start -w crowd-depth-api" + "start": "npm run start -w crowd-depth-api", + "crowd-depth-import": "tsx packages/signalk-plugin/src/importer/cli.ts" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/packages/signalk-plugin/README.md b/packages/signalk-plugin/README.md index ab808b9..5679030 100644 --- a/packages/signalk-plugin/README.md +++ b/packages/signalk-plugin/README.md @@ -45,3 +45,63 @@ From the root of the monorepo: - The plugin corrects depth positions using configured sensor offsets and marks data as unprocessed for tides/vertical datums. - Includes a helper CLI `xyz-to-geojson` (installed with the package) for converting XYZ files to GeoJSON. + +## Importing Raymarine GPX tracks + +The `crowd-depth-import` CLI imports Raymarine GPX trackpoints whose extensions +contain `WaterDepth`. It uses the same `BathymetryData`, GeoJSON metadata, +precision transform, identity, and upload reporter as the Signal K plugin. +Namespace prefixes are ignored, so both `raymarine:WaterDepth` and an equivalent +prefix work. + +From a development checkout: + +```sh +npm run crowd-depth-import -- ./track.gpx \ + --depth-reference belowTransducer \ + --transducer-depth 0.45 \ + --started-at 2025-07-12T08:30:00+02:00 \ + --interval 1s \ + --dry-run +``` + +Installed packages also expose the `crowd-depth-import` executable. Preview is +the default, and **nothing is uploaded unless `--upload` is explicitly passed**. +`--dry-run` merely makes that intention explicit. Use `--out result.geojson` to +write the generated GeoJSON locally. + +### Required time information + +Times embedded in GPX trackpoints are preserved. If any imported point lacks a +time, both `--started-at` (an ISO timestamp with offset) and `--interval` (for +example `500ms`, `1s`, or `2m`) are mandatory. Synthetic times follow file +order. The importer never silently invents timestamps. + +### Required depth reference + +`--depth-reference` is always mandatory: + +- `belowWaterline`: `WaterDepth` already measures from the waterline and is kept unchanged. +- `belowTransducer`: also requires `--transducer-depth`; that positive waterline-to-transducer offset is added. +- `belowKeel`: also requires `--draft`; that positive waterline-to-keel offset is added. + +All output is therefore waterline-referenced, matching the existing reporter's +metadata. No tidal or vertical-datum correction is performed. Verify the GPX +source setting and vessel offsets before upload; an incorrect reference cannot +be recovered from the file itself. + +### Upload identity and duplicate protection + +Upload reuses the existing identity JSON (`uuid` and `token`). The default path +is `~/.signalk/plugin-data/crowd-depth/identity.json`; override it with +`--identity-file`. Override the normal plugin endpoint with `--api-base-url`. + +After a successful upload, a SHA-256 key covering the file and conversion +options is recorded in `.crowd-depth-import-ledger.json`. Repeating the same +upload is rejected. Use `--ledger-file` to place the ledger elsewhere. Preview +and `--out` do not modify the ledger. + +Optional controls include `--dedupe-distance-meters` (off by default) and +`--max-points` for probe imports. Dedupe only compares consecutive positions. +`WaterTemp` is currently ignored. Missing heading is valid, so GNSS/sounder +position correction is not applied to GPX imports. diff --git a/packages/signalk-plugin/bin/crowd-depth-import b/packages/signalk-plugin/bin/crowd-depth-import new file mode 100755 index 0000000..a995e97 --- /dev/null +++ b/packages/signalk-plugin/bin/crowd-depth-import @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../dist/importer/cli.js"; diff --git a/packages/signalk-plugin/package.json b/packages/signalk-plugin/package.json index aadebed..88464b2 100644 --- a/packages/signalk-plugin/package.json +++ b/packages/signalk-plugin/package.json @@ -25,7 +25,8 @@ }, "types": "./dist/index.d.ts", "bin": { - "xyz-to-geojson": "bin/xyz-to-geojson" + "xyz-to-geojson": "bin/xyz-to-geojson", + "crowd-depth-import": "bin/crowd-depth-import" }, "files": [ "dist", @@ -51,6 +52,7 @@ "cron": "^4.3.4", "csv-parse": "^5.6.0", "debug": "^4.4.3", + "fast-xml-parser": "^5.3.1", "geojson": "^0.5.0", "geolib": "^3.3.4", "json-stream-stringify": "^3.1.6", diff --git a/packages/signalk-plugin/src/importer/cli.ts b/packages/signalk-plugin/src/importer/cli.ts new file mode 100644 index 0000000..d6ee35c --- /dev/null +++ b/packages/signalk-plugin/src/importer/cli.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +import { formatSummary, parseArgs, runImport } from "./index.js"; + +try { + const options = parseArgs(process.argv.slice(2)); + const { summary, submission } = await runImport(options); + console.log(formatSummary(summary)); + if (options.out) console.log(`GeoJSON written: ${options.out}`); + if (submission) console.log(`Upload complete: ${submission.message}`); + else console.log("Preview only; no data uploaded. Pass --upload to upload."); +} catch (error) { + console.error(`crowd-depth-import: ${(error as Error).message}`); + process.exitCode = 1; +} diff --git a/packages/signalk-plugin/src/importer/convert.ts b/packages/signalk-plugin/src/importer/convert.ts new file mode 100644 index 0000000..8b43e50 --- /dev/null +++ b/packages/signalk-plugin/src/importer/convert.ts @@ -0,0 +1,145 @@ +import { Temporal } from "@js-temporal/polyfill"; +import type { BathymetryData } from "../types.js"; +import type { ParsedGpx } from "./gpx.js"; + +export const DepthReferences = [ + "belowWaterline", + "belowTransducer", + "belowKeel", +] as const; +export type DepthReference = (typeof DepthReferences)[number]; + +export type ConversionOptions = { + startedAt?: Temporal.Instant; + interval?: Temporal.Duration; + depthReference: DepthReference; + transducerDepth?: number; + draft?: number; + maxPoints?: number; + dedupeDistanceMeters?: number; +}; + +export type ConvertedGpx = { + data: BathymetryData[]; + timestampSource: "gpx" | "cli" | "mixed"; + deduplicatedPoints: number; +}; + +export function convertGpx( + parsed: ParsedGpx, + options: ConversionOptions, +): ConvertedGpx { + validateDepthReference(options); + const hasTimestamps = parsed.points.map( + (point) => point.timestamp !== undefined, + ); + const missingTimestamps = hasTimestamps.some((present) => !present); + if (missingTimestamps && (!options.startedAt || !options.interval)) { + throw new Error( + "GPX trackpoints are missing timestamps; provide both --started-at and --interval", + ); + } + if (options.interval && options.interval.total("milliseconds") <= 0) + throw new Error("--interval must be greater than zero"); + + let data = parsed.points.map((point, index): BathymetryData => ({ + latitude: point.latitude, + longitude: point.longitude, + depth: toWaterlineDepth(point.depth, options), + timestamp: + point.timestamp ?? + options.startedAt!.add({ + milliseconds: options.interval!.total("milliseconds") * index, + }), + })); + + const beforeDedupe = data.length; + if (options.dedupeDistanceMeters !== undefined) { + if (options.dedupeDistanceMeters < 0) + throw new Error("--dedupe-distance-meters cannot be negative"); + data = dedupeByDistance(data, options.dedupeDistanceMeters); + } + const deduplicatedPoints = beforeDedupe - data.length; + if (options.maxPoints !== undefined) { + if (!Number.isInteger(options.maxPoints) || options.maxPoints <= 0) + throw new Error("--max-points must be a positive integer"); + data = data.slice(0, options.maxPoints); + } + + return { + data, + timestampSource: hasTimestamps.every(Boolean) + ? "gpx" + : hasTimestamps.some(Boolean) + ? "mixed" + : "cli", + deduplicatedPoints, + }; +} + +function validateDepthReference(options: ConversionOptions) { + if ( + options.depthReference === "belowTransducer" && + (options.transducerDepth === undefined || options.transducerDepth < 0) + ) + throw new Error( + "belowTransducer requires a non-negative --transducer-depth to convert depths to the waterline reference", + ); + if ( + options.depthReference === "belowKeel" && + (options.draft === undefined || options.draft < 0) + ) + throw new Error( + "belowKeel requires a non-negative --draft to convert depths to the waterline reference", + ); +} + +function toWaterlineDepth(depth: number, options: ConversionOptions) { + switch (options.depthReference) { + case "belowWaterline": + return depth; + case "belowTransducer": + return depth + options.transducerDepth!; + case "belowKeel": + return depth + options.draft!; + } +} + +function dedupeByDistance(points: BathymetryData[], meters: number) { + if (meters === 0) return points; + const result: BathymetryData[] = []; + for (const point of points) { + const previous = result.at(-1); + if (!previous || distanceMeters(previous, point) >= meters) + result.push(point); + } + return result; +} + +function distanceMeters(a: BathymetryData, b: BathymetryData) { + const radians = Math.PI / 180; + const dLat = (b.latitude - a.latitude) * radians; + const dLon = (b.longitude - a.longitude) * radians; + const lat1 = a.latitude * radians; + const lat2 = b.latitude * radians; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + return 6_371_000 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); +} + +export function parseInterval(value: string): Temporal.Duration { + const match = value.match(/^([0-9]+(?:\.[0-9]+)?)(ms|s|m|h)$/); + if (!match) + throw new Error( + `Invalid interval "${value}"; use e.g. 500ms, 1s, 2m, or 1h`, + ); + const milliseconds = + Number(match[1]) * + ({ ms: 1, s: 1_000, m: 60_000, h: 3_600_000 }[match[2]]!); + if (!Number.isInteger(milliseconds) || milliseconds <= 0) + throw new Error( + "--interval must resolve to a positive whole number of milliseconds", + ); + return Temporal.Duration.from({ milliseconds }); +} diff --git a/packages/signalk-plugin/src/importer/gpx.ts b/packages/signalk-plugin/src/importer/gpx.ts new file mode 100644 index 0000000..51cd792 --- /dev/null +++ b/packages/signalk-plugin/src/importer/gpx.ts @@ -0,0 +1,118 @@ +import { Temporal } from "@js-temporal/polyfill"; +import { XMLParser, XMLValidator } from "fast-xml-parser"; +import type { BathymetryData } from "../types.js"; + +export type ParsedGpxPoint = Omit & { + timestamp?: Temporal.Instant; +}; + +export type ParsedGpx = { + trackpoints: number; + pointsWithoutDepth: number; + points: ParsedGpxPoint[]; +}; + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: "@_", + removeNSPrefix: true, + isArray: (_name, path) => path.endsWith(".trkpt"), + parseTagValue: false, +}); + +export function parseRaymarineGpx(xml: string): ParsedGpx { + const validation = XMLValidator.validate(xml); + if (validation !== true) + throw new Error( + `Invalid GPX XML at line ${validation.err.line}, column ${validation.err.col}: ${validation.err.msg}`, + ); + + let document: unknown; + try { + document = parser.parse(xml); + } catch (cause) { + throw new Error("Invalid GPX XML", { cause }); + } + + const trackpoints = findValues(document, "trkpt"); + const points: ParsedGpxPoint[] = []; + let pointsWithoutDepth = 0; + + for (const raw of trackpoints) { + if (!isRecord(raw)) continue; + const latitude = finiteNumber(raw["@_lat"]); + const longitude = finiteNumber(raw["@_lon"]); + if ( + latitude === undefined || + longitude === undefined || + latitude < -90 || + latitude > 90 || + longitude < -180 || + longitude > 180 + ) { + throw new Error( + `Invalid latitude/longitude in trackpoint ${points.length + pointsWithoutDepth + 1}`, + ); + } + + const depthValue = findFirstValue(raw.extensions, "WaterDepth"); + const depth = finiteNumber(depthValue); + if (depth === undefined || depth < 0) { + pointsWithoutDepth += 1; + continue; + } + + const timeValue = findFirstValue(raw, "time"); + let timestamp: Temporal.Instant | undefined; + if (typeof timeValue === "string" && timeValue.trim()) { + try { + timestamp = Temporal.Instant.from(timeValue.trim()); + } catch (cause) { + throw new Error( + `Invalid GPX timestamp in trackpoint ${points.length + pointsWithoutDepth + 1}: ${timeValue}`, + { cause }, + ); + } + } + + points.push({ latitude, longitude, depth, timestamp }); + } + + if (points.length === 0) + throw new Error( + "The GPX file contains no trackpoints with WaterDepth measurements", + ); + + return { trackpoints: trackpoints.length, pointsWithoutDepth, points }; +} + +function findValues(value: unknown, localName: string): unknown[] { + if (Array.isArray(value)) + return value.flatMap((item) => findValues(item, localName)); + if (!isRecord(value)) return []; + return Object.entries(value).flatMap(([key, child]) => + key === localName + ? Array.isArray(child) + ? child + : [child] + : findValues(child, localName), + ); +} + +function findFirstValue(value: unknown, localName: string): unknown { + return findValues(value, localName)[0]; +} + +function finiteNumber(value: unknown): number | undefined { + const number = + typeof value === "number" + ? value + : typeof value === "string" + ? Number(value.trim()) + : NaN; + return Number.isFinite(number) ? number : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/signalk-plugin/src/importer/index.ts b/packages/signalk-plugin/src/importer/index.ts new file mode 100644 index 0000000..ea45a33 --- /dev/null +++ b/packages/signalk-plugin/src/importer/index.ts @@ -0,0 +1,283 @@ +import { createHash } from "crypto"; +import { readFile, writeFile } from "fs/promises"; +import { homedir } from "os"; +import { join, resolve } from "path"; +import { Readable } from "stream"; +import { text } from "stream/consumers"; +import { Temporal } from "@js-temporal/polyfill"; +import { BATHY_URL } from "../constants.js"; +import type { Config } from "../config.js"; +import type { VesselInfo } from "../metadata.js"; +import { createGeoJSON, submitGeoJSON } from "../reporters/noaa.js"; +import { + convertGpx, + DepthReferences, + parseInterval, + type ConversionOptions, + type DepthReference, +} from "./convert.js"; +import { parseRaymarineGpx } from "./gpx.js"; + +export type ImportOptions = ConversionOptions & { + file: string; + apiBaseUrl: string; + identityFile: string; + ledgerFile: string; + upload: boolean; + out?: string; +}; + +export type ImportSummary = { + file: string; + trackpoints: number; + pointsWithDepth: number; + pointsWithoutDepth: number; + importedPoints: number; + deduplicatedPoints: number; + from: string; + to: string; + minDepth: number; + maxDepth: number; + timestampSource: "gpx" | "cli" | "mixed"; + depthReference: "belowWaterline"; +}; + +export async function runImport(options: ImportOptions) { + const xml = await readFile(options.file, "utf8"); + const parsed = parseRaymarineGpx(xml); + const converted = convertGpx(parsed, options); + const summary = summarize( + options.file, + parsed.trackpoints, + parsed.pointsWithoutDepth, + converted, + ); + const vessel = options.upload + ? await readIdentity(options.identityFile) + : previewVessel; + const config = importerConfig(options); + + if (options.out) { + const geojson = await text( + createGeoJSON(config, vessel, Readable.from(converted.data)), + ); + await writeFile(options.out, geojson, "utf8"); + } + + let submission: Awaited> | undefined; + if (options.upload) { + const ledgerKey = createLedgerKey(xml, options); + const ledger = await readLedger(options.ledgerFile); + if (ledger[ledgerKey]) + throw new Error( + `This file and option set was already uploaded at ${ledger[ledgerKey]}; ledger: ${options.ledgerFile}`, + ); + submission = await submitGeoJSON( + options.apiBaseUrl, + config, + vessel, + Readable.from(converted.data), + ); + if (!submission.success) + throw new Error(`Upload was rejected: ${submission.message}`); + ledger[ledgerKey] = new Date().toISOString(); + await writeFile( + options.ledgerFile, + JSON.stringify(ledger, null, 2) + "\n", + "utf8", + ); + } + + return { summary, submission }; +} + +export function summarize( + file: string, + trackpoints: number, + pointsWithoutDepth: number, + converted: ReturnType, +): ImportSummary { + if (converted.data.length === 0) + throw new Error("No points remain after filtering"); + const depths = converted.data.map(({ depth }) => depth); + const times = converted.data + .map(({ timestamp }) => timestamp) + .sort(Temporal.Instant.compare); + return { + file: resolve(file), + trackpoints, + pointsWithDepth: trackpoints - pointsWithoutDepth, + pointsWithoutDepth, + importedPoints: converted.data.length, + deduplicatedPoints: converted.deduplicatedPoints, + from: times[0].toString(), + to: times.at(-1)!.toString(), + minDepth: Math.min(...depths), + maxDepth: Math.max(...depths), + timestampSource: converted.timestampSource, + depthReference: "belowWaterline", + }; +} + +export function formatSummary(summary: ImportSummary) { + return [ + `File: ${summary.file}`, + `Trackpoints: ${summary.trackpoints}`, + `Points with depth: ${summary.pointsWithDepth}`, + `Points without depth: ${summary.pointsWithoutDepth}`, + `Imported points: ${summary.importedPoints}`, + `Deduplicated points: ${summary.deduplicatedPoints}`, + `Time range: ${summary.from} – ${summary.to}`, + `Depth range: ${summary.minDepth}–${summary.maxDepth} m (below waterline)`, + `Timestamp source: ${summary.timestampSource}`, + ].join("\n"); +} + +export function parseArgs(argv: string[]): ImportOptions { + const [file, ...args] = argv; + if (!file || file.startsWith("--")) + throw new Error( + "Usage: crowd-depth-import --depth-reference [options]", + ); + const values = new Map(); + const flags = new Set(); + const valueOptions = new Set([ + "--depth-reference", + "--started-at", + "--interval", + "--transducer-depth", + "--draft", + "--dedupe-distance-meters", + "--max-points", + "--api-base-url", + "--identity-file", + "--ledger-file", + "--out", + ]); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith("--")) throw new Error(`Unexpected argument: ${arg}`); + if (["--upload", "--dry-run"].includes(arg)) flags.add(arg); + else { + if (!valueOptions.has(arg)) throw new Error(`Unknown option: ${arg}`); + const value = args[++i]; + if (!value || value.startsWith("--")) + throw new Error(`${arg} requires a value`); + values.set(arg, value); + } + } + if (flags.has("--upload") && flags.has("--dry-run")) + throw new Error("Use either --upload or --dry-run, not both"); + const depthReference = values.get("--depth-reference") as + | DepthReference + | undefined; + if (!depthReference || !DepthReferences.includes(depthReference)) + throw new Error( + `--depth-reference is required (${DepthReferences.join(", ")})`, + ); + const number = (name: string) => { + const value = values.get(name); + if (value === undefined) return undefined; + const parsed = Number(value); + if (!Number.isFinite(parsed)) + throw new Error(`${name} must be a finite number`); + return parsed; + }; + const startedAtValue = values.get("--started-at"); + let startedAt: Temporal.Instant | undefined; + if (startedAtValue) { + try { + startedAt = Temporal.Instant.from(startedAtValue); + } catch (cause) { + throw new Error(`Invalid --started-at: ${startedAtValue}`, { cause }); + } + } + return { + file: resolve(file), + depthReference, + startedAt, + interval: values.has("--interval") + ? parseInterval(values.get("--interval")!) + : undefined, + transducerDepth: number("--transducer-depth"), + draft: number("--draft"), + dedupeDistanceMeters: number("--dedupe-distance-meters"), + maxPoints: number("--max-points"), + apiBaseUrl: values.get("--api-base-url") ?? BATHY_URL, + identityFile: resolve( + values.get("--identity-file") ?? + join( + homedir(), + ".signalk", + "plugin-data", + "crowd-depth", + "identity.json", + ), + ), + ledgerFile: resolve( + values.get("--ledger-file") ?? ".crowd-depth-import-ledger.json", + ), + out: values.get("--out") ? resolve(values.get("--out")!) : undefined, + upload: flags.has("--upload"), + }; +} + +async function readIdentity(path: string): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(path, "utf8")); + } catch (cause) { + throw new Error(`Cannot read identity file ${path}`, { cause }); + } + if ( + !value || + typeof value !== "object" || + !("uuid" in value) || + !("token" in value) + ) + throw new Error(`Identity file ${path} must contain uuid and token`); + return value as VesselInfo; +} + +function importerConfig(options: ImportOptions): Config { + return { + path: "belowSurface", + sounder: { + x: 0, + y: 0, + z: options.transducerDepth ?? 0, + draft: options.draft, + }, + gnss: { x: 0, y: 0, z: 0 }, + sharing: { anonymous: false }, + }; +} + +const previewVessel: VesselInfo = { uuid: "preview", token: "preview" }; + +function createLedgerKey(xml: string, options: ImportOptions) { + return createHash("sha256") + .update(xml) + .update( + JSON.stringify({ + depthReference: options.depthReference, + startedAt: options.startedAt?.toString(), + interval: options.interval?.toString(), + transducerDepth: options.transducerDepth, + draft: options.draft, + dedupeDistanceMeters: options.dedupeDistanceMeters, + maxPoints: options.maxPoints, + apiBaseUrl: options.apiBaseUrl, + }), + ) + .digest("hex"); +} + +async function readLedger(path: string): Promise> { + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new Error(`Cannot read import ledger ${path}`, { cause: error }); + } +} diff --git a/packages/signalk-plugin/src/index.ts b/packages/signalk-plugin/src/index.ts index 3c5d148..9f4174a 100644 --- a/packages/signalk-plugin/src/index.ts +++ b/packages/signalk-plugin/src/index.ts @@ -8,3 +8,6 @@ export * from "./types.js"; export * from "./config.js"; export * from "./metadata.js"; export * from "./reporters/index.js"; +export * from "./importer/index.js"; +export * from "./importer/gpx.js"; +export * from "./importer/convert.js"; diff --git a/packages/signalk-plugin/test/fixtures/raymarine.gpx b/packages/signalk-plugin/test/fixtures/raymarine.gpx new file mode 100644 index 0000000..db9fae3 --- /dev/null +++ b/packages/signalk-plugin/test/fixtures/raymarine.gpx @@ -0,0 +1,8 @@ + + + Soundings + 2.5293.17 + 3.25 + 293.20 + + diff --git a/packages/signalk-plugin/test/importer/gpx.test.ts b/packages/signalk-plugin/test/importer/gpx.test.ts new file mode 100644 index 0000000..6e9336c --- /dev/null +++ b/packages/signalk-plugin/test/importer/gpx.test.ts @@ -0,0 +1,58 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, test } from "vitest"; +import { Temporal } from "@js-temporal/polyfill"; +import { convertGpx, parseRaymarineGpx } from "../../src/index.js"; + +const fixture = join(import.meta.dirname, "..", "fixtures", "raymarine.gpx"); + +describe("Raymarine GPX importer", () => { + test("parses depth and counts points without depth", async () => { + const parsed = parseRaymarineGpx(await readFile(fixture, "utf8")); + expect(parsed.trackpoints).toBe(3); + expect(parsed.pointsWithoutDepth).toBe(1); + expect(parsed.points.map(({ depth }) => depth)).toEqual([2.5, 3.25]); + }); + + test("requires a time source when GPX timestamps are absent", async () => { + const parsed = parseRaymarineGpx(await readFile(fixture, "utf8")); + expect(() => + convertGpx(parsed, { depthReference: "belowWaterline" }), + ).toThrow(/provide both --started-at and --interval/); + }); + + test("creates synthetic timestamps without changing waterline depths", async () => { + const parsed = parseRaymarineGpx(await readFile(fixture, "utf8")); + const result = convertGpx(parsed, { + depthReference: "belowWaterline", + startedAt: Temporal.Instant.from("2025-07-12T06:30:00Z"), + interval: Temporal.Duration.from({ seconds: 1 }), + }); + expect(result.timestampSource).toBe("cli"); + expect(result.data.map(({ timestamp }) => timestamp.toString())).toEqual([ + "2025-07-12T06:30:00Z", + "2025-07-12T06:30:01Z", + ]); + expect(result.data[0].depth).toBe(2.5); + }); + + test("finds WaterDepth with a different namespace prefix", () => { + const parsed = parseRaymarineGpx(` + + 4.2 + `); + expect(parsed.points[0].depth).toBe(4.2); + expect(parsed.points[0].timestamp?.toString()).toBe("2025-01-01T00:00:00Z"); + }); + + test("converts transducer-referenced depth to waterline", async () => { + const parsed = parseRaymarineGpx(await readFile(fixture, "utf8")); + const result = convertGpx(parsed, { + depthReference: "belowTransducer", + transducerDepth: 0.6, + startedAt: Temporal.Instant.from("2025-01-01T00:00:00Z"), + interval: Temporal.Duration.from({ seconds: 1 }), + }); + expect(result.data[0].depth).toBe(3.1); + }); +}); diff --git a/packages/signalk-plugin/test/importer/import.test.ts b/packages/signalk-plugin/test/importer/import.test.ts new file mode 100644 index 0000000..ec0c8d3 --- /dev/null +++ b/packages/signalk-plugin/test/importer/import.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readFile } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { describe, expect, test } from "vitest"; +import { parseArgs, runImport } from "../../src/importer/index.js"; + +const fixture = join(import.meta.dirname, "..", "fixtures", "raymarine.gpx"); + +describe("import preview", () => { + test("defaults to no upload and writes GeoJSON", async () => { + const out = join( + await mkdtemp(join(tmpdir(), "crowd-depth-")), + "preview.geojson", + ); + const options = parseArgs([ + fixture, + "--depth-reference", + "belowWaterline", + "--started-at", + "2025-07-12T08:30:00+02:00", + "--interval", + "1s", + "--out", + out, + ]); + expect(options.upload).toBe(false); + const { summary } = await runImport(options); + const geojson = JSON.parse(await readFile(out, "utf8")); + expect(summary).toMatchObject({ + trackpoints: 3, + pointsWithoutDepth: 1, + importedPoints: 2, + timestampSource: "cli", + }); + expect(geojson.features[0]).toMatchObject({ + geometry: { coordinates: [10.2, 54.1] }, + properties: { depth: 2.5, time: "2025-07-12T06:30:00.000Z" }, + }); + }); +});