signalk-tides 2.0.0-beta.1 → 2.0.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.
@@ -9,7 +9,7 @@ jobs:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
11
  - name: Checkout repository
12
- uses: actions/checkout@v6
12
+ uses: actions/checkout@v7
13
13
 
14
14
  - name: Setup Node.js
15
15
  uses: actions/setup-node@v6
@@ -27,7 +27,7 @@ jobs:
27
27
  CI: true
28
28
  steps:
29
29
  - name: Checkout repository
30
- uses: actions/checkout@v6
30
+ uses: actions/checkout@v7
31
31
 
32
32
  - name: Setup Node.js
33
33
  uses: actions/setup-node@v6
@@ -51,7 +51,7 @@ jobs:
51
51
  id-token: write
52
52
  pull-requests: write
53
53
  steps:
54
- - uses: actions/checkout@v6
54
+ - uses: actions/checkout@v7
55
55
 
56
56
  - uses: actions/setup-node@v6
57
57
 
package/README.md CHANGED
@@ -26,10 +26,10 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
26
26
 
27
27
  ### Tides API
28
28
 
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/current` resolves to the nearest station to the vessel:
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
30
 
31
31
  ```
32
- $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/current/extremes
32
+ $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
33
33
  ```
34
34
 
35
35
  ### Tides resource
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { createRoutes } from "@neaps/api";
17
- import { getExtremesPrediction, getWaterLevelAtTime } from "neaps";
17
+ import { findStation, nearestStation, stationsNear } from "neaps";
18
18
  import { tideStateAt, timeToNextExtreme } from "./calculations.js";
19
19
  import FileCache from "./cache.js";
20
20
  import { withVesselPosition } from "./middleware.js";
@@ -25,6 +25,8 @@ const API_PATH = "/signalk/v2/api/tides";
25
25
  export default function (app) {
26
26
  let unsubscribes = [];
27
27
  let activeRouter = null;
28
+ let lastPosition = null;
29
+ let config = {};
28
30
  // Mount forwarding middleware once (Express doesn't support unmounting)
29
31
  // @ts-expect-error: app is an Express app at runtime
30
32
  app.use(API_PATH, (req, res, next) => {
@@ -42,7 +44,15 @@ export default function (app) {
42
44
  schema: () => ({
43
45
  title: "Tides",
44
46
  type: "object",
45
- properties: {},
47
+ properties: {
48
+ defaultStation: {
49
+ type: "string",
50
+ title: "Default tide station",
51
+ description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
52
+ default: "auto",
53
+ oneOf: defaultStationOptions(),
54
+ },
55
+ },
46
56
  }),
47
57
  start,
48
58
  stop() {
@@ -51,21 +61,59 @@ export default function (app) {
51
61
  activeRouter = null;
52
62
  },
53
63
  };
54
- async function start() {
64
+ function defaultStationOptions() {
65
+ const options = [{ const: "auto", title: "Closest station" }];
66
+ if (lastPosition) {
67
+ for (const station of stationsNear({ ...lastPosition, maxResults: 10 })) {
68
+ options.push({ const: station.id, title: stationTitle(station) });
69
+ }
70
+ }
71
+ // Keep the saved station selectable even when it is not in the nearby
72
+ // list, so opening the settings far from it (or before a position fix)
73
+ // cannot clobber it on save.
74
+ const saved = savedDefaultStation();
75
+ if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
76
+ let title = saved;
77
+ try {
78
+ title = stationTitle(findStation(saved));
79
+ }
80
+ catch {
81
+ // Unknown id: keep the raw value as the label
82
+ }
83
+ options.splice(1, 0, { const: saved, title });
84
+ }
85
+ return options;
86
+ }
87
+ function savedDefaultStation() {
88
+ try {
89
+ const saved = app.readPluginOptions();
90
+ return saved?.configuration?.defaultStation;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ async function start(options) {
55
97
  app.debug("Starting tides");
98
+ config = (options ?? {});
99
+ // Keep the forecast and the predictor that produced it together, so the
100
+ // extremes and the current height are always read from the same station.
56
101
  let lastForecast = null;
57
- let lastPosition = null;
102
+ let lastPredictor = null;
58
103
  const cache = new FileCache(app.getDataDirPath());
59
- // Mount the Neaps API, with vessel/current resolved to the nearest station.
60
- activeRouter = withVesselPosition(createRoutes({ prefix: API_PATH }), () => lastPosition);
104
+ // Mount the Neaps API, with vessel/default resolved to the configured
105
+ // default station (or the nearest one).
106
+ activeRouter = withVesselPosition(createRoutes({ prefix: API_PATH }), () => lastPosition, () => config.defaultStation ?? null);
61
107
  // Register tide predictions as a resource provider
62
108
  app.registerResourceProvider({
63
109
  type: "tides",
64
110
  methods: {
65
111
  async listResources() {
66
- if (!lastPosition)
67
- throw new Error("No position available");
68
- return forecastFor(lastPosition);
112
+ const predictor = resolvePredictor();
113
+ if (!predictor) {
114
+ throw new Error("No position or default station available");
115
+ }
116
+ return forecastFor(predictor);
69
117
  },
70
118
  getResource() {
71
119
  throw new Error("Not implemented");
@@ -90,11 +138,28 @@ export default function (app) {
90
138
  }, unsubscribes, (subscriptionError) => {
91
139
  app.error("Error:" + subscriptionError);
92
140
  }, updatePosition);
93
- function forecastFor(position) {
141
+ function resolvePredictor() {
142
+ if (config.defaultStation && config.defaultStation !== "auto") {
143
+ try {
144
+ return findStation(config.defaultStation);
145
+ }
146
+ catch {
147
+ app.error(`Configured tide station not found: ${config.defaultStation}`);
148
+ }
149
+ }
150
+ if (lastPosition) {
151
+ try {
152
+ return nearestStation(lastPosition);
153
+ }
154
+ catch {
155
+ app.error("No tide station found near vessel position");
156
+ }
157
+ }
158
+ return null;
159
+ }
160
+ function forecastFor(predictor) {
94
161
  const now = new Date();
95
- return getExtremesPrediction({
96
- latitude: position.latitude,
97
- longitude: position.longitude,
162
+ return predictor.getExtremesPrediction({
98
163
  start: now,
99
164
  end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
100
165
  });
@@ -110,16 +175,20 @@ export default function (app) {
110
175
  if (!lastPosition) {
111
176
  lastPosition = (await cache.get("position"));
112
177
  }
113
- if (lastPosition)
114
- updateForecast();
178
+ // A configured default station predicts without a position
179
+ updateForecast();
115
180
  }
116
181
  function updateForecast() {
117
- if (!lastPosition) {
118
- app.debug("No position available, cannot compute tide data");
182
+ const predictor = resolvePredictor();
183
+ if (!predictor) {
184
+ app.debug("No position or default station available, cannot compute tide data");
185
+ lastForecast = null;
186
+ lastPredictor = null;
119
187
  return;
120
188
  }
121
189
  try {
122
- lastForecast = forecastFor(lastPosition);
190
+ lastForecast = forecastFor(predictor);
191
+ lastPredictor = predictor;
123
192
  app.setPluginStatus("Updated tide forecast");
124
193
  updateTides();
125
194
  }
@@ -131,17 +200,13 @@ export default function (app) {
131
200
  }
132
201
  }
133
202
  function updateTides(now = new Date()) {
134
- if (!lastForecast || !lastPosition)
203
+ if (!lastForecast || !lastPredictor)
135
204
  return;
136
205
  // Get the next two upcoming extremes
137
206
  const nextTides = lastForecast.extremes
138
207
  .filter(({ time }) => time >= now)
139
208
  .slice(0, 2);
140
- const heightNow = getWaterLevelAtTime({
141
- latitude: lastPosition.latitude,
142
- longitude: lastPosition.longitude,
143
- time: now,
144
- }).level;
209
+ const heightNow = lastPredictor.getWaterLevelAtTime({ time: now }).level;
145
210
  const state = tideStateAt(lastForecast.extremes, now);
146
211
  const secondsToNextExtreme = timeToNextExtreme(lastForecast.extremes, now);
147
212
  const delta = {
@@ -164,7 +229,12 @@ export default function (app) {
164
229
  ? [{ path: "environment.tide.state", value: state }]
165
230
  : []),
166
231
  ...(secondsToNextExtreme !== null
167
- ? [{ path: "environment.tide.timeToNextExtreme", value: secondsToNextExtreme }]
232
+ ? [
233
+ {
234
+ path: "environment.tide.timeToNextExtreme",
235
+ value: secondsToNextExtreme,
236
+ },
237
+ ]
168
238
  : []),
169
239
  ...nextTides.flatMap(({ label, time, level }) => {
170
240
  return [
@@ -185,11 +255,16 @@ export default function (app) {
185
255
  meta: [
186
256
  {
187
257
  path: "environment.tide.state",
188
- value: { description: "Tide trend at the current position: rising or falling" },
258
+ value: {
259
+ description: "Tide trend at the current position: rising or falling",
260
+ },
189
261
  },
190
262
  {
191
263
  path: "environment.tide.timeToNextExtreme",
192
- value: { units: "s", description: "Seconds until the next tide extreme (high or low water)" },
264
+ value: {
265
+ units: "s",
266
+ description: "Seconds until the next tide extreme (high or low water)",
267
+ },
193
268
  },
194
269
  ],
195
270
  },
@@ -209,3 +284,8 @@ export default function (app) {
209
284
  }
210
285
  return plugin;
211
286
  }
287
+ function stationTitle(station) {
288
+ const where = [station.region, station.country].filter(Boolean).join(", ");
289
+ const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";
290
+ return `${station.name}${where ? ` (${where})` : ""}${distance}`;
291
+ }
@@ -1,33 +1,46 @@
1
- import { nearestStation } from 'neaps';
1
+ import { findStation, nearestStation } from "neaps";
2
2
  /**
3
- * Middleware that handles vessel/current station endpoints by redirecting to
4
- * the nearest real station, and injects the default vessel position for
5
- * location-based queries.
3
+ * Middleware that resolves the vessel/default station alias and bare
4
+ * location-based queries (/extremes, /timeline) against the configured default
5
+ * station, falling back to the station nearest the vessel's position when no
6
+ * default is set.
6
7
  */
7
- export function withVesselPosition(router, getPosition) {
8
+ export function withVesselPosition(router, getPosition, getDefaultStationId) {
8
9
  return (req, res, next) => {
9
- const isVesselCurrentPath = /^\/stations\/vessel\/current(?:\/|$)/.test(req.path);
10
- // Handle vessel/current station endpoints by redirecting to the nearest real station
11
- if (isVesselCurrentPath) {
10
+ // HTTP 303 See Other: the response can be found under a different URI
11
+ const redirect = (toPath) => res.redirect(303, `${req.baseUrl}${toPath}`);
12
+ // The configured default station, or null when unset, "auto", or stale.
13
+ const defaultStation = () => {
14
+ const id = getDefaultStationId?.();
15
+ if (!id || id === "auto")
16
+ return null;
17
+ try {
18
+ return findStation(id);
19
+ }
20
+ catch {
21
+ return null; // stale or invalid saved id: fall back to position
22
+ }
23
+ };
24
+ // Redirect the vessel/default alias to the configured (or nearest) station.
25
+ if (/^\/stations\/vessel\/default(?:\/|$)/.test(req.path)) {
26
+ const toStation = (station) => redirect(req.url.replace(/^\/stations\/vessel\/default\b/, `/stations/${station.id}`));
27
+ // A configured default station resolves without a position
28
+ const station = defaultStation();
29
+ if (station)
30
+ return toStation(station);
12
31
  const pos = getPosition();
13
32
  if (!pos) {
14
- return res.status(503).json({
15
- message: "Vessel position not available",
16
- });
33
+ return res.status(503).json({ message: "Vessel position not available" });
17
34
  }
18
35
  try {
19
- const station = nearestStation({
36
+ const nearest = nearestStation({
20
37
  latitude: pos.latitude,
21
38
  longitude: pos.longitude,
22
39
  });
23
- if (!station) {
24
- return res.status(404).json({
25
- message: "No nearby stations found",
26
- });
40
+ if (!nearest) {
41
+ return res.status(404).json({ message: "No nearby stations found" });
27
42
  }
28
- const path = req.url.replace(/^\/stations\/vessel\/current\b/, `/stations/${station.id}`);
29
- // HTTP 303 See Other: the response can be found under a different URI
30
- return res.redirect(303, `${req.baseUrl}${path}`);
43
+ return toStation(nearest);
31
44
  }
32
45
  catch {
33
46
  return res.status(500).json({
@@ -35,9 +48,14 @@ export function withVesselPosition(router, getPosition) {
35
48
  });
36
49
  }
37
50
  }
38
- // Inject default position for location-based queries
51
+ // Bare location-based queries: redirect to the configured station when set,
52
+ // otherwise inject the vessel position so neaps uses the nearest station.
39
53
  const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
40
54
  if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
55
+ const station = defaultStation();
56
+ if (station) {
57
+ return redirect(req.url.replace(/^\/(extremes|timeline)\b/, `/stations/${station.id}/$1`));
58
+ }
41
59
  const pos = getPosition();
42
60
  if (pos) {
43
61
  req.query.latitude = String(pos.latitude);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-tides",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0",
4
4
  "description": "Tidal predictions for the vessel's position, powered by Neaps.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,11 +34,11 @@
34
34
  ],
35
35
  "license": "Apache-2.0",
36
36
  "dependencies": {
37
- "@neaps/api": "https://pkg.pr.new/openwatersio/neaps/@neaps/api@4eed5db",
38
- "@neaps/react": "https://pkg.pr.new/openwatersio/neaps/@neaps/react@4eed5db",
39
- "@signalk/server-api": "^2.0",
37
+ "@neaps/api": "^0.5.1",
38
+ "@neaps/react": "^0.1.0",
39
+ "@signalk/server-api": "^2.28.0",
40
40
  "express": "^4.0",
41
- "neaps": "https://pkg.pr.new/openwatersio/neaps@4eed5db",
41
+ "neaps": "^0.7.0",
42
42
  "react": "^19.1.0",
43
43
  "react-dom": "^19.1.0"
44
44
  },