signalk-tides 1.2.3 → 1.4.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,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "npm"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 100
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: "weekly"
12
+ open-pull-requests-limit: 100
package/README.md CHANGED
@@ -18,6 +18,8 @@ It publishes the following [tide data](https://signalk.org/specification/1.7.0/d
18
18
  * `environment.tide.timeHigh`
19
19
  * `environment.tide.heightLow`
20
20
  * `environment.tide.timeLow`
21
+ * `environment.tide.heightNow`
22
+ * `environment.tide.stationName`
21
23
 
22
24
  ### Tides resource
23
25
 
package/dist/cache.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const path_1 = require("path");
4
+ const promises_1 = require("fs/promises");
5
+ class FileCache {
6
+ path;
7
+ constructor(path) {
8
+ this.path = path;
9
+ }
10
+ async get(key) {
11
+ try {
12
+ const content = await (0, promises_1.readFile)(this.getKey(key), 'utf-8');
13
+ return JSON.parse(content);
14
+ }
15
+ catch (err) {
16
+ return undefined;
17
+ }
18
+ }
19
+ async set(key, value) {
20
+ // Ensure directory exists
21
+ await (0, promises_1.mkdir)(this.path, { recursive: true });
22
+ return (0, promises_1.writeFile)(this.getKey(key), JSON.stringify(value));
23
+ }
24
+ getKey(key) {
25
+ return (0, path_1.join)(this.path, `${key}.json`);
26
+ }
27
+ }
28
+ exports.default = FileCache;
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.approximateTideHeightAt = approximateTideHeightAt;
4
+ /** Given a list of tide extremes, estimate the height at a specific time. */
5
+ function approximateTideHeightAt(extremes, time) {
6
+ const sorted = extremes.slice().sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
7
+ const prev = sorted.filter(h => new Date(h.time) <= time).at(-1);
8
+ const next = sorted.filter(h => new Date(h.time) >= time).at(0);
9
+ if (!prev)
10
+ throw new Error("Missing height data before " + time.toISOString());
11
+ if (!next)
12
+ throw new Error("Missing height data after " + time.toISOString());
13
+ const progress = (time.getTime() - new Date(prev.time).getTime()) /
14
+ (new Date(next.time).getTime() - new Date(prev.time).getTime());
15
+ const value = interpolate(prev.value, next.value, easeSine(progress));
16
+ return parseFloat(value.toFixed(3));
17
+ }
18
+ /** Interpolate between two values using the given progress (0-1). */
19
+ function interpolate(start, end, progress) {
20
+ return start + (end - start) * progress;
21
+ }
22
+ function easeSine(progress) {
23
+ // Map progress [0..1] to angle [0..π]
24
+ const angle = progress * Math.PI;
25
+ // Use sine to ease in/out
26
+ return (1 - Math.cos(angle)) / 2;
27
+ }
package/dist/index.js CHANGED
@@ -20,6 +20,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
20
20
  const noaa_js_1 = __importDefault(require("./sources/noaa.js"));
21
21
  const stormglass_js_1 = __importDefault(require("./sources/stormglass.js"));
22
22
  const worldtides_js_1 = __importDefault(require("./sources/worldtides.js"));
23
+ const calculations_js_1 = require("./calculations.js");
24
+ const cache_js_1 = __importDefault(require("./cache.js"));
23
25
  module.exports = function (app) {
24
26
  // Interval to update tide data
25
27
  const defaultPeriod = 60; // 1 hour
@@ -52,7 +54,7 @@ module.exports = function (app) {
52
54
  period: {
53
55
  title: "Update frequency",
54
56
  type: "number",
55
- description: "How often to update tide data (minutes)",
57
+ description: "How often to update tide forecast (minutes)",
56
58
  default: 60,
57
59
  minimum: 1,
58
60
  },
@@ -65,6 +67,9 @@ module.exports = function (app) {
65
67
  };
66
68
  plugin.start = async function (props) {
67
69
  app.debug("Starting tides-api: " + JSON.stringify(props));
70
+ let lastForecast = null;
71
+ let lastPosition = null;
72
+ const cache = new cache_js_1.default(app.getDataDirPath());
68
73
  // Use the selected source, or the first one if not specified
69
74
  const source = sources.find(source => source.id === props.source) || sources[0];
70
75
  // Load the selected source
@@ -74,7 +79,9 @@ module.exports = function (app) {
74
79
  type: "tides",
75
80
  methods: {
76
81
  async listResources(query) {
77
- return provider(query);
82
+ if (!lastPosition)
83
+ throw new Error("No position available");
84
+ return provider({ position: lastPosition, ...query });
78
85
  },
79
86
  getResource() {
80
87
  throw new Error("Not implemented");
@@ -98,46 +105,23 @@ module.exports = function (app) {
98
105
  ],
99
106
  }, unsubscribes, (subscriptionError) => {
100
107
  app.error("Error:" + subscriptionError);
101
- }, (delta) => {
102
- // @ts-expect-error: TODO[TS]: fix Delta type upstream
103
- delta.updates.forEach(({ values }) => {
104
- // @ts-expect-error: TODO[TS]: fix Delta type upstream
105
- values.forEach(({ path }) => {
106
- if (path === "navigation.position") {
107
- performUpdate();
108
- }
109
- });
110
- });
111
- });
112
- async function performUpdate() {
108
+ }, updatePosition);
109
+ async function updatePosition() {
110
+ lastPosition = app.getSelfPath('navigation.position.value') || await cache.get('position') || null;
111
+ if (lastPosition) {
112
+ await cache.set('position', lastPosition);
113
+ await updateForecast();
114
+ }
115
+ }
116
+ async function updateForecast() {
117
+ if (!lastPosition) {
118
+ app.debug("No position available, cannot fetch tide data");
119
+ return;
120
+ }
113
121
  try {
114
- const { extremes } = await provider();
115
- // Use server date, or current date if not available
116
- const now = new Date(app.getSelfPath("navigation.datetime.value") ?? Date.now());
117
- const nextTide = {};
118
- extremes.forEach(({ type, value, time }) => {
119
- // Get the first tide of this type after now
120
- if (!nextTide[type] && new Date(time) > now) {
121
- nextTide[type] = { time, value };
122
- }
123
- });
124
- const delta = {
125
- context: "vessels." + app.selfId,
126
- updates: [
127
- {
128
- timestamp: now.toISOString(),
129
- values: Object.entries(nextTide).flatMap(([type, { time, value }]) => {
130
- return [
131
- { path: `environment.tide.height${type}`, value: value },
132
- { path: `environment.tide.time${type}`, value: time },
133
- ];
134
- }),
135
- },
136
- ],
137
- };
138
- app.debug("Sending delta: " + JSON.stringify(delta));
139
- app.handleMessage(plugin.id, delta);
140
- app.setPluginStatus("Updated tide data");
122
+ lastForecast = await provider({ position: lastPosition });
123
+ app.setPluginStatus("Updated tide forecast from " + source.title);
124
+ updateTides();
141
125
  }
142
126
  catch (e) {
143
127
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -146,8 +130,42 @@ module.exports = function (app) {
146
130
  app.error(e);
147
131
  }
148
132
  }
133
+ async function updateTides(now = new Date()) {
134
+ if (!lastForecast)
135
+ return;
136
+ // Get the next two upcoming extremes
137
+ const nextTides = lastForecast.extremes.filter(({ time }) => new Date(time) >= now).slice(0, 2);
138
+ const delta = {
139
+ context: "vessels." + app.selfId,
140
+ updates: [
141
+ {
142
+ timestamp: now.toISOString(),
143
+ values: [
144
+ {
145
+ path: "environment.tide.stationName",
146
+ value: lastForecast.station.name
147
+ },
148
+ {
149
+ path: "environment.tide.heightNow",
150
+ value: (0, calculations_js_1.approximateTideHeightAt)(lastForecast.extremes, now)
151
+ },
152
+ ...nextTides.flatMap(({ type, time, value }) => {
153
+ return [
154
+ { path: `environment.tide.height${type}`, value },
155
+ { path: `environment.tide.time${type}`, value: time },
156
+ ];
157
+ })
158
+ ]
159
+ },
160
+ ],
161
+ };
162
+ app.debug("Sending delta: " + JSON.stringify(delta));
163
+ app.handleMessage(plugin.id, delta);
164
+ }
149
165
  // Perform initial update on startup after short delay to allow gnss position to be populated
150
- delay(4000).then(() => { performUpdate(); });
166
+ delay(4000).then(updatePosition);
167
+ // Update every minute
168
+ setInterval(updateTides, 60 * 1000);
151
169
  };
152
170
  function delay(time) {
153
171
  return new Promise((resolve) => setTimeout(resolve, time));
@@ -5,9 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.default = default_1;
7
7
  const path_1 = __importDefault(require("path"));
8
- const promises_1 = __importDefault(require("fs/promises"));
8
+ const promises_1 = require("fs/promises");
9
9
  const geolib_1 = require("geolib");
10
10
  const moment_1 = __importDefault(require("moment"));
11
+ const cache_js_1 = __importDefault(require("../cache.js"));
11
12
  const stationsUrl = `https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=tidepredictions`;
12
13
  const dataGetterUrl = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
13
14
  const datum = 'MLLW';
@@ -16,11 +17,12 @@ function default_1(app) {
16
17
  id: 'noaa',
17
18
  title: 'NOAA (US only)',
18
19
  async start() {
19
- const stations = await StationList.load(app);
20
- return async ({ date } = {}) => {
21
- const position = app.getSelfPath("navigation.position.value");
22
- if (!position)
23
- throw new Error("no position");
20
+ const cache = new cache_js_1.default(path_1.default.join(app.getDataDirPath(), 'noaa'));
21
+ const stations = await StationList.load(cache, app);
22
+ // Remove old cache file
23
+ (0, promises_1.unlink)(path_1.default.join(app.config.configPath, 'noaastations.json')).catch(() => { });
24
+ return async (params) => {
25
+ const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
24
26
  const station = stations.closestTo(position);
25
27
  const endpoint = new URL(dataGetterUrl);
26
28
  endpoint.search = new URLSearchParams({
@@ -71,21 +73,18 @@ function default_1(app) {
71
73
  }
72
74
  ;
73
75
  class StationList extends Map {
74
- static async load(app) {
75
- const filename = path_1.default.join(app.config.configPath, "noaastations.json");
76
- let data;
77
- try {
78
- data = JSON.parse(await promises_1.default.readFile(filename, 'utf-8'));
79
- app.debug("NOAA: Loaded cached tide stations from " + filename);
76
+ static async load(cache, app) {
77
+ let data = await cache.get('stations');
78
+ if (data) {
79
+ app.debug("NOAA: Loaded cached tide stations");
80
80
  }
81
- catch (e) {
82
- app.debug(`NOAA: failed to load cached tide stations: ${e}`);
81
+ else {
83
82
  app.debug('NOAA: Downloading tide stations');
84
83
  const res = await fetch(stationsUrl);
85
84
  if (!res.ok)
86
85
  throw new Error(`Failed to download stations: ${res.statusText}`);
87
86
  data = await res.json();
88
- await promises_1.default.writeFile(filename, JSON.stringify(data));
87
+ await cache.set('stations', data);
89
88
  }
90
89
  return new this(data.stations);
91
90
  }
@@ -17,18 +17,14 @@ function default_1(app) {
17
17
  },
18
18
  start({ stormglassApiKey } = {}) {
19
19
  app.debug("Using StormGlass.io API");
20
- return async (params = {}) => {
21
- const { date = (0, moment_1.default)().subtract(1, "days").toISOString() } = params;
20
+ return async ({ position, date = (0, moment_1.default)().subtract(1, "days").toISOString() }) => {
22
21
  const endPoint = new URL("https://api.stormglass.io/v2/tide/extremes/point");
23
- const position = app.getSelfPath("navigation.position.value");
24
- if (!position)
25
- throw new Error("no position");
26
22
  endPoint.search = new URLSearchParams({
27
23
  start: (0, moment_1.default)(date).format("YYYY-MM-DD"),
28
24
  end: (0, moment_1.default)(date).add(7, "days").format("YYYY-MM-DD"),
29
25
  // datum: "CD",
30
- lat: position.latitude,
31
- lng: position.longitude,
26
+ lat: position.latitude.toString(),
27
+ lng: position.longitude.toString()
32
28
  }).toString();
33
29
  app.debug("Fetching StormGlass.io: " + endPoint.toString());
34
30
  const res = await fetch(endPoint, {
@@ -40,7 +36,7 @@ function default_1(app) {
40
36
  app.debug(JSON.stringify(data, null, 2));
41
37
  return {
42
38
  station: {
43
- name: data.meta.station.name,
39
+ name: `${data.meta.station.name} (${data.meta.station.source})`,
44
40
  position: {
45
41
  latitude: data.meta.station.lat,
46
42
  longitude: data.meta.station.lng,
@@ -17,20 +17,17 @@ function default_1(app) {
17
17
  },
18
18
  start({ worldtidesApiKey = '' } = {}) {
19
19
  app.debug("Using WorldTides API");
20
- return async (params = {}) => {
21
- const { date = (0, moment_1.default)().subtract(1, "days") } = params;
20
+ return async (params) => {
21
+ const { position, date = (0, moment_1.default)().subtract(1, "days") } = params;
22
22
  const endPoint = new URL("https://www.worldtides.info/api/v3");
23
- const position = app.getSelfPath("navigation.position.value");
24
- if (!position)
25
- throw new Error("no position");
26
23
  endPoint.search = new URLSearchParams({
27
24
  date: (0, moment_1.default)(date).format("YYYY-MM-DD"),
28
25
  datum: "CD",
29
26
  days: "7",
30
27
  extremes: "true",
31
28
  key: worldtidesApiKey,
32
- lat: position.latitude,
33
- lon: position.longitude,
29
+ lat: position.latitude.toString(),
30
+ lon: position.longitude.toString(),
34
31
  }).toString();
35
32
  app.debug("Fetching worldtides: " + endPoint.toString());
36
33
  const res = await fetch(endPoint);
@@ -42,7 +39,7 @@ function default_1(app) {
42
39
  throw new Error("worldtides data: " + data.error ? data.error : "none");
43
40
  return {
44
41
  station: {
45
- name: "WorldTides",
42
+ name: `${data.station} (${data.atlas})`,
46
43
  position: {
47
44
  latitude: data.responseLat,
48
45
  longitude: data.responseLon,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-tides",
3
- "version": "1.2.3",
3
+ "version": "1.4.0",
4
4
  "description": "Tidal predictions for the vessel's position from various online sources.",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -38,24 +38,22 @@
38
38
  "@eslint/js": "^9.21.0",
39
39
  "@tailwindcss/vite": "^4.1.3",
40
40
  "@types/d3": "^7.4.3",
41
- "@types/node": "^22.13.11",
41
+ "@types/node": "^24.3.1",
42
42
  "@types/react": "^19.0.12",
43
43
  "@types/react-dom": "^19.0.4",
44
- "@types/recharts": "^1.8.29",
45
- "@vitejs/plugin-react": "^4.3.4",
44
+ "@vitejs/plugin-react": "^5.0.2",
46
45
  "d3": "^7.9.0",
47
46
  "eslint": "^9.21.0",
48
47
  "eslint-plugin-react-hooks": "^5.1.0",
49
48
  "eslint-plugin-react-refresh": "^0.4.19",
50
- "globals": "^15.15.0",
49
+ "globals": "^16.3.0",
51
50
  "react": "^19.1.0",
52
51
  "react-dom": "^19.1.0",
53
- "recharts": "^2.15.1",
54
52
  "signalk-server": "^2.13.5",
55
53
  "tailwindcss": "^4.1.3",
56
54
  "typescript": "^5.8.2",
57
55
  "typescript-eslint": "^8.24.1",
58
- "vite": "^6.2.0"
56
+ "vite": "^7.1.5"
59
57
  },
60
58
  "repository": {
61
59
  "type": "git",