signalk-tides 2.2.0 → 2.3.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.
- package/.github/mise.node24.toml +3 -0
- package/.github/workflows/ci.yml +6 -0
- package/.github/workflows/lockfile.yml +31 -0
- package/.github/workflows/release.yml +16 -1
- package/CONTRIBUTING.md +11 -0
- package/README.md +3 -2
- package/dist/calculations.js +20 -0
- package/dist/index.js +201 -41
- package/dist/middleware.js +90 -65
- package/mise.toml +2 -0
- package/package.json +25 -24
- package/public/assets/main-FMwgC28o.js +1 -0
- package/public/assets/maplibre-CXEYa9hE.js +814 -0
- package/public/assets/maplibre-gl-worker-BWso-Bu4.js +10 -0
- package/public/assets/useUnitPreferences-Bnl2Hfv0.js +12 -0
- package/public/assets/useUnitPreferences-D6HIp3mv.css +1 -0
- package/public/assets/widget-DrHqs948.js +1 -0
- package/public/index.html +3 -3
- package/public/widget.html +3 -3
- package/tides-dev-server.ts +5 -5
- package/public/assets/main-68xVY5Ec.js +0 -1
- package/public/assets/maplibre-CcFST_Y2.js +0 -814
- package/public/assets/maplibre-gl-worker-CRiIRpYb.js +0 -15
- package/public/assets/useUnitPreferences-BoZImJ0r.css +0 -1
- package/public/assets/useUnitPreferences-lYEQOWom.js +0 -12
- package/public/assets/widget-DyJmPMCf.js +0 -1
package/.github/workflows/ci.yml
CHANGED
|
@@ -13,6 +13,8 @@ jobs:
|
|
|
13
13
|
|
|
14
14
|
- name: Setup Node.js
|
|
15
15
|
uses: actions/setup-node@v7
|
|
16
|
+
with:
|
|
17
|
+
node-version-file: mise.toml
|
|
16
18
|
|
|
17
19
|
- name: Install dependencies
|
|
18
20
|
run: npm install
|
|
@@ -31,6 +33,8 @@ jobs:
|
|
|
31
33
|
|
|
32
34
|
- name: Setup Node.js
|
|
33
35
|
uses: actions/setup-node@v7
|
|
36
|
+
with:
|
|
37
|
+
node-version-file: mise.toml
|
|
34
38
|
|
|
35
39
|
- name: Install dependencies
|
|
36
40
|
run: npm install
|
|
@@ -54,6 +58,8 @@ jobs:
|
|
|
54
58
|
- uses: actions/checkout@v7
|
|
55
59
|
|
|
56
60
|
- uses: actions/setup-node@v7
|
|
61
|
+
with:
|
|
62
|
+
node-version-file: mise.toml
|
|
57
63
|
|
|
58
64
|
- name: Install modules
|
|
59
65
|
run: npm install
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Lockfile
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: [main]
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
# A pull request can slip a package in through package-lock.json, which
|
|
13
|
+
# reviewers rarely read. npm keeps existing resolved URLs when it regenerates,
|
|
14
|
+
# so the diff alone misses a swapped tarball; lockfile-lint catches that.
|
|
15
|
+
lockfile:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
- uses: actions/setup-node@v7
|
|
20
|
+
with:
|
|
21
|
+
node-version-file: .github/mise.node24.toml
|
|
22
|
+
- name: Check the lockfile regenerates from package.json
|
|
23
|
+
run: |
|
|
24
|
+
git ls-files --error-unmatch package-lock.json
|
|
25
|
+
npm install --package-lock-only --ignore-scripts
|
|
26
|
+
git diff --exit-code -- package-lock.json
|
|
27
|
+
- name: Check every package resolves from the npm registry
|
|
28
|
+
# @isaacs/cliui installs CommonJS releases of these packages under -cjs aliases.
|
|
29
|
+
run: |
|
|
30
|
+
npx -y lockfile-lint@5.0.1 --path package-lock.json --allowed-hosts npm --validate-https --validate-integrity --validate-package-names \
|
|
31
|
+
--allowed-package-name-aliases string-width-cjs:string-width strip-ansi-cjs:strip-ansi wrap-ansi-cjs:wrap-ansi
|
|
@@ -17,9 +17,24 @@ jobs:
|
|
|
17
17
|
|
|
18
18
|
- uses: actions/setup-node@v7
|
|
19
19
|
with:
|
|
20
|
-
node-version:
|
|
20
|
+
node-version-file: .github/mise.node24.toml # npm >= 11.5.1 for trusted publishing
|
|
21
21
|
registry-url: https://registry.npmjs.org
|
|
22
22
|
|
|
23
|
+
# Before anything installs, so a preview build is neither run nor published.
|
|
24
|
+
# Overrides don't reach people installing the plugin, so these specs are
|
|
25
|
+
# what they get.
|
|
26
|
+
- name: Refuse dependencies from outside the registry
|
|
27
|
+
run: |
|
|
28
|
+
node -e '
|
|
29
|
+
const { dependencies = {}, optionalDependencies = {} } = require("./package.json");
|
|
30
|
+
const outside = Object.entries({ ...dependencies, ...optionalDependencies })
|
|
31
|
+
.filter(([, spec]) => /[:/]/.test(spec));
|
|
32
|
+
if (outside.length) {
|
|
33
|
+
console.error("Not from the registry:", Object.fromEntries(outside));
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
'
|
|
37
|
+
|
|
23
38
|
- name: Install dependencies
|
|
24
39
|
run: npm install
|
|
25
40
|
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Releasing
|
|
4
|
+
|
|
5
|
+
Publishing to npm runs from [release.yml](.github/workflows/release.yml) when a GitHub release is created. A person creates the release by hand: choose or create a `vX.Y.Z` tag, then fill in the title and notes.
|
|
6
|
+
|
|
7
|
+
Title and notes follow the Open Waters [release notes guidance](https://github.com/openwatersio/.github/blob/main/docs/agents/releases.md#release-notes). Write them for boaters running Signal K, not for developers reading commit subjects:
|
|
8
|
+
|
|
9
|
+
- Title the release with a short headline of what changed, in sentence case with no period and no version. Patch releases get a headline too. Put a new requirement in the headline, such as "requires Node 24".
|
|
10
|
+
- Open the notes with what is new and who it is for. Put breaking changes and upgrade steps first.
|
|
11
|
+
- List the changes users will notice, one per bullet, each linking its pull request. Leave out chores, CI changes, and bot dependency bumps unless a runtime dependency change matters to users.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# signalk-tides
|
|
2
2
|
|
|
3
|
-
A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [
|
|
3
|
+
A SignalK plugin that provides offline tidal predictions for the vessel's position, powered by [Slackwater](https://openwaters.io/tides/slackwater/).
|
|
4
4
|
|
|
5
5
|
Since 2.0, predictions are computed locally from harmonic constituents — no network access or API keys required.
|
|
6
6
|
|
|
@@ -21,12 +21,13 @@ 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.stationDistance` — metres from the vessel to the station, when it was chosen by position
|
|
24
25
|
* `environment.tide.state` — tide trend, `rising` or `falling`
|
|
25
26
|
* `environment.tide.timeToNextExtreme` — seconds until the next high or low water
|
|
26
27
|
|
|
27
28
|
### Tides API
|
|
28
29
|
|
|
29
|
-
The plugin mounts the [
|
|
30
|
+
The plugin mounts the [Slackwater API](https://openwaters.io/api/tides/) 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 with tide predictions within 100 km when none is set or the configured one has no predictions. With none in range, the plugin publishes no tide and says so in its status, and the API answers 404:
|
|
30
31
|
|
|
31
32
|
```
|
|
32
33
|
$ curl http://localhost:3000/signalk/v2/api/tides/stations/vessel/default/extremes
|
package/dist/calculations.js
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far from the vessel a station may be and still stand in for its tide. Past
|
|
3
|
+
* this a station gives a plausible tide for other water, so the plugin publishes
|
|
4
|
+
* none. Half of the CHS positions with no model have a station that predicts
|
|
5
|
+
* within 28 km, and 184 of 1,057 have none within 100 km. This project's
|
|
6
|
+
* definition, not a published figure.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_STATION_KM = 100;
|
|
9
|
+
/** Whether a location lookup found nothing in range. */
|
|
10
|
+
export function isNoStationNearby(e) {
|
|
11
|
+
return e instanceof Error && e.message.startsWith("No stations found");
|
|
12
|
+
}
|
|
13
|
+
/** Whether a lookup failed on the id, rather than on loading the database. */
|
|
14
|
+
export function isStationNotFound(e) {
|
|
15
|
+
return e instanceof Error && e.message.startsWith("Station not found");
|
|
16
|
+
}
|
|
17
|
+
// CHS stations carry identity only, and predicting from one gives a flat zero tide.
|
|
18
|
+
export function hasTideModel(station) {
|
|
19
|
+
return station.harmonic_constituents.length > 0 || station.offsets !== undefined;
|
|
20
|
+
}
|
|
1
21
|
/**
|
|
2
22
|
* Trend of the tide at `time`: "rising" if the next extreme is a High,
|
|
3
23
|
* "falling" if it is a Low.
|
package/dist/index.js
CHANGED
|
@@ -13,16 +13,35 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import { createRoutes } from "@
|
|
17
|
-
import { findStation, nearestStation, stationsNear } from "
|
|
18
|
-
import { tideStateAt, timeToNextExtreme } from "./calculations.js";
|
|
16
|
+
import { createRoutes } from "@slackwater/api";
|
|
17
|
+
import { findStation, nearestStation, stationsNear } from "slackwater";
|
|
18
|
+
import { MAX_STATION_KM, hasTideModel, isNoStationNearby, isStationNotFound, tideStateAt, timeToNextExtreme, } from "./calculations.js";
|
|
19
19
|
import FileCache from "./cache.js";
|
|
20
20
|
import { withVesselPosition } from "./middleware.js";
|
|
21
21
|
import { PLOTTER_EXTENSION_ID, plotterExtensionManifest, } from "./plotter-extension.js";
|
|
22
22
|
// Recompute and republish on a fixed interval. Predictions are computed locally
|
|
23
|
-
// by
|
|
23
|
+
// by slackwater, so this is cheap and needs no configuration.
|
|
24
24
|
const UPDATE_INTERVAL = 60 * 1000; // 1 minute
|
|
25
25
|
const API_PATH = "/signalk/v2/api/tides";
|
|
26
|
+
const TIDE_PATHS = [
|
|
27
|
+
"environment.tide.stationName",
|
|
28
|
+
"environment.tide.stationDistance",
|
|
29
|
+
"environment.tide.heightNow",
|
|
30
|
+
"environment.tide.state",
|
|
31
|
+
"environment.tide.timeToNextExtreme",
|
|
32
|
+
"environment.tide.heightHigh",
|
|
33
|
+
"environment.tide.timeHigh",
|
|
34
|
+
"environment.tide.heightLow",
|
|
35
|
+
"environment.tide.timeLow",
|
|
36
|
+
];
|
|
37
|
+
// Tide heights are water levels, so clients should show them in the user's
|
|
38
|
+
// depth unit. Without a category the server picks one from the unit alone, and
|
|
39
|
+
// for meters that is distance: kilometers or miles, depending on the preset.
|
|
40
|
+
const TIDE_HEIGHT_PATHS = [
|
|
41
|
+
"environment.tide.heightHigh",
|
|
42
|
+
"environment.tide.heightNow",
|
|
43
|
+
"environment.tide.heightLow",
|
|
44
|
+
];
|
|
26
45
|
export default function (app) {
|
|
27
46
|
let unsubscribes = [];
|
|
28
47
|
let activeRouter = null;
|
|
@@ -41,8 +60,9 @@ export default function (app) {
|
|
|
41
60
|
const plugin = {
|
|
42
61
|
id: "tides",
|
|
43
62
|
name: "Tides",
|
|
44
|
-
description: "Offline tidal predictions for the vessel's position, powered by
|
|
45
|
-
|
|
63
|
+
description: "Offline tidal predictions for the vessel's position, powered by Slackwater.",
|
|
64
|
+
// signalk-server awaits this, but a rejection would fail its whole plugin list
|
|
65
|
+
schema: async () => ({
|
|
46
66
|
title: "Tides",
|
|
47
67
|
type: "object",
|
|
48
68
|
properties: {
|
|
@@ -51,7 +71,7 @@ export default function (app) {
|
|
|
51
71
|
title: "Default tide station",
|
|
52
72
|
description: "Use the closest station to the vessel's current position, or pick a default nearby station.",
|
|
53
73
|
default: "auto",
|
|
54
|
-
oneOf: defaultStationOptions(),
|
|
74
|
+
oneOf: await defaultStationOptions(),
|
|
55
75
|
},
|
|
56
76
|
},
|
|
57
77
|
}),
|
|
@@ -62,11 +82,20 @@ export default function (app) {
|
|
|
62
82
|
activeRouter = null;
|
|
63
83
|
},
|
|
64
84
|
};
|
|
65
|
-
function defaultStationOptions() {
|
|
85
|
+
async function defaultStationOptions() {
|
|
66
86
|
const options = [{ const: "auto", title: "Closest station" }];
|
|
67
87
|
if (lastPosition) {
|
|
68
|
-
|
|
69
|
-
|
|
88
|
+
try {
|
|
89
|
+
const nearby = await stationsNear({
|
|
90
|
+
...lastPosition,
|
|
91
|
+
maxResults: 10,
|
|
92
|
+
});
|
|
93
|
+
for (const station of nearby) {
|
|
94
|
+
options.push({ const: station.id, title: stationTitle(station) });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
app.error(`Could not list nearby tide stations: ${errorMessage(e)}`);
|
|
70
99
|
}
|
|
71
100
|
}
|
|
72
101
|
// Keep the saved station selectable even when it is not in the nearby
|
|
@@ -76,15 +105,40 @@ export default function (app) {
|
|
|
76
105
|
if (saved && saved !== "auto" && !options.some((o) => o.const === saved)) {
|
|
77
106
|
let title = saved;
|
|
78
107
|
try {
|
|
79
|
-
|
|
108
|
+
const station = await findStation(saved);
|
|
109
|
+
title = hasTideModel(station)
|
|
110
|
+
? stationTitle(station)
|
|
111
|
+
: `${stationTitle(station)} (no tide predictions)`;
|
|
80
112
|
}
|
|
81
|
-
catch {
|
|
113
|
+
catch (e) {
|
|
82
114
|
// Unknown id: keep the raw value as the label
|
|
115
|
+
if (!isStationNotFound(e)) {
|
|
116
|
+
app.error(`Could not load saved tide station: ${errorMessage(e)}`);
|
|
117
|
+
}
|
|
83
118
|
}
|
|
84
119
|
options.splice(1, 0, { const: saved, title });
|
|
85
120
|
}
|
|
86
121
|
return options;
|
|
87
122
|
}
|
|
123
|
+
// The server keeps display units the user chose for a path, and stores the
|
|
124
|
+
// suggestion, so later starts leave a path alone while it has display units.
|
|
125
|
+
async function suggestDepthUnits() {
|
|
126
|
+
// Signal K server 2.30 added setDefaultMetadata
|
|
127
|
+
if (typeof app.setDefaultMetadata !== "function")
|
|
128
|
+
return;
|
|
129
|
+
for (const path of TIDE_HEIGHT_PATHS) {
|
|
130
|
+
try {
|
|
131
|
+
// MetaValue types displayUnits as the server resolves it; a suggestion
|
|
132
|
+
// needs only the category.
|
|
133
|
+
await app.setDefaultMetadata(path, {
|
|
134
|
+
displayUnits: { category: "depth" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
app.error(`Could not suggest display units for ${path}: ${e}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
88
142
|
function savedDefaultStation() {
|
|
89
143
|
try {
|
|
90
144
|
const saved = app.readPluginOptions();
|
|
@@ -94,18 +148,37 @@ export default function (app) {
|
|
|
94
148
|
return undefined;
|
|
95
149
|
}
|
|
96
150
|
}
|
|
97
|
-
async
|
|
151
|
+
// Not async: signalk-server only rolls back a start() that throws synchronously,
|
|
152
|
+
// and that rollback does not call stop().
|
|
153
|
+
function start(options) {
|
|
154
|
+
try {
|
|
155
|
+
startPublishing(options);
|
|
156
|
+
}
|
|
157
|
+
catch (e) {
|
|
158
|
+
plugin.stop();
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function startPublishing(options) {
|
|
98
163
|
app.debug("Starting tides");
|
|
99
164
|
config = (options ?? {});
|
|
165
|
+
void suggestDepthUnits();
|
|
100
166
|
// Keep the forecast and the predictor that produced it together, so the
|
|
101
167
|
// extremes and the current height are always read from the same station.
|
|
102
168
|
let lastForecast = null;
|
|
103
169
|
let lastPredictor = null;
|
|
170
|
+
let latestUpdate = 0;
|
|
171
|
+
let stopped = false;
|
|
172
|
+
let tidesCleared = false;
|
|
173
|
+
let positionWrite = Promise.resolve();
|
|
174
|
+
unsubscribes.push(() => {
|
|
175
|
+
stopped = true;
|
|
176
|
+
});
|
|
104
177
|
const cache = new FileCache(app.getDataDirPath());
|
|
105
|
-
// Mount the
|
|
178
|
+
// Mount the Slackwater API, with vessel/default resolved to the configured
|
|
106
179
|
// default station (or the nearest one).
|
|
107
180
|
activeRouter = withVesselPosition(
|
|
108
|
-
// @
|
|
181
|
+
// @slackwater/api bundles its own Express declarations, so its Router is
|
|
109
182
|
// callable at runtime but not structurally compatible with ours.
|
|
110
183
|
// The OpenAPI server URL comes from req.baseUrl, set by the
|
|
111
184
|
// app.use(API_PATH, ...) mount above.
|
|
@@ -115,7 +188,7 @@ export default function (app) {
|
|
|
115
188
|
type: "tides",
|
|
116
189
|
methods: {
|
|
117
190
|
async listResources() {
|
|
118
|
-
const predictor = resolvePredictor();
|
|
191
|
+
const predictor = await resolvePredictor();
|
|
119
192
|
if (!predictor) {
|
|
120
193
|
throw new Error("No position or default station available");
|
|
121
194
|
}
|
|
@@ -165,25 +238,51 @@ export default function (app) {
|
|
|
165
238
|
}, unsubscribes, (subscriptionError) => {
|
|
166
239
|
app.error("Error:" + subscriptionError);
|
|
167
240
|
}, updatePosition);
|
|
168
|
-
function resolvePredictor() {
|
|
241
|
+
async function resolvePredictor(report = reportLookupFailure) {
|
|
242
|
+
let failed = false;
|
|
169
243
|
if (config.defaultStation && config.defaultStation !== "auto") {
|
|
170
244
|
try {
|
|
171
|
-
|
|
245
|
+
const station = await findStation(config.defaultStation);
|
|
246
|
+
if (hasTideModel(station))
|
|
247
|
+
return station;
|
|
248
|
+
throw new Error("Station has no tide predictions");
|
|
172
249
|
}
|
|
173
|
-
catch {
|
|
174
|
-
|
|
250
|
+
catch (e) {
|
|
251
|
+
failed = true;
|
|
252
|
+
report(`Could not load tide station ${config.defaultStation}: ${errorMessage(e)}`);
|
|
175
253
|
}
|
|
176
254
|
}
|
|
177
255
|
if (lastPosition) {
|
|
178
256
|
try {
|
|
179
|
-
return nearestStation(
|
|
257
|
+
return await nearestStation({
|
|
258
|
+
...lastPosition,
|
|
259
|
+
maxDistance: MAX_STATION_KM,
|
|
260
|
+
});
|
|
180
261
|
}
|
|
181
|
-
catch {
|
|
182
|
-
|
|
262
|
+
catch (e) {
|
|
263
|
+
if (isNoStationNearby(e)) {
|
|
264
|
+
// A status would replace the error about the saved station
|
|
265
|
+
if (!stopped && !failed) {
|
|
266
|
+
app.setPluginStatus(`No tide station within ${MAX_STATION_KM} km of the vessel`);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
report(`Could not find a tide station near vessel position: ${errorMessage(e)}`);
|
|
183
271
|
}
|
|
184
272
|
}
|
|
185
273
|
return null;
|
|
186
274
|
}
|
|
275
|
+
function reportLookupFailure(message) {
|
|
276
|
+
if (stopped)
|
|
277
|
+
return;
|
|
278
|
+
app.error(message);
|
|
279
|
+
app.setPluginError(message);
|
|
280
|
+
}
|
|
281
|
+
function forecastStatus({ station, distance }) {
|
|
282
|
+
return distance === undefined
|
|
283
|
+
? "Updated tide forecast"
|
|
284
|
+
: `Updated tide forecast from ${station.name}, ${Math.round(distance)} km away`;
|
|
285
|
+
}
|
|
187
286
|
function forecastFor(predictor) {
|
|
188
287
|
const now = new Date();
|
|
189
288
|
return predictor.getExtremesPrediction({
|
|
@@ -191,32 +290,63 @@ export default function (app) {
|
|
|
191
290
|
end: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
|
192
291
|
});
|
|
193
292
|
}
|
|
293
|
+
// Runs from a timer and a delta callback, which both drop its promise, so
|
|
294
|
+
// it must never reject.
|
|
194
295
|
async function updatePosition() {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
296
|
+
try {
|
|
297
|
+
const newPosition = app.getSelfPath("navigation.position.value");
|
|
298
|
+
// New position received, save it to the cache
|
|
299
|
+
if (newPosition) {
|
|
300
|
+
lastPosition = newPosition;
|
|
301
|
+
// Writes run in turn and each saves the newest position, so an older
|
|
302
|
+
// one can't land last
|
|
303
|
+
positionWrite = positionWrite
|
|
304
|
+
.catch(() => { })
|
|
305
|
+
.then(() => cache.set("position", lastPosition));
|
|
306
|
+
await positionWrite;
|
|
307
|
+
}
|
|
308
|
+
// No last known position, try to load from cache.
|
|
309
|
+
if (!lastPosition) {
|
|
310
|
+
const cached = (await cache.get("position"));
|
|
311
|
+
// A delta may have set a newer position during the read
|
|
312
|
+
lastPosition ??= cached ?? null;
|
|
313
|
+
}
|
|
200
314
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
lastPosition = (await cache.get("position"));
|
|
315
|
+
catch (e) {
|
|
316
|
+
app.error(`Could not update cached position: ${errorMessage(e)}`);
|
|
204
317
|
}
|
|
205
318
|
// A configured default station predicts without a position
|
|
206
|
-
updateForecast();
|
|
319
|
+
await updateForecast();
|
|
207
320
|
}
|
|
208
|
-
function updateForecast() {
|
|
209
|
-
const
|
|
210
|
-
if (!predictor) {
|
|
211
|
-
app.debug("No position or default station available, cannot compute tide data");
|
|
212
|
-
lastForecast = null;
|
|
213
|
-
lastPredictor = null;
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
321
|
+
async function updateForecast() {
|
|
322
|
+
const update = ++latestUpdate;
|
|
216
323
|
try {
|
|
324
|
+
let lookupFailed = false;
|
|
325
|
+
const predictor = await resolvePredictor((message) => {
|
|
326
|
+
lookupFailed = true;
|
|
327
|
+
reportLookupFailure(message);
|
|
328
|
+
});
|
|
329
|
+
// The first lookup waits for the database, so a newer update or stop()
|
|
330
|
+
// can overtake this one.
|
|
331
|
+
if (stopped || update !== latestUpdate)
|
|
332
|
+
return;
|
|
333
|
+
if (!predictor) {
|
|
334
|
+
app.debug("No position or default station available, cannot compute tide data");
|
|
335
|
+
// Once per loss, and once after a restart, which can't tell what the
|
|
336
|
+
// run before it published
|
|
337
|
+
if (!tidesCleared)
|
|
338
|
+
clearTides();
|
|
339
|
+
tidesCleared = true;
|
|
340
|
+
lastForecast = null;
|
|
341
|
+
lastPredictor = null;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
217
344
|
lastForecast = forecastFor(predictor);
|
|
218
345
|
lastPredictor = predictor;
|
|
219
|
-
|
|
346
|
+
tidesCleared = false;
|
|
347
|
+
// A fallback keeps the lookup's error showing
|
|
348
|
+
if (!lookupFailed)
|
|
349
|
+
app.setPluginStatus(forecastStatus(lastForecast));
|
|
220
350
|
updateTides();
|
|
221
351
|
}
|
|
222
352
|
catch (e) {
|
|
@@ -226,6 +356,19 @@ export default function (app) {
|
|
|
226
356
|
app.error(e);
|
|
227
357
|
}
|
|
228
358
|
}
|
|
359
|
+
// Signal K keeps a path's last value, so a tide that no longer applies has
|
|
360
|
+
// to be cleared or it stays on displays, frozen.
|
|
361
|
+
function clearTides() {
|
|
362
|
+
app.handleMessage(plugin.id, {
|
|
363
|
+
context: ("vessels." + app.selfId),
|
|
364
|
+
updates: [
|
|
365
|
+
{
|
|
366
|
+
timestamp: new Date().toISOString(),
|
|
367
|
+
values: TIDE_PATHS.map((path) => ({ path, value: null })),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
}
|
|
229
372
|
function updateTides(now = new Date()) {
|
|
230
373
|
if (!lastForecast || !lastPredictor)
|
|
231
374
|
return;
|
|
@@ -246,6 +389,13 @@ export default function (app) {
|
|
|
246
389
|
path: "environment.tide.stationName",
|
|
247
390
|
value: lastForecast.station.name,
|
|
248
391
|
},
|
|
392
|
+
// Null for a configured station, which isn't chosen by distance
|
|
393
|
+
{
|
|
394
|
+
path: "environment.tide.stationDistance",
|
|
395
|
+
value: lastForecast.distance === undefined
|
|
396
|
+
? null
|
|
397
|
+
: lastForecast.distance * 1000,
|
|
398
|
+
},
|
|
249
399
|
{
|
|
250
400
|
path: "environment.tide.heightNow",
|
|
251
401
|
value: heightNow,
|
|
@@ -286,6 +436,13 @@ export default function (app) {
|
|
|
286
436
|
description: "Tide trend at the current position: rising or falling",
|
|
287
437
|
},
|
|
288
438
|
},
|
|
439
|
+
{
|
|
440
|
+
path: "environment.tide.stationDistance",
|
|
441
|
+
value: {
|
|
442
|
+
units: "m",
|
|
443
|
+
description: "Distance from the vessel to the station it was given",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
289
446
|
{
|
|
290
447
|
path: "environment.tide.timeToNextExtreme",
|
|
291
448
|
value: {
|
|
@@ -311,6 +468,9 @@ export default function (app) {
|
|
|
311
468
|
}
|
|
312
469
|
return plugin;
|
|
313
470
|
}
|
|
471
|
+
function errorMessage(e) {
|
|
472
|
+
return e instanceof Error ? e.message : String(e);
|
|
473
|
+
}
|
|
314
474
|
function stationTitle(station) {
|
|
315
475
|
const where = [station.region, station.country].filter(Boolean).join(", ");
|
|
316
476
|
const distance = station.distance != null ? ` — ${station.distance.toFixed(1)} km` : "";
|