signalk-tides 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # Lockfile checks and npm trusted publishing use Node 24.
2
+ [tools]
3
+ node = "24"
@@ -13,6 +13,8 @@ jobs:
13
13
 
14
14
  - name: Setup Node.js
15
15
  uses: actions/setup-node@v7
16
+ with:
17
+ node-version-file: mise.toml
16
18
 
17
19
  - name: Install dependencies
18
20
  run: npm install
@@ -31,6 +33,8 @@ jobs:
31
33
 
32
34
  - name: Setup Node.js
33
35
  uses: actions/setup-node@v7
36
+ with:
37
+ node-version-file: mise.toml
34
38
 
35
39
  - name: Install dependencies
36
40
  run: npm install
@@ -54,6 +58,8 @@ jobs:
54
58
  - uses: actions/checkout@v7
55
59
 
56
60
  - uses: actions/setup-node@v7
61
+ with:
62
+ node-version-file: mise.toml
57
63
 
58
64
  - name: Install modules
59
65
  run: npm install
@@ -0,0 +1,31 @@
1
+ name: Lockfile
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ # A pull request can slip a package in through package-lock.json, which
13
+ # reviewers rarely read. npm keeps existing resolved URLs when it regenerates,
14
+ # so the diff alone misses a swapped tarball; lockfile-lint catches that.
15
+ lockfile:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: actions/setup-node@v7
20
+ with:
21
+ node-version-file: .github/mise.node24.toml
22
+ - name: Check the lockfile regenerates from package.json
23
+ run: |
24
+ git ls-files --error-unmatch package-lock.json
25
+ npm install --package-lock-only --ignore-scripts
26
+ git diff --exit-code -- package-lock.json
27
+ - name: Check every package resolves from the npm registry
28
+ # @isaacs/cliui installs CommonJS releases of these packages under -cjs aliases.
29
+ run: |
30
+ npx -y lockfile-lint@5.0.1 --path package-lock.json --allowed-hosts npm --validate-https --validate-integrity --validate-package-names \
31
+ --allowed-package-name-aliases string-width-cjs:string-width strip-ansi-cjs:strip-ansi wrap-ansi-cjs:wrap-ansi
@@ -17,9 +17,24 @@ jobs:
17
17
 
18
18
  - uses: actions/setup-node@v7
19
19
  with:
20
- node-version: 24 # npm >= 11.5.1 for trusted publishing
20
+ node-version-file: .github/mise.node24.toml # npm >= 11.5.1 for trusted publishing
21
21
  registry-url: https://registry.npmjs.org
22
22
 
23
+ # Before anything installs, so a preview build is neither run nor published.
24
+ # Overrides don't reach people installing the plugin, so these specs are
25
+ # what they get.
26
+ - name: Refuse dependencies from outside the registry
27
+ run: |
28
+ node -e '
29
+ const { dependencies = {}, optionalDependencies = {} } = require("./package.json");
30
+ const outside = Object.entries({ ...dependencies, ...optionalDependencies })
31
+ .filter(([, spec]) => /[:/]/.test(spec));
32
+ if (outside.length) {
33
+ console.error("Not from the registry:", Object.fromEntries(outside));
34
+ process.exit(1);
35
+ }
36
+ '
37
+
23
38
  - name: Install dependencies
24
39
  run: npm install
25
40
 
