signalk-tides 1.3.0 → 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;
package/dist/index.js CHANGED
@@ -21,11 +21,11 @@ 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
23
  const calculations_js_1 = require("./calculations.js");
24
+ const cache_js_1 = __importDefault(require("./cache.js"));
24
25
  module.exports = function (app) {
25
26
  // Interval to update tide data
26
27
  const defaultPeriod = 60; // 1 hour
27
28
  let unsubscribes = [];
28
- let lastForecast = null;
29
29
  const sources = [
30
30
  (0, noaa_js_1.default)(app),
31
31
  (0, stormglass_js_1.default)(app),
@@ -67,6 +67,9 @@ module.exports = function (app) {
67
67
  };
68
68
  plugin.start = async function (props) {
69
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());
70
73
  // Use the selected source, or the first one if not specified
71
74
  const source = sources.find(source => source.id === props.source) || sources[0];
72
75
  // Load the selected source
@@ -76,7 +79,9 @@ module.exports = function (app) {
76
79
  type: "tides",
77
80
  methods: {
78
81
  async listResources(query) {
79
- return provider(query);
82
+ if (!lastPosition)
83
+ throw new Error("No position available");
84
+ return provider({ position: lastPosition, ...query });
80
85
  },
81
86
  getResource() {
82
87
  throw new Error("Not implemented");
@@ -100,45 +105,23 @@ module.exports = function (app) {
100
105
  ],
101
106
  }, unsubscribes, (subscriptionError) => {
102
107
  app.error("Error:" + subscriptionError);
103
- }, (delta) => {
104
- // @ts-expect-error: TODO[TS]: fix Delta type upstream
105
- delta.updates.forEach(({ values }) => {
106
- // @ts-expect-error: TODO[TS]: fix Delta type upstream
107
- values.forEach(({ path }) => {
108
- if (path === "navigation.position") {
109
- updateForecast();
110
- }
111
- });
112
- });
113
- });
114
- async function updateForecast(now = new Date()) {
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
+ }
115
121
  try {
116
- lastForecast = await provider();
117
- const nextTide = {};
118
- lastForecast.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
- updateHeightNow();
141
- app.setPluginStatus("Updated tide data");
122
+ lastForecast = await provider({ position: lastPosition });
123
+ app.setPluginStatus("Updated tide forecast from " + source.title);
124
+ updateTides();
142
125
  }
143
126
  catch (e) {
144
127
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -147,25 +130,42 @@ module.exports = function (app) {
147
130
  app.error(e);
148
131
  }
149
132
  }
150
- async function updateHeightNow(now = new Date()) {
133
+ async function updateTides(now = new Date()) {
151
134
  if (!lastForecast)
152
135
  return;
153
- // Combine heights and extremes to account for min/max being between height samples
154
- const height = (0, calculations_js_1.approximateTideHeightAt)(lastForecast.extremes, now);
155
- app.handleMessage(plugin.id, {
136
+ // Get the next two upcoming extremes
137
+ const nextTides = lastForecast.extremes.filter(({ time }) => new Date(time) >= now).slice(0, 2);
138
+ const delta = {
156
139
  context: "vessels." + app.selfId,
157
140
  updates: [
158
141
  {
159
142
  timestamp: now.toISOString(),
160
- values: [{ path: "environment.tide.heightNow", value: height }]
161
- }
162
- ]
163
- });
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
164
  }
165
165
  // Perform initial update on startup after short delay to allow gnss position to be populated
166
- delay(4000).then(() => { updateForecast(); });
167
- // Update heightNow every minute
168
- setInterval(updateHeightNow, 60 * 1000);
166
+ delay(4000).then(updatePosition);
167
+ // Update every minute
168
+ setInterval(updateTides, 60 * 1000);
169
169
  };
170
170
  function delay(time) {
171
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.3.0",
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",