signalk-tides 2.0.0-beta.0 → 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
 
@@ -0,0 +1,60 @@
1
+ [ 188ms] [LOG] [ConnectionStateMachine] Service initialized @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
2
+ [ 191ms] [LOG] [ConnectionStateMachine] Starting HTTP discovery: Connecting to http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
3
+ [ 192ms] [LOG] [Connection Service] Connecting to: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
4
+ [ 213ms] [LOG] [ConnectionStateMachine] HTTP discovery successful @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
5
+ [ 214ms] [LOG] [AppSettings Service] Service startup... @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:3
6
+ [ 214ms] [LOG] [AppSettings Service] LocalStorage enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
7
+ [ 214ms] [LOG] [DatasetStreamService] Auto Starting 0 Datasets @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
8
+ [ 214ms] [LOG] [AppInit Network Service] Initialization completed @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
9
+ [ 214ms] [LOG] [ConnectionStateMachine] WebSocket mode enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
10
+ [ 214ms] [LOG] [AppInit Network Service] Starting WebSocket connection after initialization @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
11
+ [ 214ms] [LOG] [ConnectionStateMachine] Starting WebSocket connection @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
12
+ [ 215ms] [LOG] [Delta Service] ConnectionStateMachine request: WebSocket opening... @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
13
+ [ 220ms] [LOG] [Remote Dashboards] Cleaning paths on server @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
14
+ [ 221ms] [LOG] [UI Event Service] PWA mode: false @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:49
15
+ [ 226ms] [LOG] *********** KIP Version Information *********** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
16
+ [ 226ms] [LOG] ** App Version: 4.8.0 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
17
+ [ 226ms] [LOG] ** Browser Version: Chrome 149 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
18
+ [ 226ms] [LOG] ** OS Version: macOS @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
19
+ [ 226ms] [LOG] *********************************************** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
20
+ [ 229ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
21
+ [ 248ms] [LOG] [Delta Service] WebSocket connected without Authorization Token @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
22
+ [ 248ms] [LOG] [ConnectionStateMachine] WebSocket connected @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
23
+ [ 249ms] [LOG] [Connection Service] Server Name: signalk-server, Version: 2.23.0-beta.1, Roles: ["master","main"] @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
24
+ [ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
25
+ [ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
26
+ [ 370ms] [WARNING] [Request Service] Status Code: 403 - DENIED: Authorization with R/W or Admin permission level is required to send commands. Configure Sign In credential. @ http://localhost:3000/@mxtommy/kip/chunk-W6MCE3GH.js:0
27
+ [ 380ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3
28
+ [ 476ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ http://localhost:3000/@signalk/freeboard-sk/:0
29
+ [ 934ms] [INFO] Internet connection detected. @ http://localhost:3000/@signalk/freeboard-sk/main-YHMZXR5G.js:249
30
+ [ 940ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
31
+ [ 1000ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip:0
32
+ [ 1000ms] [LOG] [KipSeriesApiClientService] POST http://localhost:3000/plugins/kip/series/reconcile (0 series) @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
33
+ [ 1007ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip/series/reconcile:0
34
+ [ 1007ms] [ERROR] [KipSeriesApiClientService] Series reconcile request failed: Ar @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
35
+ [ 1073ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk/v1/api/vessels/self/navigation/anchor:0
36
+ [ 87530ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
37
+ [ 87531ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/anchoralarm:0
38
+ [ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/tracks:0
39
+ [ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/signalk-polar-performance-plugin:0
40
+ [ 87532ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/resources-provider:0
41
+ [ 87536ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/signalk-racer:0
42
+ [ 87572ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/course-provider:0
43
+ [ 87573ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/autopilot:0
44
+ [ 87573ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/pypilot-autopilot-provider:0
45
+ [ 112153ms] [ERROR] ERROR _: NG0951
46
+ at Object.computation (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:94168)
47
+ at Object.producerRecomputeValue (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:33213)
48
+ at Qn (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:30918)
49
+ at n.r [as iframe] (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3:32825)
50
+ at n.ngAfterViewInit (http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:44:361583)
51
+ at ng (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27312)
52
+ at rI (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27474)
53
+ at Gg (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:27201)
54
+ at wa (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:26929)
55
+ at bS (http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:4:67813) @ http://localhost:3000/@mxtommy/kip/chunk-LEY6MANN.js:3
56
+ [ 263184ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
57
+ [ 263185ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
58
+ [ 399636ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
59
+ [ 399636ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
60
+ [ 4767580ms] [LOG] [Delta Service] App suspended @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
@@ -0,0 +1,5 @@
1
+ [ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/plugins:0
2
+ [ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/appstore/available:0
3
+ [ 2353ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/skServer/security/access/requests:0
4
+ [ 2427ms] [LOG] connected @ http://localhost:3000/admin/assets/bootstrap-BbNbkfZh.js:69
5
+ [ 3712ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk-usage/assets/icons/signalk-usage-icon_72x72.png:0
package/README.md CHANGED
@@ -21,13 +21,15 @@ 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.state` — tide trend, `rising` or `falling`
25
+ * `environment.tide.timeToNextExtreme` — seconds until the next high or low water
24
26
 
25
27
  ### Tides API
26
28
 
27
- 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:
28
30
 
29
31
  ```
30
- $ 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
31
33
  ```
32
34
 
33
35
  ### Tides resource
@@ -1,24 +1,37 @@
1
- /** Given a list of tide extremes, estimate the height at a specific time. */
2
- export function approximateTideHeightAt(extremes, time) {
3
- const sorted = extremes.slice().sort((a, b) => a.time.getTime() - b.time.getTime());
4
- const prev = sorted.filter(h => h.time <= time).at(-1);
5
- const next = sorted.filter(h => h.time >= time).at(0);
6
- if (!prev)
7
- throw new Error("Missing height data before " + time.toISOString());
1
+ /**
2
+ * Trend of the tide at `time`: "rising" if the next extreme is a High,
3
+ * "falling" if it is a Low.
4
+ *
5
+ * Boundary: at the instant of an extreme the extreme itself satisfies
6
+ * `extreme.time >= time`, so it is still "next", and the state reads as that
7
+ * extreme's own type — e.g. "falling" with timeToNextExtreme 0 at the instant
8
+ * of low water — flipping on the following tick. Simplest consistent rule at
9
+ * one-minute resolution.
10
+ *
11
+ * Returns null when the forecast has no upcoming extreme (stale data).
12
+ */
13
+ export function tideStateAt(extremes, time) {
14
+ const next = nextExtremeAt(extremes, time);
8
15
  if (!next)
9
- throw new Error("Missing height data after " + time.toISOString());
10
- const progress = (time.getTime() - prev.time.getTime()) /
11
- (next.time.getTime() - prev.time.getTime());
12
- const value = interpolate(prev.level, next.level, easeSine(progress));
13
- return parseFloat(value.toFixed(3));
16
+ return null;
17
+ return next.high ? "rising" : "falling";
14
18
  }
15
- /** Interpolate between two values using the given progress (0-1). */
16
- function interpolate(start, end, progress) {
17
- return start + (end - start) * progress;
19
+ /** First extreme at or after `time`, or undefined. Tolerates unsorted input. */
20
+ function nextExtremeAt(extremes, time) {
21
+ return extremes
22
+ .slice()
23
+ .sort((a, b) => a.time.getTime() - b.time.getTime())
24
+ .find((extreme) => extreme.time >= time);
18
25
  }
19
- function easeSine(progress) {
20
- // Map progress [0..1] to angle [0..π]
21
- const angle = progress * Math.PI;
22
- // Use sine to ease in/out
23
- return (1 - Math.cos(angle)) / 2;
26
+ /**
27
+ * Whole seconds (rounded up) until the next tide extreme (high or low water)
28
+ * at `time`, or null when the forecast has no upcoming extreme. Ceiling keeps
29
+ * the countdown > 0 until the extreme is actually reached; see tideStateAt
30
+ * for the exactly-at-extreme boundary behaviour (this returns 0 for that tick).
31
+ */
32
+ export function timeToNextExtreme(extremes, time) {
33
+ const next = nextExtremeAt(extremes, time);
34
+ if (!next)
35
+ return null;
36
+ return Math.ceil((next.time.getTime() - time.getTime()) / 1000);
24
37
  }
package/dist/index.js CHANGED
@@ -14,7 +14,8 @@
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
+ import { tideStateAt, timeToNextExtreme } from "./calculations.js";
18
19
  import FileCache from "./cache.js";
19
20
  import { withVesselPosition } from "./middleware.js";
20
21
  // Recompute and republish on a fixed interval. Predictions are computed locally
@@ -24,6 +25,8 @@ const API_PATH = "/signalk/v2/api/tides";
24
25
  export default function (app) {
25
26
  let unsubscribes = [];
26
27
  let activeRouter = null;
28
+ let lastPosition = null;
29
+ let config = {};
27
30
  // Mount forwarding middleware once (Express doesn't support unmounting)
28
31
  // @ts-expect-error: app is an Express app at runtime
29
32
  app.use(API_PATH, (req, res, next) => {
@@ -41,7 +44,15 @@ export default function (app) {
41
44
  schema: () => ({
42
45
  title: "Tides",
43
46
  type: "object",
44
- 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
+ },
45
56
  }),
46
57
  start,
47
58
  stop() {
@@ -50,21 +61,59 @@ export default function (app) {
50
61
  activeRouter = null;
51
62
  },
52
63
  };
53
- 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) {
54
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.
55
101
  let lastForecast = null;
56
- let lastPosition = null;
102
+ let lastPredictor = null;
57
103
  const cache = new FileCache(app.getDataDirPath());
58
- // Mount the Neaps API, with vessel/current resolved to the nearest station.
59
- 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);
60
107
  // Register tide predictions as a resource provider
61
108
  app.registerResourceProvider({
62
109
  type: "tides",
63
110
  methods: {
64
111
  async listResources() {
65
- if (!lastPosition)
66
- throw new Error("No position available");
67
- 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);
68
117
  },
69
118
  getResource() {
70
119
  throw new Error("Not implemented");
@@ -89,11 +138,28 @@ export default function (app) {
89
138
  }, unsubscribes, (subscriptionError) => {
90
139
  app.error("Error:" + subscriptionError);
91
140
  }, updatePosition);
92
- 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) {
93
161
  const now = new Date();
94
- return getExtremesPrediction({
95
- latitude: position.latitude,
96
- longitude: position.longitude,
162
+ return predictor.getExtremesPrediction({
97
163
  start: now,
98
164
  end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
99
165
  });
@@ -109,16 +175,20 @@ export default function (app) {
109
175
  if (!lastPosition) {
110
176
  lastPosition = (await cache.get("position"));
111
177
  }
112
- if (lastPosition)
113
- updateForecast();
178
+ // A configured default station predicts without a position
179
+ updateForecast();
114
180
  }
115
181
  function updateForecast() {
116
- if (!lastPosition) {
117
- 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;
118
187
  return;
119
188
  }
120
189
  try {
121
- lastForecast = forecastFor(lastPosition);
190
+ lastForecast = forecastFor(predictor);
191
+ lastPredictor = predictor;
122
192
  app.setPluginStatus("Updated tide forecast");
123
193
  updateTides();
124
194
  }
@@ -130,17 +200,15 @@ export default function (app) {
130
200
  }
131
201
  }
132
202
  function updateTides(now = new Date()) {
133
- if (!lastForecast || !lastPosition)
203
+ if (!lastForecast || !lastPredictor)
134
204
  return;
135
205
  // Get the next two upcoming extremes
136
206
  const nextTides = lastForecast.extremes
137
207
  .filter(({ time }) => time >= now)
138
208
  .slice(0, 2);
139
- const heightNow = getWaterLevelAtTime({
140
- latitude: lastPosition.latitude,
141
- longitude: lastPosition.longitude,
142
- time: now,
143
- }).level;
209
+ const heightNow = lastPredictor.getWaterLevelAtTime({ time: now }).level;
210
+ const state = tideStateAt(lastForecast.extremes, now);
211
+ const secondsToNextExtreme = timeToNextExtreme(lastForecast.extremes, now);
144
212
  const delta = {
145
213
  context: ("vessels." + app.selfId),
146
214
  updates: [
@@ -155,6 +223,19 @@ export default function (app) {
155
223
  path: "environment.tide.heightNow",
156
224
  value: heightNow,
157
225
  },
226
+ // Omitted (not null) when the forecast has no upcoming extreme,
227
+ // matching how the nextTides flatMap below goes empty.
228
+ ...(state !== null
229
+ ? [{ path: "environment.tide.state", value: state }]
230
+ : []),
231
+ ...(secondsToNextExtreme !== null
232
+ ? [
233
+ {
234
+ path: "environment.tide.timeToNextExtreme",
235
+ value: secondsToNextExtreme,
236
+ },
237
+ ]
238
+ : []),
158
239
  ...nextTides.flatMap(({ label, time, level }) => {
159
240
  return [
160
241
  {
@@ -169,6 +250,24 @@ export default function (app) {
169
250
  }),
170
251
  ],
171
252
  },
253
+ {
254
+ timestamp: now.toISOString(),
255
+ meta: [
256
+ {
257
+ path: "environment.tide.state",
258
+ value: {
259
+ description: "Tide trend at the current position: rising or falling",
260
+ },
261
+ },
262
+ {
263
+ path: "environment.tide.timeToNextExtreme",
264
+ value: {
265
+ units: "s",
266
+ description: "Seconds until the next tide extreme (high or low water)",
267
+ },
268
+ },
269
+ ],
270
+ },
172
271
  ],
173
272
  };
174
273
  if (app.debug.enabled) {
@@ -185,3 +284,8 @@ export default function (app) {
185
284
  }
186
285
  return plugin;
187
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.0",
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
  },
@@ -53,7 +53,7 @@
53
53
  "@types/react": "^19.0.12",
54
54
  "@types/react-dom": "^19.0.4",
55
55
  "@types/supertest": "^7.2.0",
56
- "@vitejs/plugin-react": "^5.0.2",
56
+ "@vitejs/plugin-react": "^6.0.2",
57
57
  "@vitest/browser": "^4.0.18",
58
58
  "@vitest/browser-playwright": "^4.0.18",
59
59
  "@vitest/ui": "^4.0.18",