signalk-tides 1.5.0 → 2.0.0-beta.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.
@@ -20,8 +20,8 @@ jobs:
20
20
  - name: Run linter
21
21
  run: npm run lint
22
22
 
23
- build:
24
- name: Build
23
+ test:
24
+ name: Test
25
25
  runs-on: ubuntu-latest
26
26
  env:
27
27
  CI: true
@@ -35,5 +35,31 @@ jobs:
35
35
  - name: Install dependencies
36
36
  run: npm install
37
37
 
38
+ - name: Install Playwright browsers
39
+ run: npx playwright install chromium
40
+
38
41
  - name: Run build
39
42
  run: npm run build
43
+
44
+ - name: Run Tests
45
+ run: npm run test
46
+
47
+ publish:
48
+ needs: [lint, test]
49
+ runs-on: ubuntu-latest
50
+ permissions:
51
+ id-token: write
52
+ pull-requests: write
53
+ steps:
54
+ - uses: actions/checkout@v6
55
+
56
+ - uses: actions/setup-node@v6
57
+
58
+ - name: Install modules
59
+ run: npm install
60
+
61
+ - name: Build
62
+ run: npm run build
63
+
64
+ - name: Publish to pkg.pr.new
65
+ run: npx pkg-pr-new publish
@@ -0,0 +1 @@
1
+ [ 2058ms] [ERROR] Failed to load resource: net::ERR_NETWORK_IO_SUSPENDED @ https://docs.stormglass.io/_media/logo.svg:0
@@ -0,0 +1 @@
1
+ [ 239ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3000/signalk-tides/assets/icon.svg:0
@@ -0,0 +1,44 @@
1
+ [ 260ms] [LOG] [ConnectionStateMachine] Service initialized @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
2
+ [ 265ms] [LOG] [AppInit Network Service] Connection Configuration not found. Creating configuration using Auto-Discovery URL: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
3
+ [ 268ms] [LOG] [ConnectionStateMachine] Starting HTTP discovery: Connecting to http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
4
+ [ 268ms] [LOG] [Connection Service] Connecting to: http://localhost:3000 @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
5
+ [ 279ms] [LOG] [ConnectionStateMachine] HTTP discovery successful @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
6
+ [ 279ms] [LOG] [AppSettings Service] Service startup... @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:3
7
+ [ 279ms] [LOG] [AppSettings Service] LocalStorage enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
8
+ [ 279ms] [LOG] [AppSettings Service] Error loading appConfig config. Force loading appConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
9
+ [ 279ms] [LOG] [AppSettings Service] Error loading dashboardsConfig config. Force loading dashboardsConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
10
+ [ 279ms] [LOG] [AppSettings Service] Error loading themeConfig config. Force loading themeConfig defaults @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
11
+ [ 280ms] [LOG] [AppSettings Service] Saving Application config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
12
+ [ 280ms] [LOG] [DatasetStreamService] Auto Starting 0 Datasets @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
13
+ [ 280ms] [LOG] [AppInit Network Service] Initialization completed @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
14
+ [ 280ms] [LOG] [ConnectionStateMachine] WebSocket mode enabled @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
15
+ [ 280ms] [LOG] [AppInit Network Service] Starting WebSocket connection after initialization @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
16
+ [ 280ms] [LOG] [ConnectionStateMachine] Starting WebSocket connection @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
17
+ [ 280ms] [LOG] [Delta Service] ConnectionStateMachine request: WebSocket opening... @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
18
+ [ 287ms] [WARNING] [Dashboard Service] No dashboards found in settings, creating blank dashboard @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:0
19
+ [ 289ms] [LOG] [Remote Dashboards] Cleaning paths on server @ http://localhost:3000/@mxtommy/kip/main-UBENKC2D.js:0
20
+ [ 290ms] [LOG] [UI Event Service] PWA mode: false @ http://localhost:3000/@mxtommy/kip/chunk-5SMHTIVS.js:49
21
+ [ 300ms] [LOG] *********** KIP Version Information *********** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
22
+ [ 300ms] [LOG] ** App Version: 4.8.0 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
23
+ [ 300ms] [LOG] ** Browser Version: Chrome 149 @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
24
+ [ 300ms] [LOG] ** OS Version: macOS @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
25
+ [ 300ms] [LOG] *********************************************** @ http://localhost:3000/@mxtommy/kip/chunk-NS2OV2OW.js:0
26
+ [ 305ms] [LOG] [AppSettings Service] Saving Dashboard config to LocalStorage @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:4
27
+ [ 323ms] [LOG] [Delta Service] WebSocket connected without Authorization Token @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
28
+ [ 324ms] [LOG] [ConnectionStateMachine] WebSocket connected @ http://localhost:3000/@mxtommy/kip/chunk-2TP7C66X.js:0
29
+ [ 324ms] [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
30
+ [ 437ms] [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
31
+ [ 437ms] [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
32
+ [ 437ms] [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
33
+ [ 450ms] [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
34
+ [ 515ms] [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
35
+ [ 1311ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/freeboard-sk:0
36
+ [ 1387ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip:0
37
+ [ 1387ms] [LOG] [KipSeriesApiClientService] POST http://localhost:3000/plugins/kip/series/reconcile (0 series) @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
38
+ [ 1403ms] [INFO] Internet connection detected. @ http://localhost:3000/@signalk/freeboard-sk/main-YHMZXR5G.js:249
39
+ [ 1414ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3000/plugins/kip/series/reconcile:0
40
+ [ 1414ms] [ERROR] [KipSeriesApiClientService] Series reconcile request failed: Ar @ http://localhost:3000/@mxtommy/kip/chunk-6ZR3M3IQ.js:2
41
+ [ 1517ms] [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
42
+ [ 1784ms] [WARNING] Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users. Avoid using aria-hidden on a focused element or its ancestor. Consider using the inert attribute instead, which will also prevent focus. For more details, see the aria-hidden section of the WAI-ARIA specification at https://w3c.github.io/aria/#aria-hidden.
43
+ Element with focus: <div>
44
+ Ancestor with aria-hidden: <app-root> @ http://localhost:3000/@signalk/freeboard-sk/:0
@@ -0,0 +1 @@
1
+ [ 68ms] [LOG] [Delta Service] App suspended @ http://localhost:3000/@mxtommy/kip/chunk-IXUKD73N.js:0
package/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # signalk-tides
2
2
 
3
- A SignalK plugin that provides tidal predictions for the vessel's position from various [sources](#sources).
3
+ A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Neaps](https://github.com/neaps/neaps).
4
+
5
+ Since 2.0, predictions are computed locally from harmonic constituents — no network access or API keys required.
4
6
 
5
7
  ## Installation
6
8
 
7
- 1. Install `signalk-tides` from the SignalK Appstore or manually by running `npm install signalk-tides` in the SignalK server directory (`~/.signalk`).
9
+ Install `signalk-tides` from the SignalK Appstore or manually by running `npm install signalk-tides` in the SignalK server directory (`~/.signalk`).
8
10
 
9
- 2. Optionally go to the plugin settings in "Server => Plugin Config => Tides" and configure which source to use.
10
11
 
11
12
  ## Usage
12
13
 
@@ -21,204 +22,41 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
21
22
  * `environment.tide.heightNow`
22
23
  * `environment.tide.stationName`
23
24
 
24
- ### Tides resource
25
+ ### Tides API
25
26
 
26
- It also registers a `tides` resource, which returns tide predictions for yesterday and the next 6 days for the vessel's position.
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:
27
28
 
28
29
  ```
29
- $ curl http://localhost:3000/signalk/v2/api/resources/tides
30
+ $ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/current/extremes
30
31
  ```
31
32
 
32
- ##### Request Parameters
33
+ ### Tides resource
33
34
 
34
- * `date` (optional) - the date for which to get the tide predictions in the format `YYYY-MM-DD`. Default: yesterday's date.
35
+ It also registers a `tides` resource, which returns the next 7 days of tide extremes for the vessel's position.
36
+
37
+ ```
38
+ $ curl http://localhost:3000/signalk/v2/api/resources/tides
39
+ ```
35
40
 
36
41
  ##### Response
37
42
 
38
43
  ```json
39
44
  {
40
- "extremes" : [
41
- {
42
- "time" : "2025-03-29T00:45:00.000Z",
43
- "type" : "Low",
44
- "value" : 0.025
45
- },
46
- {
47
- "time" : "2025-03-29T07:20:00.000Z",
48
- "type" : "High",
49
- "value" : 1.928
50
- },
51
- {
52
- "time" : "2025-03-29T13:18:00.000Z",
53
- "type" : "Low",
54
- "value" : 0.044
55
- },
56
- {
57
- "time" : "2025-03-29T19:45:00.000Z",
58
- "type" : "High",
59
- "value" : 1.815
60
- },
61
- {
62
- "time" : "2025-03-30T01:24:00.000Z",
63
- "type" : "Low",
64
- "value" : 0.164
65
- },
66
- {
67
- "time" : "2025-03-30T07:52:00.000Z",
68
- "type" : "High",
69
- "value" : 2.024
70
- },
71
- {
72
- "time" : "2025-03-30T14:05:00.000Z",
73
- "type" : "Low",
74
- "value" : -0.131
75
- },
76
- {
77
- "time" : "2025-03-30T20:43:00.000Z",
78
- "type" : "High",
79
- "value" : 1.733
80
- },
81
- {
82
- "time" : "2025-03-31T02:05:00.000Z",
83
- "type" : "Low",
84
- "value" : 0.334
85
- },
86
- {
87
- "time" : "2025-03-31T08:27:00.000Z",
88
- "type" : "High",
89
- "value" : 2.084
90
- },
91
- {
92
- "time" : "2025-03-31T14:53:00.000Z",
93
- "type" : "Low",
94
- "value" : -0.247
95
- },
96
- {
97
- "time" : "2025-03-31T21:44:00.000Z",
98
- "type" : "High",
99
- "value" : 1.632
100
- },
101
- {
102
- "time" : "2025-04-01T02:48:00.000Z",
103
- "type" : "Low",
104
- "value" : 0.517
105
- },
106
- {
107
- "time" : "2025-04-01T09:06:00.000Z",
108
- "type" : "High",
109
- "value" : 2.094
110
- },
111
- {
112
- "time" : "2025-04-01T15:46:00.000Z",
113
- "type" : "Low",
114
- "value" : -0.29
115
- },
116
- {
117
- "time" : "2025-04-01T22:51:00.000Z",
118
- "type" : "High",
119
- "value" : 1.529
120
- },
121
- {
122
- "time" : "2025-04-02T03:37:00.000Z",
123
- "type" : "Low",
124
- "value" : 0.695
125
- },
126
- {
127
- "time" : "2025-04-02T09:49:00.000Z",
128
- "type" : "High",
129
- "value" : 2.05
130
- },
131
- {
132
- "time" : "2025-04-02T16:43:00.000Z",
133
- "type" : "Low",
134
- "value" : -0.263
135
- },
136
- {
137
- "time" : "2025-04-03T00:04:00.000Z",
138
- "type" : "High",
139
- "value" : 1.448
140
- },
141
- {
142
- "time" : "2025-04-03T04:35:00.000Z",
143
- "type" : "Low",
144
- "value" : 0.847
145
- },
146
- {
147
- "time" : "2025-04-03T10:39:00.000Z",
148
- "type" : "High",
149
- "value" : 1.957
150
- },
151
- {
152
- "time" : "2025-04-03T17:50:00.000Z",
153
- "type" : "Low",
154
- "value" : -0.189
155
- },
156
- {
157
- "time" : "2025-04-04T01:24:00.000Z",
158
- "type" : "High",
159
- "value" : 1.421
160
- },
161
- {
162
- "time" : "2025-04-04T05:49:00.000Z",
163
- "type" : "Low",
164
- "value" : 0.946
165
- },
166
- {
167
- "time" : "2025-04-04T11:40:00.000Z",
168
- "type" : "High",
169
- "value" : 1.837
170
- },
171
- {
172
- "time" : "2025-04-04T19:07:00.000Z",
173
- "type" : "Low",
174
- "value" : -0.11
175
- },
176
- {
177
- "time" : "2025-04-05T02:40:00.000Z",
178
- "type" : "High",
179
- "value" : 1.455
180
- },
181
- {
182
- "time" : "2025-04-05T07:19:00.000Z",
183
- "type" : "Low",
184
- "value" : 0.958
185
- },
186
- {
187
- "time" : "2025-04-05T12:53:00.000Z",
188
- "type" : "High",
189
- "value" : 1.728
190
- },
191
- {
192
- "time" : "2025-04-05T20:25:00.000Z",
193
- "type" : "Low",
194
- "value" : -0.061
195
- }
196
- ],
197
- "station" : {
198
- "name" : "Rincon Point, Pier 22 1/2",
199
- "position" : {
200
- "latitude" : 37.79,
201
- "longitude" : -122.387
202
- }
203
- }
45
+ "datum": "MLLW",
46
+ "units": "meters",
47
+ "station": {
48
+ "id": "noaa/9414290",
49
+ "name": "San Francisco",
50
+ "latitude": 37.806,
51
+ "longitude": -122.465
52
+ },
53
+ "extremes": [
54
+ { "time": "2025-03-29T00:45:00.000Z", "level": 0.025, "high": false, "low": true, "label": "Low" },
55
+ { "time": "2025-03-29T07:20:00.000Z", "level": 1.928, "high": true, "low": false, "label": "High" }
56
+ ]
204
57
  }
205
58
  ```
206
59
 
207
- ## Sources
208
-
209
- - [Neaps](https://github.com/neaps/neaps)
210
- - Offline, open source, runs locally
211
- - Coverage: Regional — United States and territories (currently)
212
- - [NOAA](https://tidesandcurrents.noaa.gov/web_services_info.html)
213
- - Online (NOAA web services)
214
- - Regional — United States and territories
215
- - [WorldTides API](https://www.worldtides.info/)
216
- - Online (requires API key, subject to API quotas)
217
- - Coverage: Global - station and model coverage varies by location
218
- - [StormGlass.io](https://stormglass.io/)
219
- - Online (requires API key)
220
- - Coverage: Global - aggregated marine data; tide availability varies
221
-
222
60
  ## License
223
61
 
224
62
  This plugin is a fork of the [signalk-tides-api](https://github.com/joabakk/signalk-tides-api) plugin (which is no longer working) and is licensed under the [Apache License 2.0](LICENSE). Kudos to @joabakk and @sbender9 for the original work.
@@ -1,15 +1,15 @@
1
1
  /** Given a list of tide extremes, estimate the height at a specific time. */
2
2
  export function approximateTideHeightAt(extremes, time) {
3
- const sorted = extremes.slice().sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
4
- const prev = sorted.filter(h => new Date(h.time) <= time).at(-1);
5
- const next = sorted.filter(h => new Date(h.time) >= time).at(0);
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
6
  if (!prev)
7
7
  throw new Error("Missing height data before " + time.toISOString());
8
8
  if (!next)
9
9
  throw new Error("Missing height data after " + time.toISOString());
10
- const progress = (time.getTime() - new Date(prev.time).getTime()) /
11
- (new Date(next.time).getTime() - new Date(prev.time).getTime());
12
- const value = interpolate(prev.value, next.value, easeSine(progress));
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
13
  return parseFloat(value.toFixed(3));
14
14
  }
15
15
  /** Interpolate between two values using the given progress (0-1). */
package/dist/index.js CHANGED
@@ -13,65 +13,58 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { approximateTideHeightAt } from "./calculations.js";
16
+ import { createRoutes } from "@neaps/api";
17
+ import { getExtremesPrediction, getWaterLevelAtTime } from "neaps";
17
18
  import FileCache from "./cache.js";
18
- import createSources from "./sources/index.js";
19
+ import { withVesselPosition } from "./middleware.js";
20
+ // Recompute and republish on a fixed interval. Predictions are computed locally
21
+ // by neaps, so this is cheap and needs no configuration.
22
+ const UPDATE_INTERVAL = 60 * 1000; // 1 minute
23
+ const API_PATH = "/signalk/v2/api/tides";
19
24
  export default function (app) {
20
- // Interval to update tide data
21
- const defaultPeriod = 60; // 1 hour
22
25
  let unsubscribes = [];
23
- const sources = createSources(app);
26
+ let activeRouter = null;
27
+ // Mount forwarding middleware once (Express doesn't support unmounting)
28
+ // @ts-expect-error: app is an Express app at runtime
29
+ app.use(API_PATH, (req, res, next) => {
30
+ if (activeRouter) {
31
+ activeRouter(req, res, next);
32
+ }
33
+ else {
34
+ next();
35
+ }
36
+ });
24
37
  const plugin = {
25
38
  id: "tides",
26
39
  name: "Tides",
27
- description: "Tidal predictions for the vessel's position from various online sources.",
40
+ description: "Offline tidal predictions for the vessel's position, powered by Neaps.",
28
41
  schema: () => ({
29
- title: "Tides API",
42
+ title: "Tides",
30
43
  type: "object",
31
- properties: {
32
- source: {
33
- title: "Data source",
34
- type: "string",
35
- anyOf: sources.map(({ id, title }) => ({
36
- const: id,
37
- title,
38
- })),
39
- default: sources[0].id,
40
- },
41
- // Update plugin schema with sources
42
- ...sources.reduce((properties, source) => Object.assign(properties, source.properties ?? {}), {}),
43
- period: {
44
- title: "Update frequency",
45
- type: "number",
46
- description: "How often to update tide forecast (minutes)",
47
- default: 60,
48
- minimum: 1,
49
- },
50
- },
44
+ properties: {},
51
45
  }),
52
46
  start,
53
47
  stop() {
54
48
  unsubscribes.forEach((f) => f());
55
49
  unsubscribes = [];
50
+ activeRouter = null;
56
51
  },
57
52
  };
58
- async function start(props) {
59
- app.debug("Starting tides-api: " + JSON.stringify(props));
53
+ async function start() {
54
+ app.debug("Starting tides");
60
55
  let lastForecast = null;
61
56
  let lastPosition = null;
62
57
  const cache = new FileCache(app.getDataDirPath());
63
- // Use the selected source, or the first one if not specified
64
- const source = sources.find((source) => source.id === props.source) || sources[0];
65
- // Load the selected source
66
- const provider = await source.start(props);
67
- // Register the source as a resource provider
58
+ // Mount the Neaps API, with vessel/current resolved to the nearest station.
59
+ activeRouter = withVesselPosition(createRoutes({ prefix: API_PATH }), () => lastPosition);
60
+ // Register tide predictions as a resource provider
68
61
  app.registerResourceProvider({
69
62
  type: "tides",
70
63
  methods: {
71
- async listResources(query) {
64
+ async listResources() {
72
65
  if (!lastPosition)
73
66
  throw new Error("No position available");
74
- return provider({ position: lastPosition, ...query });
67
+ return forecastFor(lastPosition);
75
68
  },
76
69
  getResource() {
77
70
  throw new Error("Not implemented");
@@ -89,29 +82,44 @@ export default function (app) {
89
82
  subscribe: [
90
83
  {
91
84
  path: "navigation.position",
92
- period: (props.period ?? defaultPeriod) * 60 * 1000,
85
+ period: UPDATE_INTERVAL,
93
86
  policy: "fixed",
94
87
  },
95
88
  ],
96
89
  }, unsubscribes, (subscriptionError) => {
97
90
  app.error("Error:" + subscriptionError);
98
91
  }, updatePosition);
92
+ function forecastFor(position) {
93
+ const now = new Date();
94
+ return getExtremesPrediction({
95
+ latitude: position.latitude,
96
+ longitude: position.longitude,
97
+ start: now,
98
+ end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
99
+ });
100
+ }
99
101
  async function updatePosition() {
100
- lastPosition =
101
- app.getSelfPath("navigation.position.value") || (await cache.get("position")) || null;
102
- if (lastPosition) {
102
+ const newPosition = app.getSelfPath("navigation.position.value");
103
+ // New position received, save it to the cache
104
+ if (newPosition) {
105
+ lastPosition = newPosition;
103
106
  await cache.set("position", lastPosition);
104
- await updateForecast();
105
107
  }
108
+ // No last known position, try to load from cache.
109
+ if (!lastPosition) {
110
+ lastPosition = (await cache.get("position"));
111
+ }
112
+ if (lastPosition)
113
+ updateForecast();
106
114
  }
107
- async function updateForecast() {
115
+ function updateForecast() {
108
116
  if (!lastPosition) {
109
- app.debug("No position available, cannot fetch tide data");
117
+ app.debug("No position available, cannot compute tide data");
110
118
  return;
111
119
  }
112
120
  try {
113
- lastForecast = await provider({ position: lastPosition });
114
- app.setPluginStatus("Updated tide forecast from " + source.title);
121
+ lastForecast = forecastFor(lastPosition);
122
+ app.setPluginStatus("Updated tide forecast");
115
123
  updateTides();
116
124
  }
117
125
  catch (e) {
@@ -121,13 +129,18 @@ export default function (app) {
121
129
  app.error(e);
122
130
  }
123
131
  }
124
- async function updateTides(now = new Date()) {
125
- if (!lastForecast)
132
+ function updateTides(now = new Date()) {
133
+ if (!lastForecast || !lastPosition)
126
134
  return;
127
135
  // Get the next two upcoming extremes
128
136
  const nextTides = lastForecast.extremes
129
- .filter(({ time }) => new Date(time) >= now)
137
+ .filter(({ time }) => time >= now)
130
138
  .slice(0, 2);
139
+ const heightNow = getWaterLevelAtTime({
140
+ latitude: lastPosition.latitude,
141
+ longitude: lastPosition.longitude,
142
+ time: now,
143
+ }).level;
131
144
  const delta = {
132
145
  context: ("vessels." + app.selfId),
133
146
  updates: [
@@ -140,28 +153,35 @@ export default function (app) {
140
153
  },
141
154
  {
142
155
  path: "environment.tide.heightNow",
143
- value: approximateTideHeightAt(lastForecast.extremes, now),
156
+ value: heightNow,
144
157
  },
145
- ...nextTides.flatMap(({ type, time, value }) => {
158
+ ...nextTides.flatMap(({ label, time, level }) => {
146
159
  return [
147
- { path: `environment.tide.height${type}`, value },
148
- { path: `environment.tide.time${type}`, value: time },
160
+ {
161
+ path: `environment.tide.height${label}`,
162
+ value: level,
163
+ },
164
+ {
165
+ path: `environment.tide.time${label}`,
166
+ value: time.toISOString(),
167
+ },
149
168
  ];
150
169
  }),
151
170
  ],
152
171
  },
153
172
  ],
154
173
  };
155
- app.debug("Sending delta: " + JSON.stringify(delta));
174
+ if (app.debug.enabled) {
175
+ app.debug("Sending delta: " + JSON.stringify(delta));
176
+ }
156
177
  app.handleMessage(plugin.id, delta);
157
178
  }
158
179
  // Perform initial update on startup after short delay to allow gnss position to be populated
159
- delay(4000).then(updatePosition);
160
- // Update every minute
161
- setInterval(updateTides, 60 * 1000);
162
- }
163
- function delay(time) {
164
- return new Promise((resolve) => setTimeout(resolve, time));
180
+ const startupTimer = setTimeout(updatePosition, 4000);
181
+ unsubscribes.push(() => clearTimeout(startupTimer));
182
+ // Recompute the current height and next tides every minute
183
+ const updateTimer = setInterval(updateTides, UPDATE_INTERVAL);
184
+ unsubscribes.push(() => clearInterval(updateTimer));
165
185
  }
166
186
  return plugin;
167
187
  }
@@ -0,0 +1,49 @@
1
+ import { nearestStation } from 'neaps';
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.
6
+ */
7
+ export function withVesselPosition(router, getPosition) {
8
+ 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) {
12
+ const pos = getPosition();
13
+ if (!pos) {
14
+ return res.status(503).json({
15
+ message: "Vessel position not available",
16
+ });
17
+ }
18
+ try {
19
+ const station = nearestStation({
20
+ latitude: pos.latitude,
21
+ longitude: pos.longitude,
22
+ });
23
+ if (!station) {
24
+ return res.status(404).json({
25
+ message: "No nearby stations found",
26
+ });
27
+ }
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}`);
31
+ }
32
+ catch {
33
+ return res.status(500).json({
34
+ message: "Failed to find nearest station",
35
+ });
36
+ }
37
+ }
38
+ // Inject default position for location-based queries
39
+ const isLocationQueryPath = req.path === "/extremes" || req.path === "/timeline";
40
+ if (isLocationQueryPath && !req.query.latitude && !req.query.longitude) {
41
+ const pos = getPosition();
42
+ if (pos) {
43
+ req.query.latitude = String(pos.latitude);
44
+ req.query.longitude = String(pos.longitude);
45
+ }
46
+ }
47
+ router(req, res, next);
48
+ };
49
+ }