@@ -0,0 +1,11 @@
1
+ # Contributing
2
+
3
+ ## Releasing
4
+
5
+ Publishing to npm runs from [release.yml](.github/workflows/release.yml) when a GitHub release is created. A person creates the release by hand: choose or create a `vX.Y.Z` tag, then fill in the title and notes.
6
+
7
+ Title and notes follow the Open Waters [release notes guidance](https://github.com/openwatersio/.github/blob/main/docs/agents/releases.md#release-notes). Write them for boaters running Signal K, not for developers reading commit subjects:
8
+
9
+ - Title the release with a short headline of what changed, in sentence case with no period and no version. Patch releases get a headline too. Put a new requirement in the headline, such as "requires Node 24".
10
+ - Open the notes with what is new and who it is for. Put breaking changes and upgrade steps first.
11
+ - List the changes users will notice, one per bullet, each linking its pull request. Leave out chores, CI changes, and bot dependency bumps unless a runtime dependency change matters to users.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # signalk-tides
2
2
 
3
- A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Neaps](https://github.com/neaps/neaps).
3
+ A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Slackwater](https://openwaters.io/tides/slackwater/).
4
4
 
5
5
  Since 2.0, predictions are computed locally from harmonic constituents — no network access or API keys required.
6
6
 
@@ -21,12 +21,13 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
21
21
  * `environment.tide.timeLow`
22
22
  * `environment.tide.heightNow`
23
23
  * `environment.tide.stationName`
24
+ * `environment.tide.stationDistance` — metres from the vessel to the station, when it was chosen by position
24
25
  * `environment.tide.state` — tide trend, `rising` or `falling`
25
26
  * `environment.tide.timeToNextExtreme` — seconds until the next high or low water
26
27
 
27
28
  ### Tides API
28
29
 
29
- The plugin mounts the [Neaps API](https://github.com/neaps/neaps) at `/signalk/v2/api/tides`, which serves station search, extremes, and timeline predictions. The synthetic station `vessel/default` resolves to the configured default station, or the nearest station to the vessel when none is set:
30
+ The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) at `/signalk/v2/api/tides`, which serves station search, extremes, and timeline predictions. The synthetic station `vessel/default` resolves to the configured default station, or the nearest station with tide predictions within 100 km when none is set or the configured one has no predictions. With none in range, the plugin publishes no tide and says so in its status, and the API answers 404:
30
31
 
31
32
  ```
32
33
  $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
@@ -1,3 +1,23 @@
1
+ /**
2
+ * How far from the vessel a station may be and still stand in for its tide. Past
3
+ * this a station gives a plausible tide for other water, so the plugin publishes
4
+ * none. Half of the CHS positions with no model have a station that predicts
5
+ * within 28 km, and 184 of 1,057 have none within 100 km. This project's
6
+ * definition, not a published figure.
7
+ */
8
+ export const MAX_STATION_KM = 100;
9
+ /** Whether a location lookup found nothing in range. */
10
+ export function isNoStationNearby(e) {
11
+ return e instanceof Error && e.message.startsWith("No stations found");
12
+ }
13
+ /** Whether a lookup failed on the id, rather than on loading the database. */
14
+ export function isStationNotFound(e) {
15
+ return e instanceof Error && e.message.startsWith("Station not found");
16
+ }
17
+ // CHS stations carry identity only, and predicting from one gives a flat zero tide.
18
+ export function hasTideModel(station) {
19
+ return station.harmonic_constituents.length > 0 || station.offsets !== undefined;
20
+ }
1
21
  /**
2
22
  * Trend of the tide at `time`: "rising" if the next extreme is a High,
3
23
  * "falling" if it is a Low.
package/dist/index.js CHANGED
@@ -13,16 +13,35 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { createRoutes } from "@neaps/api";
17
- import { findStation, nearestStation, stationsNear } from "neaps";
18
- import { tideStateAt, timeToNextExtreme } from "./calculations.js";
16
+ import { createRoutes } from "@slackwater/api";
17
+ import { findStation, nearestStation, stationsNear } from "slackwater";
18
+ import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, tideStateAt, timeToNextExtreme, } from "./calculations.js";
19
19
  import FileCache from "./cache.js";
20
20
  import { withVesselPosition } from "./middleware.js";
21
21
  import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-extension.js";
22
22
  // Recompute and republish on a fixed interval. Predictions are computed locally
23
- // by neaps, so this is cheap and needs no configuration.
23
+ // by slackwater, so this is cheap and needs no configuration.
24
24
  const UPDATE_INTERVAL = 60 * 1000; // 1 minute
25
25
  const API_PATH = "/signalk/v2/api/tides";
26
+ const TIDE_PATHS = [
27
+ "environment.tide.stationName",
28
+ "environment.tide.stationDistance",
29
+ "environment.tide.heightNow",
30
+ "environment.tide.state",
31
+ "environment.tide.timeToNextExtreme",
32
+ "environment.tide.heightHigh",
33
+ "environment.tide.timeHigh",
34
+ "environment.tide.heightLow",
35
+ "environment.tide.timeLow",
36
+ ];
37
+ // Tide heights are water levels, so clients should show them in the user's
38
+ // depth unit. Without a category the server picks one from the unit alone, and
39
+ // for meters that is distance: kilometers or miles, depending on the preset.
40
+ const TIDE_HEIGHT_PATHS = [
41
+ "environment.tide.heightHigh",
42
+ "environment.tide.heightNow",
43
+ "environment.tide.heightLow",
44
+ ];
26
45
  export default function (app) {
27
46
  let unsubscribes = [];
28
47
  let activeRouter = null;
@@ -41,8 +60,9 @@ export default function (app) {
41
60
  const plugin = {
42
61
  id: "tides",
43
62
  name: "Tides",
44
- description: "Offline tidal predictions for the vessel's position, powered by Neaps.",
45
- schema: () => ({
63
+ description: "Offline tidal predictions for the vessel's position, powered by Slackwater.",
64
+ // signalk-server awaits this, but a rejection would fail its whole plugin list
65
+ schema: async () => ({
46
66
  title: "Tides",
47
67
  type: "object",
48
68
  properties: {
@@ -51,7 +71,7 @@ export default function (app) {
51
71
  title: "Default tide station",
52
72
  description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
53
73
  default: "auto",
54
- oneOf: defaultStationOptions(),
74
+ oneOf: await defaultStationOptions(),
55
75
  },
56
76
  },
57
77
  }),
@@ -62,11 +82,20 @@ export default function (app) {
62
82
  activeRouter = null;
63
83
  },
64
84
  };
65
- function defaultStationOptions() {
85
+ async function defaultStationOptions() {
66
86
  const options = [{ const: "auto", title: "Closest station" }];
67
87
  if (lastPosition) {
68
- for (const station of stationsNear({ ...lastPosition, maxResults: 10 })) {
69
- options.push({ const: station.id, title: stationTitle(station) });
88
+ try {
89
+ const nearby = await stationsNear({
90
+ ...lastPosition,
91
+ maxResults: 10,
92
+ });
93
+ for (const station of nearby) {
94
+ options.push({ const: station.id, title: stationTitle(station) });
95
+ }
96
+ }
97
+ catch (e) {
98
+ app.error(`Could not list nearby tide stations: ${errorMessage(e)}`);
70
99
  }
71
100
  }
72
101
  // Keep the saved station selectable even when it is not in the nearby
@@ -76,15 +105,40 @@ export default function (app) {
76
105
  if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
77
106
  let title = saved;
78
107
  try {
79
- title = stationTitle(findStation(saved));
108
+ const station = await findStation(saved);
109
+ title = hasTideModel(station)
110
+ ? stationTitle(station)
111
+ : `${stationTitle(station)} (no tide predictions)`;
80
112
  }
81
- catch {
113
+ catch (e) {
82
114
  // Unknown id: keep the raw value as the label
115
+ if (!isStationNotFound(e)) {
116
+ app.error(`Could not load saved tide station: ${errorMessage(e)}`);
117
+ }
83
118
  }
84
119
  options.splice(1, 0, { const: saved, title });
85
120
  }
86
121
  return options;
87
122
  }
123
+ // The server keeps display units the user chose for a path, and stores the
124
+ // suggestion, so later starts leave a path alone while it has display units.
125
+ async function suggestDepthUnits() {
126
+ // Signal K server 2.30 added setDefaultMetadata
127
+ if (typeof app.setDefaultMetadata !== "function")
128
+ return;
129
+ for (const path of TIDE_HEIGHT_PATHS) {
130
+ try {
131
+ // MetaValue types displayUnits as the server resolves it; a suggestion
132
+ // needs only the category.
133
+ await app.setDefaultMetadata(path, {
134
+ displayUnits: { category: "depth" },
135
+ });
136
+ }
137
+ catch (e) {
138
+ app.error(`Could not suggest display units for ${path}: ${e}`);
139
+ }
140
+ }
141
+ }
88
142
  function savedDefaultStation() {
89
143
  try {
90
144
  const saved = app.readPluginOptions();
@@ -94,18 +148,37 @@ export default function (app) {
94
148
  return undefined;
95
149
  }
96
150
  }
97
- async function start(options) {
151
+ // Not async: signalk-server only rolls back a start() that throws synchronously,
152
+ // and that rollback does not call stop().
153
+ function start(options) {
154
+ try {
155
+ startPublishing(options);
156
+ }
157
+ catch (e) {
158
+ plugin.stop();
159
+ throw e;
160
+ }
161
+ }
162
+ function startPublishing(options) {
98
163
  app.debug("Starting tides");
99
164
  config = (options ?? {});
165
+ void suggestDepthUnits();
100
166
  // Keep the forecast and the predictor that produced it together, so the
101
167
  // extremes and the current height are always read from the same station.
102
168
  let lastForecast = null;
103
169
  let lastPredictor = null;
170
+ let latestUpdate = 0;
171
+ let stopped = false;
172
+ let tidesCleared = false;
173
+ let positionWrite = Promise.resolve();
174
+ unsubscribes.push(() => {
175
+ stopped = true;
176
+ });
104
177
  const cache = new FileCache(app.getDataDirPath());
105
- // Mount the Neaps API, with vessel/default resolved to the configured
178
+ // Mount the Slackwater API, with vessel/default resolved to the configured
106
179
  // default station (or the nearest one).
107
180
  activeRouter = withVesselPosition(
108
- // @neaps/api bundles its own Express declarations, so its Router is
181
+ // @slackwater/api bundles its own Express declarations, so its Router is
109
182
  // callable at runtime but not structurally compatible with ours.
110
183
  // The OpenAPI server URL comes from req.baseUrl, set by the
111
184
  // app.use(API_PATH, ...) mount above.
@@ -115,7 +188,7 @@ export default function (app) {
115
188
  type: "tides",
116
189
  methods: {
117
190
  async listResources() {
118
- const predictor = resolvePredictor();
191
+ const predictor = await resolvePredictor();
119
192
  if (!predictor) {
120
193
  throw new Error("No position or default station available");
121
194
  }
@@ -165,25 +238,51 @@ export default function (app) {
165
238
  }, unsubscribes, (subscriptionError) => {
166
239
  app.error("Error:" + subscriptionError);
167
240
  }, updatePosition);
168
- function resolvePredictor() {
241
+ async function resolvePredictor(report = reportLookupFailure) {
242
+ let failed = false;
169
243
  if (config.defaultStation && config.defaultStation !== "auto") {
170
244
  try {
171
- return findStation(config.defaultStation);
245
+ const station = await findStation(config.defaultStation);
246
+ if (hasTideModel(station))
247
+ return station;
248
+ throw new Error("Station has no tide predictions");
172
249
  }
173
- catch {
174
- app.error(`Configured tide station not found: ${config.defaultStation}`);
250
+ catch (e) {
251
+ failed = true;
252
+ report(`Could not load tide station ${config.defaultStation}: ${errorMessage(e)}`);
175
253
  }
176
254
  }
177
255
  if (lastPosition) {
178
256
  try {
179
- return nearestStation(lastPosition);
257
+ return await nearestStation({
258
+ ...lastPosition,
259
+ maxDistance: MAX_STATION_KM,
260
+ });
180
261
  }
181
- catch {
182
- app.error("No tide station found near vessel position");
262
+ catch (e) {
263
+ if (isNoStationNearby(e)) {
264
+ // A status would replace the error about the saved station
265
+ if (!stopped && !failed) {
266
+ app.setPluginStatus(`No tide station within ${MAX_STATION_KM} km of the vessel`);
267
+ }
268
+ return null;
269
+ }
270
+ report(`Could not find a tide station near vessel position: ${errorMessage(e)}`);
183
271
  }
184
272
  }
185
273
  return null;
186
274
  }
275
+ function reportLookupFailure(message) {
276
+ if (stopped)
277
+ return;
278
+ app.error(message);
279
+ app.setPluginError(message);
280
+ }
281
+ function forecastStatus({ station, distance }) {
282
+ return distance === undefined
283
+ ? "Updated tide forecast"
284
+ : `Updated tide forecast from ${station.name}, ${Math.round(distance)} km away`;
285
+ }
187
286
  function forecastFor(predictor) {
188
287
  const now = new Date();
189
288
  return predictor.getExtremesPrediction({
@@ -191,32 +290,63 @@ export default function (app) {
191
290
  end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
192
291
  });
193
292
  }
293
+ // Runs from a timer and a delta callback, which both drop its promise, so
294
+ // it must never reject.
194
295
  async function updatePosition() {
195
- const newPosition = app.getSelfPath("navigation.position.value");
196
- // New position received, save it to the cache
197
- if (newPosition) {
198
- lastPosition = newPosition;
199
- await cache.set("position", lastPosition);
296
+ try {
297
+ const newPosition = app.getSelfPath("navigation.position.value");
298
+ // New position received, save it to the cache
299
+ if (newPosition) {
300
+ lastPosition = newPosition;
301
+ // Writes run in turn and each saves the newest position, so an older
302
+ // one can't land last
303
+ positionWrite = positionWrite
304
+ .catch(() => { })
305
+ .then(() => cache.set("position", lastPosition));
306
+ await positionWrite;
307
+ }
308
+ // No last known position, try to load from cache.
309
+ if (!lastPosition) {
310
+ const cached = (await cache.get("position"));
311
+ // A delta may have set a newer position during the read
312
+ lastPosition ??= cached ?? null;
313
+ }
200
314
  }
201
- // No last known position, try to load from cache.
202
- if (!lastPosition) {
203
- lastPosition = (await cache.get("position"));
315
+ catch (e) {
316
+ app.error(`Could not update cached position: ${errorMessage(e)}`);
204
317
  }
205
318
  // A configured default station predicts without a position
206
- updateForecast();
319
+ await updateForecast();
207
320
  }
208
- function updateForecast() {
209
- const predictor = resolvePredictor();
210
- if (!predictor) {
211
- app.debug("No position or default station available, cannot compute tide data");
212
- lastForecast = null;
213
- lastPredictor = null;
214
- return;
215
- }
321
+ async function updateForecast() {
322
+ const update = ++latestUpdate;
216
323
  try {
324
+ let lookupFailed = false;
325
+ const predictor = await resolvePredictor((message) => {
326
+ lookupFailed = true;
327
+ reportLookupFailure(message);
328
+ });
329
+ // The first lookup waits for the database, so a newer update or stop()
330
+ // can overtake this one.
331
+ if (stopped || update !== latestUpdate)
332
+ return;
333
+ if (!predictor) {
334
+ app.debug("No position or default station available, cannot compute tide data");
335
+ // Once per loss, and once after a restart, which can't tell what the
336
+ // run before it published
337
+ if (!tidesCleared)
338
+ clearTides();
339
+ tidesCleared = true;
340
+ lastForecast = null;
341
+ lastPredictor = null;
342
+ return;
343
+ }
217
344
  lastForecast = forecastFor(predictor);
218
345
  lastPredictor = predictor;
219
- app.setPluginStatus("Updated tide forecast");
346
+ tidesCleared = false;
347
+ // A fallback keeps the lookup's error showing
348
+ if (!lookupFailed)
349
+ app.setPluginStatus(forecastStatus(lastForecast));
220
350
  updateTides();
221
351
  }
222
352
  catch (e) {
@@ -226,6 +356,19 @@ export default function (app) {
226
356
  app.error(e);
227
357
  }
228
358
  }
359
+ // Signal K keeps a path's last value, so a tide that no longer applies has
360
+ // to be cleared or it stays on displays, frozen.
361
+ function clearTides() {
362
+ app.handleMessage(plugin.id, {
363
+ context: ("vessels." + app.selfId),
364
+ updates: [
365
+ {
366
+ timestamp: new Date().toISOString(),
367
+ values: TIDE_PATHS.map((path) => ({ path, value: null })),
368
+ },
369
+ ],
370
+ });
371
+ }
229
372
  function updateTides(now = new Date()) {
230
373
  if (!lastForecast || !lastPredictor)
231
374
  return;
@@ -246,6 +389,13 @@ export default function (app) {
246
389
  path: "environment.tide.stationName",
247
390
  value: lastForecast.station.name,
248
391
  },
392
+ // Null for a configured station, which isn't chosen by distance
393
+ {
394
+ path: "environment.tide.stationDistance",
395
+ value: lastForecast.distance === undefined
396
+ ? null
397
+ : lastForecast.distance * 1000,
398
+ },
249
399
  {
250
400
  path: "environment.tide.heightNow",
251
401
  value: heightNow,
@@ -286,6 +436,13 @@ export default function (app) {
286
436
  description: "Tide trend at the current position: rising or falling",
287
437
  },
288
438
  },
439
+ {
440
+ path: "environment.tide.stationDistance",
441
+ value: {
442
+ units: "m",
443
+ description: "Distance from the vessel to the station it was given",
444
+ },
445
+ },
289
446
  {
290
447
  path: "environment.tide.timeToNextExtreme",
291
448
  value: {
@@ -311,6 +468,9 @@ export default function (app) {
311
468
  }
312
469
  return plugin;
313
470
  }
471
+ function errorMessage(e) {
472
+ return e instanceof Error ? e.message : String(e);
473
+ }
314
474
  function stationTitle(station) {
315
475
  const where = [station.region, station.country].filter(Boolean).join(", ");
316
476
  const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";