Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions packages/signalk-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions packages/signalk-plugin/bin/crowd-depth-import
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node

import "../dist/importer/cli.js";
4 changes: 3 additions & 1 deletion packages/signalk-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/signalk-plugin/src/importer/cli.ts
Original file line number Diff line number Diff line change
@@ -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;
}
145 changes: 145 additions & 0 deletions packages/signalk-plugin/src/importer/convert.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
118 changes: 118 additions & 0 deletions packages/signalk-plugin/src/importer/gpx.ts
Original file line number Diff line number Diff line change
@@ -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<BathymetryData, "timestamp"> & {
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Loading