@sailingnaturali/signalk-currents 0.1.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/cache.d.ts +3 -0
- package/dist/cache.js +6 -0
- package/dist/calculations.d.ts +7 -0
- package/dist/calculations.js +44 -0
- package/dist/fetch.d.ts +4 -0
- package/dist/fetch.js +28 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +114 -0
- package/dist/routes.d.ts +16 -0
- package/dist/routes.js +19 -0
- package/dist/sources/chs.d.ts +2 -0
- package/dist/sources/chs.js +25 -0
- package/dist/sources/noaa.d.ts +2 -0
- package/dist/sources/noaa.js +29 -0
- package/dist/types.d.ts +17 -0
- package/dist/types.js +6 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryan Clark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# @sailingnaturali/signalk-currents
|
|
2
|
+
|
|
3
|
+
A generic [SignalK](https://signalk.org/) server plugin that fetches tidal-current
|
|
4
|
+
predictions from the **CHS** (Canadian Hydrographic Service) and **NOAA** tides &
|
|
5
|
+
currents APIs for a configured list of current stations, and:
|
|
6
|
+
|
|
7
|
+
- publishes **`environment.current`** — the interpolated current (`drift` in m/s,
|
|
8
|
+
`setTrue` in radians) at the vessel, using the nearest configured station; and
|
|
9
|
+
- serves a **`/currents`** resource — the full slack / flood / ebb event series for
|
|
10
|
+
every configured station.
|
|
11
|
+
|
|
12
|
+
There are **no hardcoded stations or gates** — the station list is plugin config, so
|
|
13
|
+
it works anywhere CHS or NOAA publishes current predictions.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
On start, and then every `pollMinutes`, the plugin:
|
|
18
|
+
|
|
19
|
+
1. Fetches each station's events for the next `horizonDays` UTC days (one fetch per
|
|
20
|
+
station-day, cached in memory — per-day predictions are immutable).
|
|
21
|
+
2. Reads the vessel position (`navigation.position`), picks the nearest configured
|
|
22
|
+
station, interpolates the current for "now", and publishes an `environment.current`
|
|
23
|
+
delta.
|
|
24
|
+
3. Keeps the per-station event series available at `/currents`.
|
|
25
|
+
|
|
26
|
+
Interpolation is a quarter-sine model between each slack (speed 0) and the adjacent
|
|
27
|
+
flood/ebb maximum: ramping up uses `Vmax·sin(π/2·frac)`, ramping down uses
|
|
28
|
+
`Vmax·cos(π/2·frac)`. `setTrue` is the extremum's configured direction (`floodDir`
|
|
29
|
+
when flooding, `ebbDir` when ebbing).
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Configured from the SignalK server plugin UI.
|
|
34
|
+
|
|
35
|
+
| Option | Type | Default | Description |
|
|
36
|
+
|--------|------|---------|-------------|
|
|
37
|
+
| `stations` | array | `[]` | Current stations (see below). |
|
|
38
|
+
| `horizonDays` | number | `3` | How many UTC days of predictions to fetch/keep. |
|
|
39
|
+
| `pollMinutes` | number | `60` | How often to refresh and republish. |
|
|
40
|
+
|
|
41
|
+
Each entry in `stations`:
|
|
42
|
+
|
|
43
|
+
| Field | Type | Description |
|
|
44
|
+
|-------|------|-------------|
|
|
45
|
+
| `provider` | `"chs"` \| `"noaa"` | Prediction source. |
|
|
46
|
+
| `stationId` | string | CHS station id, or NOAA station id (e.g. `PUG1717`). |
|
|
47
|
+
| `noaaBin` | number | NOAA current-station bin (NOAA only). |
|
|
48
|
+
| `label` | string | Human-readable name. |
|
|
49
|
+
| `lat` / `lon` | number | Station position (used for nearest-station selection). |
|
|
50
|
+
| `floodDir` | number | Set direction (°true) while flooding. |
|
|
51
|
+
| `ebbDir` | number | Set direction (°true) while ebbing. |
|
|
52
|
+
|
|
53
|
+
### Example station config
|
|
54
|
+
|
|
55
|
+
Gillard Passage (CHS) and Boundary Pass (NOAA), drawn from our
|
|
56
|
+
[`tide-mcp`](https://github.com/sailingnaturali) passage database:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"stations": [
|
|
61
|
+
{
|
|
62
|
+
"provider": "chs",
|
|
63
|
+
"stationId": "5dd3064fe0fdc4b9b4be6978",
|
|
64
|
+
"label": "Gillard Passage",
|
|
65
|
+
"lat": 50.3933,
|
|
66
|
+
"lon": -125.1567,
|
|
67
|
+
"floodDir": 160,
|
|
68
|
+
"ebbDir": 340
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"provider": "noaa",
|
|
72
|
+
"stationId": "PUG1717",
|
|
73
|
+
"noaaBin": 35,
|
|
74
|
+
"label": "Boundary Pass",
|
|
75
|
+
"lat": 48.6912,
|
|
76
|
+
"lon": -123.2450,
|
|
77
|
+
"floodDir": 110,
|
|
78
|
+
"ebbDir": 290
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"horizonDays": 3,
|
|
82
|
+
"pollMinutes": 60
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`floodDir` / `ebbDir` are the set (°true) you observe at the station on the flood and
|
|
87
|
+
ebb — fill them from a current atlas or pilot book for your stations.
|
|
88
|
+
|
|
89
|
+
## Output
|
|
90
|
+
|
|
91
|
+
### `environment.current` (delta)
|
|
92
|
+
|
|
93
|
+
```json
|
|
94
|
+
{ "drift": 1.23, "setTrue": 2.79 }
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `drift` — current speed in **m/s**.
|
|
98
|
+
- `setTrue` — direction the current sets **toward**, in **radians true**.
|
|
99
|
+
|
|
100
|
+
### `/currents` resource
|
|
101
|
+
|
|
102
|
+
Mounted at `/plugins/signalk-currents/currents`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"stations": [
|
|
107
|
+
{
|
|
108
|
+
"stationId": "5dd3064fe0fdc4b9b4be6978",
|
|
109
|
+
"label": "Gillard Passage",
|
|
110
|
+
"lat": 50.3933,
|
|
111
|
+
"lon": -125.1567,
|
|
112
|
+
"events": [
|
|
113
|
+
{ "utc": "2026-06-06T04:14:00.000Z", "kind": "slack", "speedKn": 0 },
|
|
114
|
+
{ "utc": "2026-06-06T05:40:00.000Z", "kind": "flood", "speedKn": 4.1 }
|
|
115
|
+
]
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`kind` is `slack` | `flood` | `ebb`; `speedKn` is the event speed magnitude in knots.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm install
|
|
127
|
+
npm test # vitest
|
|
128
|
+
npm run build # tsc -> dist/
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## License
|
|
132
|
+
|
|
133
|
+
MIT © 2026 Bryan Clark
|
package/dist/cache.d.ts
ADDED
package/dist/cache.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CurrentEvent, StationConfig } from './types';
|
|
2
|
+
export declare function nearestStation(lat: number, lon: number, stations: StationConfig[]): StationConfig | undefined;
|
|
3
|
+
export interface CurrentValue {
|
|
4
|
+
drift: number;
|
|
5
|
+
setTrue: number;
|
|
6
|
+
}
|
|
7
|
+
export declare function interpolateCurrent(now: Date, events: CurrentEvent[], station: StationConfig): CurrentValue | undefined;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nearestStation = nearestStation;
|
|
4
|
+
exports.interpolateCurrent = interpolateCurrent;
|
|
5
|
+
const geolib_1 = require("geolib");
|
|
6
|
+
function nearestStation(lat, lon, stations) {
|
|
7
|
+
if (stations.length === 0)
|
|
8
|
+
return undefined;
|
|
9
|
+
return stations.reduce((best, s) => (0, geolib_1.getDistance)({ latitude: lat, longitude: lon }, { latitude: s.lat, longitude: s.lon }) <
|
|
10
|
+
(0, geolib_1.getDistance)({ latitude: lat, longitude: lon }, { latitude: best.lat, longitude: best.lon })
|
|
11
|
+
? s : best);
|
|
12
|
+
}
|
|
13
|
+
const KN_TO_MS = 0.514444;
|
|
14
|
+
function interpolateCurrent(now, events, station) {
|
|
15
|
+
const t = now.getTime();
|
|
16
|
+
// bracketing consecutive pair e0 <= t <= e1
|
|
17
|
+
let e0, e1;
|
|
18
|
+
for (let i = 0; i < events.length - 1; i++) {
|
|
19
|
+
if (Date.parse(events[i].utc) <= t && t <= Date.parse(events[i + 1].utc)) {
|
|
20
|
+
e0 = events[i];
|
|
21
|
+
e1 = events[i + 1];
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (!e0 || !e1)
|
|
26
|
+
return undefined;
|
|
27
|
+
const frac = (t - Date.parse(e0.utc)) / (Date.parse(e1.utc) - Date.parse(e0.utc));
|
|
28
|
+
let speedKn;
|
|
29
|
+
let extremum;
|
|
30
|
+
if (e0.kind === 'slack') {
|
|
31
|
+
speedKn = e1.speedKn * Math.sin((Math.PI / 2) * frac);
|
|
32
|
+
extremum = e1;
|
|
33
|
+
}
|
|
34
|
+
else if (e1.kind === 'slack') {
|
|
35
|
+
speedKn = e0.speedKn * Math.cos((Math.PI / 2) * frac);
|
|
36
|
+
extremum = e0;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
speedKn = e0.speedKn + (e1.speedKn - e0.speedKn) * frac;
|
|
40
|
+
extremum = e0;
|
|
41
|
+
} // rare flood↔ebb, linear
|
|
42
|
+
const dir = extremum.kind === 'ebb' ? station.ebbDir : station.floodDir;
|
|
43
|
+
return { drift: speedKn * KN_TO_MS, setTrue: (dir * Math.PI) / 180 };
|
|
44
|
+
}
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CurrentEvent, StationConfig } from './types';
|
|
2
|
+
type DayFetcher = (s: StationConfig, dayStart: Date, dayEnd: Date) => Promise<CurrentEvent[]>;
|
|
3
|
+
export declare function stationEvents(station: StationConfig, start: Date, horizonDays: number, cache: Map<string, CurrentEvent[]>, fetcher?: DayFetcher): Promise<CurrentEvent[]>;
|
|
4
|
+
export {};
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stationEvents = stationEvents;
|
|
4
|
+
const chs_1 = require("./sources/chs");
|
|
5
|
+
const noaa_1 = require("./sources/noaa");
|
|
6
|
+
const defaultFetcher = (s, a, b) => s.provider === 'chs'
|
|
7
|
+
? (0, chs_1.fetchChsEvents)(s.stationId, a, b)
|
|
8
|
+
: (0, noaa_1.fetchNoaaEvents)(s.stationId, s.noaaBin ?? 0, a, b);
|
|
9
|
+
function utcDays(start, n) {
|
|
10
|
+
const base = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()));
|
|
11
|
+
return Array.from({ length: n }, (_, i) => new Date(base.getTime() + i * 86400000).toISOString().slice(0, 10));
|
|
12
|
+
}
|
|
13
|
+
async function stationEvents(station, start, horizonDays, cache, fetcher = defaultFetcher) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const day of utcDays(start, horizonDays)) {
|
|
16
|
+
const key = `${station.provider}:${station.stationId}:${day}`;
|
|
17
|
+
let events = cache.get(key);
|
|
18
|
+
if (!events) {
|
|
19
|
+
const dayStart = new Date(`${day}T00:00:00Z`);
|
|
20
|
+
const dayEnd = new Date(dayStart.getTime() + 86400000);
|
|
21
|
+
events = await fetcher(station, dayStart, dayEnd);
|
|
22
|
+
cache.set(key, events);
|
|
23
|
+
}
|
|
24
|
+
out.push(...events);
|
|
25
|
+
}
|
|
26
|
+
out.sort((a, b) => a.utc.localeCompare(b.utc));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const cache_1 = require("./cache");
|
|
3
|
+
const fetch_1 = require("./fetch");
|
|
4
|
+
const calculations_1 = require("./calculations");
|
|
5
|
+
const routes_1 = require("./routes");
|
|
6
|
+
module.exports = function (app) {
|
|
7
|
+
// Cache of per-day events keyed by `provider:station:YYYY-MM-DD`, and the
|
|
8
|
+
// current per-station series exposed via /currents. Both live for the
|
|
9
|
+
// lifetime of the plugin process and are read by the route handler.
|
|
10
|
+
const cache = (0, cache_1.createCache)();
|
|
11
|
+
const series = new Map();
|
|
12
|
+
let timer;
|
|
13
|
+
const plugin = {
|
|
14
|
+
id: 'signalk-currents',
|
|
15
|
+
name: 'Tidal currents (CHS/NOAA)',
|
|
16
|
+
description: 'Publishes tidal-current predictions to environment.current and a /currents resource.',
|
|
17
|
+
schema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
stations: {
|
|
21
|
+
type: 'array', title: 'Current stations',
|
|
22
|
+
items: { type: 'object', required: ['provider', 'stationId', 'label', 'lat', 'lon', 'floodDir', 'ebbDir'],
|
|
23
|
+
properties: {
|
|
24
|
+
provider: { type: 'string', enum: ['chs', 'noaa'] },
|
|
25
|
+
stationId: { type: 'string' }, noaaBin: { type: 'number' },
|
|
26
|
+
label: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' },
|
|
27
|
+
floodDir: { type: 'number', title: 'Flood set (°true)' },
|
|
28
|
+
ebbDir: { type: 'number', title: 'Ebb set (°true)' },
|
|
29
|
+
} },
|
|
30
|
+
},
|
|
31
|
+
horizonDays: { type: 'number', default: 3 },
|
|
32
|
+
pollMinutes: { type: 'number', default: 60 },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
start(options) {
|
|
36
|
+
const stations = options.stations ?? [];
|
|
37
|
+
const horizonDays = options.horizonDays ?? 3;
|
|
38
|
+
const pollMinutes = options.pollMinutes ?? 60;
|
|
39
|
+
async function refresh() {
|
|
40
|
+
try {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
// Refresh each station's series from the cached day fetch. Isolate
|
|
43
|
+
// per-station failures so one bad CHS/NOAA response can't blank the
|
|
44
|
+
// others (or skip the environment.current publish) for the whole cycle.
|
|
45
|
+
for (const station of stations) {
|
|
46
|
+
try {
|
|
47
|
+
const events = await (0, fetch_1.stationEvents)(station, now, horizonDays, cache);
|
|
48
|
+
series.set(station.stationId, { station, events });
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
app.error(`station ${station.label} fetch failed: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Read the vessel's position (mirrors signalk-tides: reads the
|
|
55
|
+
// `.value` of navigation.position via getSelfPath).
|
|
56
|
+
const position = app.getSelfPath('navigation.position.value');
|
|
57
|
+
if (!position) {
|
|
58
|
+
app.debug('No position available; skipping environment.current publish');
|
|
59
|
+
app.setPluginStatus(`Fetched ${series.size} station(s); awaiting position`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const station = (0, calculations_1.nearestStation)(position.latitude, position.longitude, stations);
|
|
63
|
+
const entry = station ? series.get(station.stationId) : undefined;
|
|
64
|
+
if (!station || !entry) {
|
|
65
|
+
app.setPluginStatus('No station near vessel position');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const current = (0, calculations_1.interpolateCurrent)(now, entry.events, station);
|
|
69
|
+
if (!current) {
|
|
70
|
+
app.setPluginStatus(`No current data bracketing now for ${station.label}`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// Publish environment.current as a SignalK delta. handleMessage takes
|
|
74
|
+
// a Partial<Delta>; the path is a branded type so it is cast.
|
|
75
|
+
app.handleMessage(plugin.id, {
|
|
76
|
+
updates: [
|
|
77
|
+
{
|
|
78
|
+
values: [
|
|
79
|
+
{
|
|
80
|
+
path: 'environment.current',
|
|
81
|
+
value: { drift: current.drift, setTrue: current.setTrue },
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
});
|
|
87
|
+
app.setPluginStatus(`environment.current from ${station.label}: ${current.drift.toFixed(2)} m/s`);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
// One bad cycle must never kill the loop.
|
|
91
|
+
app.error(`refresh failed: ${e.message}`);
|
|
92
|
+
app.setPluginError(e.message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Initial fetch, then poll. Errors are swallowed inside refresh().
|
|
96
|
+
refresh();
|
|
97
|
+
timer = setInterval(refresh, pollMinutes * 60_000);
|
|
98
|
+
},
|
|
99
|
+
stop() {
|
|
100
|
+
if (timer) {
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
timer = undefined;
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
// Mounted by the server at /plugins/signalk-currents — so the resource is
|
|
106
|
+
// served at /plugins/signalk-currents/currents. This is the typed
|
|
107
|
+
// equivalent of the express-router mounting signalk-tides does via an
|
|
108
|
+
// app.use() cast; registerWithRouter is the supported Plugin API.
|
|
109
|
+
registerWithRouter(router) {
|
|
110
|
+
router.use('/', (0, routes_1.currentsRouter)(() => series));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
return plugin;
|
|
114
|
+
};
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { StationConfig, CurrentEvent } from './types';
|
|
3
|
+
export interface StationSeries {
|
|
4
|
+
station: StationConfig;
|
|
5
|
+
events: CurrentEvent[];
|
|
6
|
+
}
|
|
7
|
+
export declare function currentsPayload(series: Map<string, StationSeries>): {
|
|
8
|
+
stations: {
|
|
9
|
+
stationId: string;
|
|
10
|
+
label: string;
|
|
11
|
+
lat: number;
|
|
12
|
+
lon: number;
|
|
13
|
+
events: CurrentEvent[];
|
|
14
|
+
}[];
|
|
15
|
+
};
|
|
16
|
+
export declare function currentsRouter(getSeries: () => Map<string, StationSeries>): Router;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.currentsPayload = currentsPayload;
|
|
4
|
+
exports.currentsRouter = currentsRouter;
|
|
5
|
+
const express_1 = require("express");
|
|
6
|
+
function currentsPayload(series) {
|
|
7
|
+
return {
|
|
8
|
+
stations: [...series.values()].map(s => ({
|
|
9
|
+
stationId: s.station.stationId, label: s.station.label,
|
|
10
|
+
lat: s.station.lat, lon: s.station.lon, events: s.events,
|
|
11
|
+
})),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// Mirror signalk-tides/src/routes.ts for how the router is registered with `app`.
|
|
15
|
+
function currentsRouter(getSeries) {
|
|
16
|
+
const r = (0, express_1.Router)();
|
|
17
|
+
r.get('/currents', (_req, res) => res.json(currentsPayload(getSeries())));
|
|
18
|
+
return r;
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchChsEvents = fetchChsEvents;
|
|
4
|
+
const types_1 = require("../types");
|
|
5
|
+
const CHS_BASE = 'https://api-sine.dfo-mpo.gc.ca/api/v1';
|
|
6
|
+
const KIND = {
|
|
7
|
+
SLACK: 'slack', EXTREMA_FLOOD: 'flood', EXTREMA_EBB: 'ebb',
|
|
8
|
+
};
|
|
9
|
+
const isoZ = (d) => d.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
10
|
+
async function fetchChsEvents(stationId, start, end, fetchFn = fetch) {
|
|
11
|
+
const params = new URLSearchParams({
|
|
12
|
+
'time-series-code': 'wcp1-events', from: isoZ(start), to: isoZ(end),
|
|
13
|
+
});
|
|
14
|
+
const resp = await fetchFn(`${CHS_BASE}/stations/${stationId}/data?${params}`);
|
|
15
|
+
if (!resp.ok)
|
|
16
|
+
throw new Error(`CHS ${resp.status}`);
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const row of await resp.json()) {
|
|
19
|
+
const kind = KIND[row.qualifier];
|
|
20
|
+
if (!kind)
|
|
21
|
+
continue;
|
|
22
|
+
out.push((0, types_1.eventFromParts)(row.eventDate, kind, parseFloat(row.value)));
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchNoaaEvents = fetchNoaaEvents;
|
|
4
|
+
const types_1 = require("../types");
|
|
5
|
+
const NOAA_BASE = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
|
|
6
|
+
const ymd = (d) => d.toISOString().slice(0, 10).replace(/-/g, '');
|
|
7
|
+
// NOAA "YYYY-MM-DD HH:MM" is UTC when requested with time_zone=gmt.
|
|
8
|
+
function parseNoaaTime(s) {
|
|
9
|
+
return new Date(s.replace(' ', 'T') + 'Z').toISOString();
|
|
10
|
+
}
|
|
11
|
+
async function fetchNoaaEvents(stationId, bin, start, end, fetchFn = fetch) {
|
|
12
|
+
const params = new URLSearchParams({
|
|
13
|
+
product: 'currents_predictions', interval: 'MAX_SLACK', time_zone: 'gmt',
|
|
14
|
+
units: 'english', format: 'json', application: 'signalk-currents',
|
|
15
|
+
station: stationId, bin: String(bin), begin_date: ymd(start), end_date: ymd(end),
|
|
16
|
+
});
|
|
17
|
+
const resp = await fetchFn(`${NOAA_BASE}?${params}`);
|
|
18
|
+
if (!resp.ok)
|
|
19
|
+
throw new Error(`NOAA ${resp.status}`);
|
|
20
|
+
const cp = (await resp.json())?.current_predictions?.cp ?? [];
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const row of cp) {
|
|
23
|
+
const kind = String(row.Type ?? '').toLowerCase();
|
|
24
|
+
if (kind !== 'slack' && kind !== 'flood' && kind !== 'ebb')
|
|
25
|
+
continue;
|
|
26
|
+
out.push((0, types_1.eventFromParts)(parseNoaaTime(row.Time), kind, parseFloat(row.Velocity_Major)));
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type CurrentKind = 'slack' | 'flood' | 'ebb';
|
|
2
|
+
export interface CurrentEvent {
|
|
3
|
+
utc: string;
|
|
4
|
+
kind: CurrentKind;
|
|
5
|
+
speedKn: number;
|
|
6
|
+
}
|
|
7
|
+
export interface StationConfig {
|
|
8
|
+
provider: 'chs' | 'noaa';
|
|
9
|
+
stationId: string;
|
|
10
|
+
noaaBin?: number;
|
|
11
|
+
label: string;
|
|
12
|
+
lat: number;
|
|
13
|
+
lon: number;
|
|
14
|
+
floodDir: number;
|
|
15
|
+
ebbDir: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function eventFromParts(utc: string, kind: CurrentKind, speed: number): CurrentEvent;
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sailingnaturali/signalk-currents",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Publish CHS/NOAA tidal-current predictions to SignalK — environment.current + a /currents resource, for a configured station list.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc",
|
|
8
|
+
"test": "vitest run",
|
|
9
|
+
"prepare": "npm run build"
|
|
10
|
+
},
|
|
11
|
+
"keywords": ["signalk-node-server-plugin", "signalk-category-utility", "tidal-currents", "currents", "marine"],
|
|
12
|
+
"author": "Bryan Clark",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/sailingnaturali/signalk-currents.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/sailingnaturali/signalk-currents#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/sailingnaturali/signalk-currents/issues"
|
|
21
|
+
},
|
|
22
|
+
"files": ["dist"],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@signalk/server-api": "^2.0.0",
|
|
31
|
+
"express": "^4.19.0",
|
|
32
|
+
"geolib": "^3.3.4"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.4.0",
|
|
36
|
+
"vitest": "^2.0.0",
|
|
37
|
+
"@types/express": "^4.17.0"
|
|
38
|
+
}
|
|
39
|
+
}
|