@meri-imperiumi/signalk-passage-briefing 0.2.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/.editorconfig +5 -0
- package/.github/workflows/publish.yml +34 -0
- package/.github/workflows/signalk-ci.yml +11 -0
- package/.github/workflows/test.yml +21 -0
- package/CHANGELOG.md +423 -0
- package/README.md +86 -0
- package/SPEC.md +488 -0
- package/bin/backfill-report.js +218 -0
- package/bin/backtest-cli.js +132 -0
- package/biome.json +6 -0
- package/doc/here-brief.png +0 -0
- package/package.json +48 -0
- package/plugin/backtest.js +742 -0
- package/plugin/brief-ext.js +173 -0
- package/plugin/bulletin-engine.js +746 -0
- package/plugin/bulletin-source.js +150 -0
- package/plugin/celestial-source.js +417 -0
- package/plugin/fetch-engine.js +717 -0
- package/plugin/history-backfill.js +540 -0
- package/plugin/index.js +1479 -0
- package/plugin/logbook-source.js +463 -0
- package/plugin/notes-publisher.js +228 -0
- package/plugin/notes-store.js +241 -0
- package/plugin/raster-convert.js +168 -0
- package/plugin/sails-configuration.js +111 -0
- package/plugin/spool-watcher.js +218 -0
- package/plugin/sqlite-db.js +378 -0
- package/plugin/state-machine.js +249 -0
- package/plugin/statustilesexamples.js +101 -0
- package/plugin/synoptic-map.json +45 -0
- package/plugin/synoptic-source.js +227 -0
- package/plugin/zone-source.js +339 -0
- package/public/app.js +18 -0
- package/public/brief-ext-model.js +64 -0
- package/public/brief-ext-widget.html +14 -0
- package/public/brief-ext-widget.js +294 -0
- package/public/components/backfill-controls.js +100 -0
- package/public/components/comfort-info.js +94 -0
- package/public/components/conditions-here.js +234 -0
- package/public/components/horizon-sparkline.js +77 -0
- package/public/components/models.mjs +372 -0
- package/public/components/passage-outlook.js +368 -0
- package/public/components/sk-api.js +146 -0
- package/public/components/sk-base-css.js +177 -0
- package/public/components/strategic-outlook.js +248 -0
- package/public/components/synoptic-chart.js +48 -0
- package/public/components/tactical-dashboard.js +172 -0
- package/public/css/visuals.css +292 -0
- package/public/gmdss-zones-min.json +287 -0
- package/public/icon.png +0 -0
- package/public/index.html +13 -0
- package/public/polar.mjs +303 -0
- package/public/route-sim.mjs +750 -0
- package/public/sereno-physics.mjs +592 -0
- package/public/tack-gybe.js +174 -0
- package/public/vendor/plotterext-bus/LICENSE +21 -0
- package/public/vendor/plotterext-bus/README.md +17 -0
- package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
- package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
- package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
- package/public/vendor/plotterext-bus/extension.js +29 -0
- package/public/vendor/plotterext-bus/host.js +28 -0
- package/public/vendor/utif/LICENSE +21 -0
- package/public/vendor/utif/UTIF.js +1171 -0
- package/public/worker.js +35 -0
- package/status-tiles-examples.json +55 -0
- package/tests/backtest.test.js +461 -0
- package/tests/brief-ext.test.js +194 -0
- package/tests/bulletin-engine.test.js +361 -0
- package/tests/celestial-source.test.js +265 -0
- package/tests/fetch-engine.test.js +297 -0
- package/tests/history-backfill.test.js +341 -0
- package/tests/logbook-source.test.js +324 -0
- package/tests/notes-publisher.test.js +161 -0
- package/tests/notes-store.test.js +138 -0
- package/tests/openmeteo-mock.js +100 -0
- package/tests/plugin.test.js +1344 -0
- package/tests/route-sim.test.js +533 -0
- package/tests/sereno-physics.test.js +333 -0
- package/tests/sqlite-db.test.js +164 -0
- package/tests/state-machine.test.js +182 -0
- package/tests/statustilesexamples.test.js +86 -0
- package/tests/synoptic-source.test.js +195 -0
- package/tests/tack-gybe.test.js +211 -0
- package/tests/webapp.test.js +257 -0
- package/tests/zone-source.test.js +226 -0
package/plugin/index.js
ADDED
|
@@ -0,0 +1,1479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal K Passage Briefing plugin.
|
|
3
|
+
*
|
|
4
|
+
* Offshore passage daily briefing: fetches multi-model weather for the
|
|
5
|
+
* planned route (online or via the GRIB spool), learns sail preferences
|
|
6
|
+
* from logbook history, and serves a tactical webapp with a 24h comfort
|
|
7
|
+
* dashboard and a strategic passage summary.
|
|
8
|
+
*
|
|
9
|
+
* This module is the lifecycle manager: it owns the configuration schema,
|
|
10
|
+
* the Signal K subscriptions feeding the state machine (SPEC §2.2), the
|
|
11
|
+
* cron timer for the UTC publication windows, and the plugin data
|
|
12
|
+
* directory wiring for the SQLite store. Fetching, backfill and the
|
|
13
|
+
* webapp live in their own modules.
|
|
14
|
+
*
|
|
15
|
+
* @file index.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("@signalk/server-api").ServerAPI} ServerAPI */
|
|
19
|
+
/** @typedef {import("@signalk/server-api").Plugin} Plugin */
|
|
20
|
+
|
|
21
|
+
const { join } = require("node:path");
|
|
22
|
+
const { homedir } = require("node:os");
|
|
23
|
+
const { mkdir, readFile, writeFile } = require("node:fs/promises");
|
|
24
|
+
|
|
25
|
+
const { PassageStateMachine } = require("./state-machine.js");
|
|
26
|
+
const { PassageDatabase } = require("./sqlite-db.js");
|
|
27
|
+
const {
|
|
28
|
+
readLogbookEntries,
|
|
29
|
+
readLogbookSailEvents,
|
|
30
|
+
} = require("./logbook-source.js");
|
|
31
|
+
const { readSailsConfiguration } = require("./sails-configuration.js");
|
|
32
|
+
const {
|
|
33
|
+
filterBulletin,
|
|
34
|
+
resolveBulletinSource,
|
|
35
|
+
ukhoBlocksFromWarnings,
|
|
36
|
+
} = require("./bulletin-engine.js");
|
|
37
|
+
const { loadBulletinCache, refreshBulletins } = require("./bulletin-source.js");
|
|
38
|
+
const {
|
|
39
|
+
loadSynoptic,
|
|
40
|
+
loadSynopticFailure,
|
|
41
|
+
refreshSynoptics,
|
|
42
|
+
} = require("./synoptic-source.js");
|
|
43
|
+
const {
|
|
44
|
+
fetchZoneBulletins,
|
|
45
|
+
parseUkhoWarnings,
|
|
46
|
+
resolveZones,
|
|
47
|
+
ukhoWarningsUrl,
|
|
48
|
+
} = require("./zone-source.js");
|
|
49
|
+
const { fetchSpaceEvents } = require("./celestial-source.js");
|
|
50
|
+
const { registerPlotterExtension } = require("./brief-ext.js");
|
|
51
|
+
const { registerStatusTileExamples } = require("./statustilesexamples.js");
|
|
52
|
+
const { createNotesStore, registerNotesProvider } = require("./notes-store.js");
|
|
53
|
+
const { clearNotes, publishNotes } = require("./notes-publisher.js");
|
|
54
|
+
const {
|
|
55
|
+
backfillSailEvents,
|
|
56
|
+
createHistoryWindStats,
|
|
57
|
+
createLogbookWindStats,
|
|
58
|
+
} = require("./history-backfill.js");
|
|
59
|
+
const {
|
|
60
|
+
DEFAULT_FORECAST_DAYS,
|
|
61
|
+
distanceNm: distanceNmLatLon,
|
|
62
|
+
fetchWeatherAlongTrack,
|
|
63
|
+
listCachedRoutes,
|
|
64
|
+
loadPayload,
|
|
65
|
+
routeDistanceNm,
|
|
66
|
+
sampleRoutePoints,
|
|
67
|
+
savePayload,
|
|
68
|
+
} = require("./fetch-engine.js");
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Plugin identifier (matches package name without the scope).
|
|
72
|
+
*/
|
|
73
|
+
const PLUGIN_ID = "signalk-passage-briefing";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Signal K paths the state machine consumes.
|
|
77
|
+
*/
|
|
78
|
+
const INTERNET_STATE_PATH = "network.internet.state";
|
|
79
|
+
const NAVIGATION_STATE_PATH = "navigation.state";
|
|
80
|
+
const HOUSE_SOC_PATH = "electrical.batteries.house.capacity.stateOfCharge";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Self path of the vessel's active route, as published by autopilot /
|
|
84
|
+
* navigation apps (same source the dead-reckoning webapp consumes).
|
|
85
|
+
*/
|
|
86
|
+
const ACTIVE_ROUTE_PATH = "navigation.course.activeRoute";
|
|
87
|
+
|
|
88
|
+
const WATCHED_PATHS = [
|
|
89
|
+
INTERNET_STATE_PATH,
|
|
90
|
+
NAVIGATION_STATE_PATH,
|
|
91
|
+
HOUSE_SOC_PATH,
|
|
92
|
+
ACTIVE_ROUTE_PATH,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/** Flat paths the plotter tile consumes (work doc #8). */
|
|
96
|
+
const BRIEF_GENERATED_AT_PATH = "navigation.briefing.generatedAt";
|
|
97
|
+
const BRIEF_ROUTE_PATH = "navigation.briefing.route";
|
|
98
|
+
const BRIEF_HAS_NEW_PATH = "navigation.briefing.hasNew";
|
|
99
|
+
const BRIEF_ACK_PATH = "navigation.briefing.acknowledgedAt";
|
|
100
|
+
const BRIEF_COMFORT_PATH = "navigation.briefing.comfort";
|
|
101
|
+
const BRIEF_STALE_PATH = "navigation.briefing.stale";
|
|
102
|
+
const BRIEF_AGE_HOURS_PATH = "navigation.briefing.ageHours";
|
|
103
|
+
/** A route briefing is overdue when the next daily edition misses
|
|
104
|
+
* this line (work doc #13); here mode keeps its 3h TTL. */
|
|
105
|
+
const ROUTE_TTL_MS = 26 * 60 * 60 * 1000;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* How often the cron ticker checks whether a publication window is due.
|
|
109
|
+
*/
|
|
110
|
+
const CRON_TICK_INTERVAL_MS = 60 * 1000;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Age at which a here payload is flagged stale to the webapp
|
|
114
|
+
* (work doc #7): the vessel position moves, so conditions at "here"
|
|
115
|
+
* go stale faster than a route briefing.
|
|
116
|
+
*/
|
|
117
|
+
const HERE_TTL_MS = 3 * 60 * 60 * 1000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Plugin configuration defaults (SPEC §2.1). The spool directory
|
|
121
|
+
* resolves against the server user's home so no absolute path is
|
|
122
|
+
* hardcoded (on the standard install this is
|
|
123
|
+
* `~/.signalk/spool/passage-outlook`).
|
|
124
|
+
*/
|
|
125
|
+
const DEFAULTS = {
|
|
126
|
+
motoring_tws_threshold: 3.5,
|
|
127
|
+
drift_mode_enabled: true,
|
|
128
|
+
waterline_length_m: 9.4,
|
|
129
|
+
spool_directory: join(homedir(), ".signalk", "spool", "passage-outlook"),
|
|
130
|
+
k_heel: 0.35,
|
|
131
|
+
k_pitch: 0.4,
|
|
132
|
+
/** Verified NWS High Seas Forecast feeds (METAREA XII/XV). The
|
|
133
|
+
* METAREA XIV issuer (MetService) gets added as a URL once a
|
|
134
|
+
* working endpoint is confirmed on board. */
|
|
135
|
+
bulletin_urls: [
|
|
136
|
+
"https://api.weather.gov/products/types/HSF/locations/NP",
|
|
137
|
+
"https://api.weather.gov/products/types/HSF/locations/EP1",
|
|
138
|
+
"https://api.weather.gov/products/types/HSF/locations/EP2",
|
|
139
|
+
],
|
|
140
|
+
/** TGFTP station→zone fast path (work doc #9 §sources 3): the
|
|
141
|
+
* doc's XIV example (Fiji radio re-broadcasting NAVAREA XIV) as the
|
|
142
|
+
* seed; extend via configuration. */
|
|
143
|
+
bulletin_stations: [{ zone: 14, header: "FQPS01", station: "NFFN" }],
|
|
144
|
+
publish_metarea_notes: true,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {ServerAPI} app - Signal K server API
|
|
149
|
+
* @returns {Plugin}
|
|
150
|
+
*/
|
|
151
|
+
module.exports = (app) => {
|
|
152
|
+
const setStatus = (app.setPluginStatus || app.setProviderStatus)?.bind(app);
|
|
153
|
+
// Failures belong in the plugin error state (SK surfaces it
|
|
154
|
+
// distinctly), not the status line
|
|
155
|
+
const setError = (app.setPluginError || app.error)?.bind(app);
|
|
156
|
+
const unsubscribes = [];
|
|
157
|
+
|
|
158
|
+
/** @type {PassageStateMachine|null} */
|
|
159
|
+
let stateMachine = null;
|
|
160
|
+
/** @type {PassageDatabase|null} */
|
|
161
|
+
let db = null;
|
|
162
|
+
/** Bulletin source URLs (from configuration). */
|
|
163
|
+
let bulletinUrls = DEFAULTS.bulletin_urls;
|
|
164
|
+
/** TGFTP station→zone table (from configuration). */
|
|
165
|
+
let bulletinStations = DEFAULTS.bulletin_stations;
|
|
166
|
+
|
|
167
|
+
/** Simulation-relevant config subset served to the webapp worker. */
|
|
168
|
+
let simulationConfig = {
|
|
169
|
+
motoring_tws_threshold: DEFAULTS.motoring_tws_threshold,
|
|
170
|
+
drift_mode_enabled: DEFAULTS.drift_mode_enabled,
|
|
171
|
+
waterline_length_m: DEFAULTS.waterline_length_m,
|
|
172
|
+
k_heel: DEFAULTS.k_heel,
|
|
173
|
+
k_pitch: DEFAULTS.k_pitch,
|
|
174
|
+
};
|
|
175
|
+
/** @type {NodeJS.Timeout|null} */
|
|
176
|
+
let cronTimer = null;
|
|
177
|
+
/** Last known values of the watched paths. */
|
|
178
|
+
const observations = {
|
|
179
|
+
[INTERNET_STATE_PATH]: null,
|
|
180
|
+
[NAVIGATION_STATE_PATH]: null,
|
|
181
|
+
[HOUSE_SOC_PATH]: null,
|
|
182
|
+
};
|
|
183
|
+
/** Latest compiled-brief metadata for the plotter tile (doc #8). */
|
|
184
|
+
const briefMeta = { generatedAt: null, route: null };
|
|
185
|
+
/** Last acknowledged `generatedAt` (tile NEW badge reset point). */
|
|
186
|
+
let lastAckAt = null;
|
|
187
|
+
/** Plotter-extension provider teardown (registered at start). */
|
|
188
|
+
let teardownPlotterExt = null;
|
|
189
|
+
let teardownStatusTiles = null;
|
|
190
|
+
let teardownNotesProvider = null;
|
|
191
|
+
let notesStore = null;
|
|
192
|
+
/** Publish METAREA blocks as resources/notes (doc #12). */
|
|
193
|
+
let publishNotesEnabled = true;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Whether the internet link currently allows fetching.
|
|
197
|
+
*
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
function isOnline() {
|
|
201
|
+
const state = observations[INTERNET_STATE_PATH];
|
|
202
|
+
return state === "online" || state === "metered";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Resolves the id of the vessel's active route from
|
|
207
|
+
* `navigation.course.activeRoute`. Both REST shapes occur — the
|
|
208
|
+
* whole node wrapped (`{value: {href…}}`) and individual leaves
|
|
209
|
+
* wrapped (`{href: {value…}}`) — so both are unwrapped, matching
|
|
210
|
+
* the dead-reckoning plugin's reading of the same path.
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} [cached] - Pre-read value (delta cache) tried first
|
|
213
|
+
* @returns {string|null} Route resource id, or null when not navigating a route
|
|
214
|
+
*/
|
|
215
|
+
function activeRouteId(cached) {
|
|
216
|
+
const raw =
|
|
217
|
+
cached !== undefined
|
|
218
|
+
? cached
|
|
219
|
+
: typeof app.getSelfPath === "function"
|
|
220
|
+
? app.getSelfPath(ACTIVE_ROUTE_PATH)
|
|
221
|
+
: null;
|
|
222
|
+
const unwrap = (v) =>
|
|
223
|
+
v && typeof v === "object" && v.value !== undefined ? v.value : v;
|
|
224
|
+
const active = unwrap(raw);
|
|
225
|
+
const href = unwrap(active?.href);
|
|
226
|
+
if (typeof href !== "string") {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const match = href.match(/\/resources\/routes\/([^/?#]+)/);
|
|
230
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Cache entries merged into one serve-time console (work doc #9:
|
|
234
|
+
* UKHO structured warnings and portal text from several zones ride
|
|
235
|
+
* the same cache). */
|
|
236
|
+
const MERGE_BULLETINS = 4;
|
|
237
|
+
|
|
238
|
+
/** Filtered blocks for one cache entry against a track: structured
|
|
239
|
+
* UKHO JSON entries skip the text pipeline entirely. */
|
|
240
|
+
function blocksForEntry(entry, track) {
|
|
241
|
+
if (entry.format === "ukho-json") {
|
|
242
|
+
try {
|
|
243
|
+
return ukhoBlocksFromWarnings(
|
|
244
|
+
parseUkhoWarnings(JSON.parse(entry.text)),
|
|
245
|
+
track,
|
|
246
|
+
);
|
|
247
|
+
} catch {
|
|
248
|
+
return []; // Corrupted entry: skip, other entries still serve
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
const bulletin = filterBulletin({
|
|
252
|
+
rawText: entry.text,
|
|
253
|
+
source: entry.source ?? "api",
|
|
254
|
+
track,
|
|
255
|
+
issuedAt: entry.fetchedAt,
|
|
256
|
+
});
|
|
257
|
+
return bulletin?.blocks ?? [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Bulletin-level metadata (header/issuedAt/source/raw text) for
|
|
261
|
+
* one cache entry, shape-compatible across both ingestion paths. */
|
|
262
|
+
function bulletinMetaForEntry(entry) {
|
|
263
|
+
if (entry.format === "ukho-json") {
|
|
264
|
+
let warnings = [];
|
|
265
|
+
try {
|
|
266
|
+
warnings = parseUkhoWarnings(JSON.parse(entry.text));
|
|
267
|
+
} catch {
|
|
268
|
+
warnings = [];
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
header:
|
|
272
|
+
warnings[0]?.text.split(/\r?\n/, 1)[0]?.trim() ?? "UKHO warnings",
|
|
273
|
+
issuedAt:
|
|
274
|
+
warnings.find((warning) => warning.issuedAt)?.issuedAt ??
|
|
275
|
+
entry.fetchedAt ??
|
|
276
|
+
new Date(0).toISOString(),
|
|
277
|
+
bulletinText: entry.text,
|
|
278
|
+
source: entry.source ?? "ukho",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const bulletin = filterBulletin({
|
|
282
|
+
rawText: entry.text,
|
|
283
|
+
source: entry.source ?? "api",
|
|
284
|
+
track: [],
|
|
285
|
+
issuedAt: entry.fetchedAt,
|
|
286
|
+
});
|
|
287
|
+
return bulletin
|
|
288
|
+
? {
|
|
289
|
+
header: bulletin.header,
|
|
290
|
+
issuedAt: bulletin.issuedAt,
|
|
291
|
+
bulletinText: bulletin.bulletinText,
|
|
292
|
+
source: bulletin.source,
|
|
293
|
+
}
|
|
294
|
+
: null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Filters the newest cached bulletins against a track (work docs
|
|
299
|
+
* #4 and #9). Blocks from the newest few entries — text pipeline
|
|
300
|
+
* and structured UKHO JSON alike — merge into one console, deduped
|
|
301
|
+
* by text. Returns null when nothing is cached.
|
|
302
|
+
*
|
|
303
|
+
* @param {Array<{lat: number, lon: number}>} waypoints - Sampled
|
|
304
|
+
* route waypoints
|
|
305
|
+
* @returns {Promise<object|null>} `metareaBulletin` shape with `blocks`
|
|
306
|
+
*/
|
|
307
|
+
async function bulletinForTrack(waypoints) {
|
|
308
|
+
const cached = await loadBulletinCache(app.getDataDirPath());
|
|
309
|
+
if (cached.length === 0) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const track = waypoints.map((w) => [w.lon, w.lat]);
|
|
313
|
+
const newest = cached[0];
|
|
314
|
+
const meta = bulletinMetaForEntry(newest) ?? {
|
|
315
|
+
header: "",
|
|
316
|
+
issuedAt: newest.fetchedAt ?? new Date(0).toISOString(),
|
|
317
|
+
bulletinText: newest.text,
|
|
318
|
+
source: newest.source ?? "api",
|
|
319
|
+
};
|
|
320
|
+
const blocks = [];
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
for (const entry of cached.slice(0, MERGE_BULLETINS)) {
|
|
323
|
+
for (const block of blocksForEntry(entry, track)) {
|
|
324
|
+
if (seen.has(block.text)) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
seen.add(block.text);
|
|
328
|
+
blocks.push(block);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return { ...meta, blocks };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Pulls bulletins while online (work doc #9): resolves the active
|
|
336
|
+
* GMDSS zones from the route track and fetches only those, then
|
|
337
|
+
* any configured extra feeds. Merges into the disk cache.
|
|
338
|
+
*
|
|
339
|
+
* @param {"oneshot"|"cron"|"briefing"|"manual"} trigger
|
|
340
|
+
* @param {Array<{lat: number, lon: number}>} [waypoints] - Track
|
|
341
|
+
* for zone resolution (position-only when omitted)
|
|
342
|
+
*/
|
|
343
|
+
async function refreshBulletinsOnline(trigger, waypoints = []) {
|
|
344
|
+
if (!isOnline()) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Zone-targeted pulls (work doc #9 fetch strategy): TGFTP fast
|
|
348
|
+
// path for configured stations, GMDSS portal fallback, then the
|
|
349
|
+
// UKHO MSI JSON for the same zones (structured navigational
|
|
350
|
+
// warnings stored raw and filtered at serve time)
|
|
351
|
+
const track = waypoints.map((w) => [w.lon, w.lat]);
|
|
352
|
+
const zones = resolveZones(track);
|
|
353
|
+
const zoneBulletins = await fetchZoneBulletins({
|
|
354
|
+
zones,
|
|
355
|
+
tgftpStations: bulletinStations,
|
|
356
|
+
});
|
|
357
|
+
for (const zone of zones) {
|
|
358
|
+
const url = ukhoWarningsUrl(zone);
|
|
359
|
+
try {
|
|
360
|
+
const { text } = await resolveBulletinSource(url, {});
|
|
361
|
+
zoneBulletins.push({
|
|
362
|
+
url,
|
|
363
|
+
text,
|
|
364
|
+
source: "ukho",
|
|
365
|
+
zone,
|
|
366
|
+
format: "ukho-json",
|
|
367
|
+
});
|
|
368
|
+
} catch {
|
|
369
|
+
// UKHO unavailable this cycle: portal text still covers the zone
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const result = await refreshBulletins({
|
|
373
|
+
dataDir: app.getDataDirPath(),
|
|
374
|
+
urls: [],
|
|
375
|
+
zoneBulletins,
|
|
376
|
+
});
|
|
377
|
+
// Custom extra feeds (source-agnostic escape hatch)
|
|
378
|
+
if (bulletinUrls.length > 0) {
|
|
379
|
+
const extra = await refreshBulletins({
|
|
380
|
+
dataDir: app.getDataDirPath(),
|
|
381
|
+
urls: bulletinUrls,
|
|
382
|
+
});
|
|
383
|
+
result.fetched.push(...extra.fetched);
|
|
384
|
+
result.failed.push(...extra.failed);
|
|
385
|
+
}
|
|
386
|
+
if (result.fetched.length > 0) {
|
|
387
|
+
app.debug?.(
|
|
388
|
+
`Bulletins refreshed (${trigger}) zones ${zones.join(",") || "none"}: ` +
|
|
389
|
+
`${result.fetched.length} fetched` +
|
|
390
|
+
(result.failed.length ? `, ${result.failed.length} failed` : ""),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Pulls the resolved zones' synoptic surface-analysis charts while
|
|
397
|
+
* online (work doc #11), sharing the online gate and the zone
|
|
398
|
+
* resolution with the bulletin pull.
|
|
399
|
+
*
|
|
400
|
+
* @param {"oneshot"|"cron"|"briefing"|"manual"|"here"} trigger
|
|
401
|
+
* @param {Array<{lat: number, lon: number}>} [waypoints]
|
|
402
|
+
*/
|
|
403
|
+
async function refreshSynopticsOnline(trigger, waypoints = []) {
|
|
404
|
+
if (!isOnline()) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
const zones = resolveZones(waypoints.map((w) => [w.lon, w.lat]));
|
|
408
|
+
if (zones.length === 0) {
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const result = await refreshSynoptics({
|
|
412
|
+
dataDir: app.getDataDirPath(),
|
|
413
|
+
zones,
|
|
414
|
+
// Short on purpose: a hanging agency host must not stall the
|
|
415
|
+
// whole briefing window (candidates are tried in order)
|
|
416
|
+
timeoutMs: 8000,
|
|
417
|
+
});
|
|
418
|
+
for (const failure of result.failed) {
|
|
419
|
+
app.error?.(
|
|
420
|
+
`Synoptic chart fetch failed (zone ${failure.zone}): ` +
|
|
421
|
+
`${failure.url} — ${failure.error}`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (result.fetched.length > 0) {
|
|
425
|
+
app.debug?.(
|
|
426
|
+
`Synoptic charts refreshed (${trigger}): zones ` +
|
|
427
|
+
`${result.fetched.join(",")}` +
|
|
428
|
+
(result.failed.length ? `, failed ${result.failed.join(",")}` : ""),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Publishes the filtered bulletin blocks as georeferenced server
|
|
435
|
+
* notes (work doc #12). Local-only: not gated on the internet
|
|
436
|
+
* state, so spool-sourced warnings publish the same way.
|
|
437
|
+
*
|
|
438
|
+
* @param {"here"|"route"} trigger
|
|
439
|
+
* @param {object|null} bulletin - metareaBulletin with blocks
|
|
440
|
+
* @param {Array<{lat: number, lon: number}>} waypoints
|
|
441
|
+
*/
|
|
442
|
+
async function publishNotesFor(trigger, bulletin, waypoints) {
|
|
443
|
+
if (!publishNotesEnabled || !bulletin) {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const zone = resolveZones(waypoints.map((w) => [w.lon, w.lat]))[0] ?? null;
|
|
447
|
+
let chart = null;
|
|
448
|
+
if (zone != null) {
|
|
449
|
+
const cached = await loadSynoptic(app.getDataDirPath(), zone);
|
|
450
|
+
if (cached) {
|
|
451
|
+
chart = {
|
|
452
|
+
url: `/plugins/signalk-passage-briefing/api/synoptic?zone=${zone}`,
|
|
453
|
+
mimeType: cached.format === "gif" ? "image/gif" : "image/png",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const result = await publishNotes({
|
|
458
|
+
store: notesStore,
|
|
459
|
+
bulletin,
|
|
460
|
+
zone,
|
|
461
|
+
synopticChartFor: () => chart,
|
|
462
|
+
ref: waypoints.length > 0 ? [waypoints[0].lon, waypoints[0].lat] : null,
|
|
463
|
+
});
|
|
464
|
+
if (result.published.length > 0 || result.pruned.length > 0) {
|
|
465
|
+
app.debug?.(
|
|
466
|
+
`Notes ${trigger}: ${result.published.length} published, ` +
|
|
467
|
+
`${result.pruned.length} expired`,
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Vessel position from the Signal K self path (work doc #7 here
|
|
474
|
+
* mode). Both wrapped and plain value shapes are unwrapped.
|
|
475
|
+
*
|
|
476
|
+
* @returns {{lat: number, lon: number}|null}
|
|
477
|
+
*/
|
|
478
|
+
function vesselPosition() {
|
|
479
|
+
if (typeof app.getSelfPath !== "function") {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
const raw = app.getSelfPath("navigation.position");
|
|
483
|
+
const unwrap = (v) =>
|
|
484
|
+
v && typeof v === "object" && v.value !== undefined ? v.value : v;
|
|
485
|
+
const position = unwrap(raw);
|
|
486
|
+
const lat = position?.latitude;
|
|
487
|
+
const lon = position?.longitude;
|
|
488
|
+
return typeof lat === "number" && typeof lon === "number"
|
|
489
|
+
? { lat, lon }
|
|
490
|
+
: null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Loads the cached here payload (`weather/here.json`), null when
|
|
495
|
+
* nothing is cached.
|
|
496
|
+
*
|
|
497
|
+
* @returns {Promise<{payload: object, cachedAt: string|null, stale: boolean}|null>}
|
|
498
|
+
*/
|
|
499
|
+
async function loadHere() {
|
|
500
|
+
try {
|
|
501
|
+
const file = join(app.getDataDirPath(), "weather", "here.json");
|
|
502
|
+
const payload = JSON.parse(await readFile(file, "utf8"));
|
|
503
|
+
const cachedAt = payload?.metadata?.fetchedAt ?? null;
|
|
504
|
+
const age = cachedAt
|
|
505
|
+
? Date.now() - new Date(cachedAt).getTime()
|
|
506
|
+
: Infinity;
|
|
507
|
+
return { payload, cachedAt, stale: age > HERE_TTL_MS };
|
|
508
|
+
} catch (_error) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Fetches the here payload: conditions at the vessel's current
|
|
515
|
+
* position for the next 24 hours (work doc #7). Same Unified
|
|
516
|
+
* Weather Payload shape with a single waypoint, so the webapp's
|
|
517
|
+
* rendering machinery works unchanged; bulletin filtering runs
|
|
518
|
+
* against the position alone.
|
|
519
|
+
*
|
|
520
|
+
* @returns {Promise<{cachedAt: string}>}
|
|
521
|
+
*/
|
|
522
|
+
async function refreshHereQueued() {
|
|
523
|
+
const position = vesselPosition();
|
|
524
|
+
if (!position) {
|
|
525
|
+
throw new Error("No vessel position available");
|
|
526
|
+
}
|
|
527
|
+
const waypoints = [
|
|
528
|
+
{ lat: position.lat, lon: position.lon, distanceFromStartNm: 0 },
|
|
529
|
+
];
|
|
530
|
+
// Bulletins ride the same online window, filtered to the position
|
|
531
|
+
await refreshBulletinsOnline("here", waypoints);
|
|
532
|
+
await refreshSynopticsOnline("here", waypoints);
|
|
533
|
+
// forecast_days=2: Open-Meteo's first day starts at 00Z, so two
|
|
534
|
+
// days guarantee 24 forward hours from any fetch time
|
|
535
|
+
const payload = await fetchWeatherAlongTrack({
|
|
536
|
+
waypoints,
|
|
537
|
+
forecastDays: 2,
|
|
538
|
+
});
|
|
539
|
+
payload.metadata.mode = "here";
|
|
540
|
+
const bulletin = await bulletinForTrack(waypoints);
|
|
541
|
+
if (bulletin) {
|
|
542
|
+
payload.metareaBulletin = bulletin;
|
|
543
|
+
}
|
|
544
|
+
await publishNotesFor("here", bulletin, waypoints);
|
|
545
|
+
// Space weather rides the same online window (work doc #3);
|
|
546
|
+
// degrades to an absent field when the endpoints fail
|
|
547
|
+
payload.spaceEvents = await fetchSpaceEvents({
|
|
548
|
+
lat: position.lat,
|
|
549
|
+
lon: position.lon,
|
|
550
|
+
});
|
|
551
|
+
const dir = join(app.getDataDirPath(), "weather");
|
|
552
|
+
await mkdir(dir, { recursive: true });
|
|
553
|
+
await writeFile(join(dir, "here.json"), JSON.stringify(payload));
|
|
554
|
+
// Tile freshness; the route field stays empty in here mode
|
|
555
|
+
payload.comfortTier = await currentComfortTier(payload);
|
|
556
|
+
recordBriefCompile(
|
|
557
|
+
payload.metadata.fetchedAt,
|
|
558
|
+
"",
|
|
559
|
+
payload.comfortTier,
|
|
560
|
+
"here",
|
|
561
|
+
);
|
|
562
|
+
return { cachedAt: payload.metadata.fetchedAt };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let modelsPromise = null;
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Current comfort tier for the tile (work doc #8): computed with
|
|
569
|
+
* the webapp's own model (models.mjs hereHourly at SOG 0) so the
|
|
570
|
+
* published value and the conditions-here view can never drift
|
|
571
|
+
* apart — one implementation, two consumers.
|
|
572
|
+
*
|
|
573
|
+
* @param {object} payload
|
|
574
|
+
* @returns {Promise<string|null>}
|
|
575
|
+
*/
|
|
576
|
+
async function currentComfortTier(payload) {
|
|
577
|
+
try {
|
|
578
|
+
if (!payload?.waypoints?.[0]?.forecasts?.length) {
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
modelsPromise ??= import("../public/components/models.mjs");
|
|
582
|
+
const { hereHourly } = await modelsPromise;
|
|
583
|
+
const rows = hereHourly(payload, {
|
|
584
|
+
waterline_length_m: simulationConfig.waterline_length_m,
|
|
585
|
+
k_heel: simulationConfig.k_heel,
|
|
586
|
+
k_pitch: simulationConfig.k_pitch,
|
|
587
|
+
});
|
|
588
|
+
return rows[0]?.comfortLevel ?? null;
|
|
589
|
+
} catch (_error) {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Single-flight refresh chain: the online-transition refresh and
|
|
595
|
+
* a manual/API refresh can overlap (cron + crew button), and two
|
|
596
|
+
* concurrent payload writes race on the cache files. Queue instead —
|
|
597
|
+
* overlapping triggers run one after the other. */
|
|
598
|
+
let refreshChain = Promise.resolve();
|
|
599
|
+
|
|
600
|
+
function serializeRefresh(task) {
|
|
601
|
+
const run = refreshChain.then(task, task);
|
|
602
|
+
refreshChain = run.catch(() => {});
|
|
603
|
+
return run;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function refreshHere() {
|
|
607
|
+
return serializeRefresh(() => refreshHereQueued());
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function refreshBriefing(routeId, forecastDays) {
|
|
611
|
+
return serializeRefresh(() => refreshBriefingQueued(routeId, forecastDays));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Fetches and caches the briefing payload for a route. Internet
|
|
616
|
+
* weather is only fetched while the link is up; everything served
|
|
617
|
+
* afterwards comes from the cache (on passage the boat is online
|
|
618
|
+
* for about an hour a day, so the last payload must survive the
|
|
619
|
+
* other 23 offline hours).
|
|
620
|
+
*
|
|
621
|
+
* @param {string} routeId
|
|
622
|
+
* @param {number} [forecastDays]
|
|
623
|
+
* @returns {Promise<{routeId: string, cached: boolean, cachedAt: string|null, fetchedAt?: string}>}
|
|
624
|
+
*/
|
|
625
|
+
async function refreshBriefingQueued(
|
|
626
|
+
routeId,
|
|
627
|
+
forecastDays = DEFAULT_FORECAST_DAYS,
|
|
628
|
+
) {
|
|
629
|
+
if (!isOnline()) {
|
|
630
|
+
throw new Error("Offline: internet weather is only fetched while online");
|
|
631
|
+
}
|
|
632
|
+
let coordinates;
|
|
633
|
+
if (typeof app.resourcesApi?.getResource === "function") {
|
|
634
|
+
const route = await app.resourcesApi.getResource("routes", routeId);
|
|
635
|
+
coordinates = route?.feature?.geometry?.coordinates;
|
|
636
|
+
}
|
|
637
|
+
if (!Array.isArray(coordinates) || coordinates.length < 2) {
|
|
638
|
+
throw new Error(`Route ${routeId} has no track`);
|
|
639
|
+
}
|
|
640
|
+
const waypoints = sampleRoutePoints(coordinates);
|
|
641
|
+
// Bulletins ride the same online window as the weather (SPEC §3.1
|
|
642
|
+
// metareaBulletin): pulled first so this briefing carries them
|
|
643
|
+
await refreshBulletinsOnline("briefing", waypoints);
|
|
644
|
+
await refreshSynopticsOnline("briefing", waypoints);
|
|
645
|
+
const payload = await fetchWeatherAlongTrack({ waypoints, forecastDays });
|
|
646
|
+
const bulletin = await bulletinForTrack(waypoints);
|
|
647
|
+
if (bulletin) {
|
|
648
|
+
payload.metareaBulletin = bulletin;
|
|
649
|
+
}
|
|
650
|
+
await publishNotesFor("route", bulletin, waypoints);
|
|
651
|
+
// Space weather for the departure position (work doc #3 Phase 1)
|
|
652
|
+
payload.spaceEvents = await fetchSpaceEvents({
|
|
653
|
+
lat: waypoints[0].lat,
|
|
654
|
+
lon: waypoints[0].lon,
|
|
655
|
+
});
|
|
656
|
+
await savePayload(app.getDataDirPath(), routeId, payload);
|
|
657
|
+
await writeFile(
|
|
658
|
+
join(app.getDataDirPath(), "weather", "last-route"),
|
|
659
|
+
routeId,
|
|
660
|
+
);
|
|
661
|
+
// Tile freshness (work doc #8): route name when resolvable
|
|
662
|
+
let routeName = routeId;
|
|
663
|
+
try {
|
|
664
|
+
const route = await app.resourcesApi?.getResource?.("routes", routeId);
|
|
665
|
+
routeName = route?.name ?? routeId;
|
|
666
|
+
} catch (_error) {
|
|
667
|
+
// Offline or missing resources API: the id still identifies it
|
|
668
|
+
}
|
|
669
|
+
payload.comfortTier = await currentComfortTier(payload);
|
|
670
|
+
recordBriefCompile(
|
|
671
|
+
payload.metadata.fetchedAt,
|
|
672
|
+
routeName,
|
|
673
|
+
payload.comfortTier,
|
|
674
|
+
"route",
|
|
675
|
+
);
|
|
676
|
+
return {
|
|
677
|
+
routeId,
|
|
678
|
+
cached: true,
|
|
679
|
+
cachedAt: payload.metadata.fetchedAt,
|
|
680
|
+
fetchedAt: payload.metadata.fetchedAt,
|
|
681
|
+
waypoints,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Runs a weather fetch for the last briefed route (cron/oneshot
|
|
687
|
+
* trigger). The multi-endpoint fetch lands in the briefing refresh;
|
|
688
|
+
* the outcome is surfaced through the plugin status.
|
|
689
|
+
*
|
|
690
|
+
* @param {"oneshot"|"cron"} trigger
|
|
691
|
+
*/
|
|
692
|
+
async function runFetch(trigger) {
|
|
693
|
+
if (!isOnline()) {
|
|
694
|
+
setStatus(`Fetch skipped while offline (${trigger})`);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
// The route being sailed wins; otherwise re-brief the last one;
|
|
698
|
+
// with no route at all keep conditions-here fresh (work doc #7)
|
|
699
|
+
let routeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
|
|
700
|
+
if (!routeId) {
|
|
701
|
+
try {
|
|
702
|
+
routeId = (
|
|
703
|
+
await readFile(
|
|
704
|
+
join(app.getDataDirPath(), "weather", "last-route"),
|
|
705
|
+
"utf8",
|
|
706
|
+
)
|
|
707
|
+
).trim();
|
|
708
|
+
} catch (_error) {
|
|
709
|
+
routeId = null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!routeId) {
|
|
713
|
+
try {
|
|
714
|
+
const here = await refreshHere();
|
|
715
|
+
setStatus(`Conditions here cached at ${here.cachedAt} (${trigger})`);
|
|
716
|
+
} catch (error) {
|
|
717
|
+
app.error(`Here refresh failed (${trigger}): ${error.message}`);
|
|
718
|
+
setError(`Here refresh failed (${trigger}): ${error.message}`);
|
|
719
|
+
}
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
try {
|
|
723
|
+
const result = await refreshBriefing(routeId);
|
|
724
|
+
// Bulletins ride the same online window (work doc #4 §1)
|
|
725
|
+
await refreshBulletinsOnline(trigger, result.waypoints);
|
|
726
|
+
setStatus(
|
|
727
|
+
`Briefing for ${routeId} cached at ${result.fetchedAt} (${trigger})`,
|
|
728
|
+
);
|
|
729
|
+
} catch (error) {
|
|
730
|
+
app.error(`Briefing refresh failed (${trigger}): ${error.message}`);
|
|
731
|
+
setError(`Briefing refresh failed (${trigger}): ${error.message}`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Publishes the tile's flat scalar paths over the Signal K stream
|
|
737
|
+
* (work doc #8: tile data flows over the host bus relay — the
|
|
738
|
+
* widget never calls REST).
|
|
739
|
+
*/
|
|
740
|
+
function publishBriefMeta() {
|
|
741
|
+
if (typeof app.handleMessage !== "function") {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
const hasNew =
|
|
745
|
+
briefMeta.generatedAt != null &&
|
|
746
|
+
(lastAckAt == null || briefMeta.generatedAt > lastAckAt);
|
|
747
|
+
const view = briefMetaView();
|
|
748
|
+
app.handleMessage(PLUGIN_ID, {
|
|
749
|
+
context: "vessels.self",
|
|
750
|
+
updates: [
|
|
751
|
+
{
|
|
752
|
+
timestamp: new Date().toISOString(),
|
|
753
|
+
values: [
|
|
754
|
+
{
|
|
755
|
+
path: BRIEF_GENERATED_AT_PATH,
|
|
756
|
+
value: view.generatedAt,
|
|
757
|
+
},
|
|
758
|
+
{ path: BRIEF_ROUTE_PATH, value: view.route },
|
|
759
|
+
{ path: BRIEF_COMFORT_PATH, value: view.comfort },
|
|
760
|
+
{ path: BRIEF_STALE_PATH, value: view.stale },
|
|
761
|
+
{ path: BRIEF_AGE_HOURS_PATH, value: view.ageHours },
|
|
762
|
+
{ path: BRIEF_HAS_NEW_PATH, value: hasNew },
|
|
763
|
+
],
|
|
764
|
+
},
|
|
765
|
+
],
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Records the compiled-brief freshness (refresh paths) and pushes
|
|
771
|
+
* it to the tile.
|
|
772
|
+
*
|
|
773
|
+
* @param {string|null} generatedAt
|
|
774
|
+
* @param {string|null} route - Route name; empty string in here mode
|
|
775
|
+
*/
|
|
776
|
+
/**
|
|
777
|
+
* The tile/meta view: current values plus the freshness verdict,
|
|
778
|
+
* shared by the bus publication and GET /api/brief-meta so every
|
|
779
|
+
* consumer sees the same numbers.
|
|
780
|
+
*
|
|
781
|
+
* @param {Date} [now]
|
|
782
|
+
* @returns {object} generatedAt/route/comfort/stale/ageHours/hasNew
|
|
783
|
+
*/
|
|
784
|
+
function briefMetaView(now = new Date()) {
|
|
785
|
+
const ageMs =
|
|
786
|
+
briefMeta.generatedAt != null
|
|
787
|
+
? now.getTime() - new Date(briefMeta.generatedAt).getTime()
|
|
788
|
+
: null;
|
|
789
|
+
const ttl = briefMeta.mode === "route" ? ROUTE_TTL_MS : HERE_TTL_MS;
|
|
790
|
+
const stale = ageMs != null && ageMs > ttl;
|
|
791
|
+
const ageHours =
|
|
792
|
+
ageMs != null ? Math.round((ageMs / 3600000) * 10) / 10 : null;
|
|
793
|
+
const hasNew =
|
|
794
|
+
briefMeta.generatedAt != null &&
|
|
795
|
+
(lastAckAt == null || briefMeta.generatedAt > lastAckAt);
|
|
796
|
+
return {
|
|
797
|
+
generatedAt: briefMeta.generatedAt,
|
|
798
|
+
route: briefMeta.route,
|
|
799
|
+
comfort: briefMeta.comfort,
|
|
800
|
+
stale,
|
|
801
|
+
ageHours,
|
|
802
|
+
hasNew,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function recordBriefCompile(
|
|
807
|
+
generatedAt,
|
|
808
|
+
route,
|
|
809
|
+
comfort = null,
|
|
810
|
+
mode = "here",
|
|
811
|
+
) {
|
|
812
|
+
briefMeta.generatedAt = generatedAt;
|
|
813
|
+
briefMeta.route = route;
|
|
814
|
+
briefMeta.comfort = comfort;
|
|
815
|
+
briefMeta.mode = mode;
|
|
816
|
+
publishBriefMeta();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Restores the acknowledged timestamp and seeds the tile with the
|
|
821
|
+
* freshest cached compile (route briefing or here payload) after a
|
|
822
|
+
* restart.
|
|
823
|
+
*/
|
|
824
|
+
async function seedBriefMeta() {
|
|
825
|
+
try {
|
|
826
|
+
lastAckAt = JSON.parse(
|
|
827
|
+
await readFile(
|
|
828
|
+
join(app.getDataDirPath(), "weather", "ack.json"),
|
|
829
|
+
"utf8",
|
|
830
|
+
),
|
|
831
|
+
).acknowledgedAt;
|
|
832
|
+
} catch (_error) {
|
|
833
|
+
lastAckAt = null;
|
|
834
|
+
}
|
|
835
|
+
try {
|
|
836
|
+
const routeId = (
|
|
837
|
+
await readFile(
|
|
838
|
+
join(app.getDataDirPath(), "weather", "last-route"),
|
|
839
|
+
"utf8",
|
|
840
|
+
)
|
|
841
|
+
).trim();
|
|
842
|
+
const cached = await loadPayload(app.getDataDirPath(), routeId);
|
|
843
|
+
if (cached?.payload?.metadata?.fetchedAt) {
|
|
844
|
+
let name = routeId;
|
|
845
|
+
try {
|
|
846
|
+
const route = await app.resourcesApi?.getResource?.(
|
|
847
|
+
"routes",
|
|
848
|
+
routeId,
|
|
849
|
+
);
|
|
850
|
+
name = route?.name ?? routeId;
|
|
851
|
+
} catch (_error) {
|
|
852
|
+
// Offline or missing resources API: the id still identifies it
|
|
853
|
+
}
|
|
854
|
+
recordBriefCompile(
|
|
855
|
+
cached.payload.metadata.fetchedAt,
|
|
856
|
+
name,
|
|
857
|
+
cached.payload.comfortTier ?? null,
|
|
858
|
+
"route",
|
|
859
|
+
);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
} catch (_error) {
|
|
863
|
+
// No last-route: fall through to the here payload
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const here = await loadHere();
|
|
867
|
+
if (here?.payload?.metadata?.fetchedAt) {
|
|
868
|
+
recordBriefCompile(
|
|
869
|
+
here.payload.metadata.fetchedAt,
|
|
870
|
+
"",
|
|
871
|
+
here.payload.comfortTier ?? null,
|
|
872
|
+
"here",
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
} catch (_error) {
|
|
876
|
+
// Nothing cached at all: tile stays muted
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Feeds a Signal K delta into the observation cache and re-evaluates
|
|
882
|
+
* the state machine.
|
|
883
|
+
*
|
|
884
|
+
* @param {object} delta - Signal K delta message
|
|
885
|
+
*/
|
|
886
|
+
function feedDelta(delta) {
|
|
887
|
+
for (const update of delta.updates || []) {
|
|
888
|
+
for (const { path, value } of update.values || []) {
|
|
889
|
+
if (WATCHED_PATHS.includes(path)) {
|
|
890
|
+
observations[path] = value;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const result = stateMachine.update({
|
|
895
|
+
internetState: observations[INTERNET_STATE_PATH],
|
|
896
|
+
navigationState: observations[NAVIGATION_STATE_PATH],
|
|
897
|
+
soc: observations[HOUSE_SOC_PATH],
|
|
898
|
+
});
|
|
899
|
+
if (result.fetch === "oneshot") {
|
|
900
|
+
runFetch("oneshot");
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Location of the signalk-logbook on-disk store. When the Signal K
|
|
906
|
+
* Resource API grows logbook support, this (and the logbook-source
|
|
907
|
+
* module behind it) is the swap point.
|
|
908
|
+
*
|
|
909
|
+
* @returns {string}
|
|
910
|
+
*/
|
|
911
|
+
function logbookStoreDir() {
|
|
912
|
+
const configPath = app.config?.configPath ?? app.dataDir;
|
|
913
|
+
return join(configPath, "plugin-config-data", "signalk-logbook");
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Sail inventory keys (from `@signalk/sailsconfiguration`) used to
|
|
918
|
+
* filter free-text noise out of manually edited log entries. Empty
|
|
919
|
+
* set when no inventory is configured: everything then parses.
|
|
920
|
+
*
|
|
921
|
+
* @returns {Promise<Set<string>>}
|
|
922
|
+
*/
|
|
923
|
+
async function knownSailKeys() {
|
|
924
|
+
const configPath = app.config?.configPath ?? app.dataDir;
|
|
925
|
+
const sails = await readSailsConfiguration(
|
|
926
|
+
join(configPath, "plugin-config-data", "sailsconfiguration.json"),
|
|
927
|
+
);
|
|
928
|
+
// Empty inventory means no filter: every component parses
|
|
929
|
+
return sails.length > 0
|
|
930
|
+
? new Set(sails.map((sail) => sail.nameKey))
|
|
931
|
+
: undefined;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const plugin = {
|
|
935
|
+
id: PLUGIN_ID,
|
|
936
|
+
name: "Passage Briefing",
|
|
937
|
+
description:
|
|
938
|
+
"Offshore passage daily briefing: multi-model weather outlook, comfort " +
|
|
939
|
+
"physics and learned sail preferences",
|
|
940
|
+
|
|
941
|
+
schema: {
|
|
942
|
+
type: "object",
|
|
943
|
+
properties: {
|
|
944
|
+
motoring_tws_threshold: {
|
|
945
|
+
type: "number",
|
|
946
|
+
title: "Motoring TWS Threshold (knots)",
|
|
947
|
+
default: DEFAULTS.motoring_tws_threshold,
|
|
948
|
+
},
|
|
949
|
+
drift_mode_enabled: {
|
|
950
|
+
type: "boolean",
|
|
951
|
+
title: "Enable Drift Mode (Zero Fuel / Current Drift)",
|
|
952
|
+
default: DEFAULTS.drift_mode_enabled,
|
|
953
|
+
},
|
|
954
|
+
waterline_length_m: {
|
|
955
|
+
type: "number",
|
|
956
|
+
title: "Waterline Length (meters)",
|
|
957
|
+
default: DEFAULTS.waterline_length_m,
|
|
958
|
+
},
|
|
959
|
+
spool_directory: {
|
|
960
|
+
type: "string",
|
|
961
|
+
title: "Local GRIB/Text Ingestion Directory",
|
|
962
|
+
default: DEFAULTS.spool_directory,
|
|
963
|
+
},
|
|
964
|
+
k_heel: {
|
|
965
|
+
type: "number",
|
|
966
|
+
title: "Heeling Acceleration Multiplier Constant",
|
|
967
|
+
default: DEFAULTS.k_heel,
|
|
968
|
+
},
|
|
969
|
+
k_pitch: {
|
|
970
|
+
type: "number",
|
|
971
|
+
title: "Pitching Acceleration Multiplier Constant",
|
|
972
|
+
default: DEFAULTS.k_pitch,
|
|
973
|
+
},
|
|
974
|
+
bulletin_urls: {
|
|
975
|
+
type: "array",
|
|
976
|
+
title: "High Seas Bulletin Sources (NAVAREA / HSF text)",
|
|
977
|
+
description:
|
|
978
|
+
"Plain-text or api.weather.gov product URLs, filtered per " +
|
|
979
|
+
"route and cached. Add the METAREA XIV source when verified.",
|
|
980
|
+
items: { type: "string" },
|
|
981
|
+
default: DEFAULTS.bulletin_urls,
|
|
982
|
+
},
|
|
983
|
+
bulletin_stations: {
|
|
984
|
+
type: "array",
|
|
985
|
+
title: "TGFTP Bulletin Stations (zone fast path)",
|
|
986
|
+
description:
|
|
987
|
+
"Station→zone table for the NOAA TGFTP raw-text fast path " +
|
|
988
|
+
"(work doc #9), fetched before the GMDSS portal fallback.",
|
|
989
|
+
items: {
|
|
990
|
+
type: "object",
|
|
991
|
+
properties: {
|
|
992
|
+
zone: { type: "number", title: "GMDSS zone number" },
|
|
993
|
+
header: {
|
|
994
|
+
type: "string",
|
|
995
|
+
title: "WMO header (e.g. FQPS01)",
|
|
996
|
+
},
|
|
997
|
+
station: {
|
|
998
|
+
type: "string",
|
|
999
|
+
title: "Station id (e.g. NFFN)",
|
|
1000
|
+
},
|
|
1001
|
+
},
|
|
1002
|
+
},
|
|
1003
|
+
default: DEFAULTS.bulletin_stations,
|
|
1004
|
+
},
|
|
1005
|
+
publish_metarea_notes: {
|
|
1006
|
+
type: "boolean",
|
|
1007
|
+
title: "Publish METAREA warnings as chart notes",
|
|
1008
|
+
description:
|
|
1009
|
+
"Write the geographically filtered warning blocks to the " +
|
|
1010
|
+
"server's resources/notes so chart plotters show them.",
|
|
1011
|
+
default: DEFAULTS.publish_metarea_notes,
|
|
1012
|
+
},
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* @param {object} options - Persisted plugin configuration
|
|
1018
|
+
*/
|
|
1019
|
+
start: (options) => {
|
|
1020
|
+
const config = { ...DEFAULTS, ...(options || {}) };
|
|
1021
|
+
bulletinUrls = Array.isArray(config.bulletin_urls)
|
|
1022
|
+
? config.bulletin_urls
|
|
1023
|
+
: [];
|
|
1024
|
+
publishNotesEnabled = config.publish_metarea_notes !== false;
|
|
1025
|
+
bulletinStations = Array.isArray(config.bulletin_stations)
|
|
1026
|
+
? config.bulletin_stations.filter(
|
|
1027
|
+
(station) =>
|
|
1028
|
+
station &&
|
|
1029
|
+
Number.isFinite(station.zone) &&
|
|
1030
|
+
typeof station.header === "string" &&
|
|
1031
|
+
typeof station.station === "string",
|
|
1032
|
+
)
|
|
1033
|
+
: [];
|
|
1034
|
+
simulationConfig = {
|
|
1035
|
+
motoring_tws_threshold: config.motoring_tws_threshold,
|
|
1036
|
+
drift_mode_enabled: config.drift_mode_enabled,
|
|
1037
|
+
waterline_length_m: config.waterline_length_m,
|
|
1038
|
+
k_heel: config.k_heel,
|
|
1039
|
+
k_pitch: config.k_pitch,
|
|
1040
|
+
};
|
|
1041
|
+
stateMachine = new PassageStateMachine();
|
|
1042
|
+
db = new PassageDatabase(app.getDataDirPath());
|
|
1043
|
+
|
|
1044
|
+
// Warm the webapp models module so the first compile does not
|
|
1045
|
+
// pay the dynamic-import cost mid-refresh
|
|
1046
|
+
modelsPromise ??= import("../public/components/models.mjs");
|
|
1047
|
+
modelsPromise.catch(() => {});
|
|
1048
|
+
|
|
1049
|
+
// Notes lifecycle (work doc #12): when publication is disabled
|
|
1050
|
+
// the store clears, so chart plotters stop seeing our warnings
|
|
1051
|
+
if (!publishNotesEnabled) {
|
|
1052
|
+
clearNotes(notesStore).catch(() => {});
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
app.subscriptionmanager.subscribe(
|
|
1056
|
+
{
|
|
1057
|
+
context: "vessels.self",
|
|
1058
|
+
subscribe: WATCHED_PATHS.map((path) => ({ path, policy: "instant" })),
|
|
1059
|
+
},
|
|
1060
|
+
unsubscribes,
|
|
1061
|
+
(err) => app.error(`Subscription error: ${err}`),
|
|
1062
|
+
(delta) => feedDelta(delta),
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
const tickerCount = 0;
|
|
1066
|
+
cronTimer = setInterval(() => {
|
|
1067
|
+
const result = stateMachine.tick(new Date());
|
|
1068
|
+
if (result.fetch === "cron") {
|
|
1069
|
+
runFetch("cron");
|
|
1070
|
+
}
|
|
1071
|
+
// Re-emit the tile paths every tick (work doc #13): deltas
|
|
1072
|
+
// only travel on change, and widgets that connect after the
|
|
1073
|
+
// last compile would otherwise wait up to five minutes for
|
|
1074
|
+
// values; stale/ageHours also drift with the clock
|
|
1075
|
+
if (briefMeta.generatedAt != null) {
|
|
1076
|
+
publishBriefMeta();
|
|
1077
|
+
}
|
|
1078
|
+
}, CRON_TICK_INTERVAL_MS);
|
|
1079
|
+
cronTimer.unref?.();
|
|
1080
|
+
|
|
1081
|
+
// Plotter-extension tile (work doc #8): manifest provider plus
|
|
1082
|
+
// the public asset mount; goes empty on stop
|
|
1083
|
+
if (typeof app.registerResourceProvider === "function") {
|
|
1084
|
+
teardownPlotterExt = registerPlotterExtension(app, { id: PLUGIN_ID });
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Status Tiles example set (work doc #13): read-only provider
|
|
1088
|
+
// so the comfort tile can be copied in the tiles screen
|
|
1089
|
+
teardownStatusTiles = registerStatusTileExamples(app, {
|
|
1090
|
+
id: PLUGIN_ID,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// The server has no built-in notes provider: the plugin
|
|
1094
|
+
// registers as the notes resource provider (work doc #12) and
|
|
1095
|
+
// serves the bulletin warnings plus any client-written notes
|
|
1096
|
+
notesStore = createNotesStore(app.getDataDirPath());
|
|
1097
|
+
teardownNotesProvider = registerNotesProvider(app, {
|
|
1098
|
+
id: PLUGIN_ID,
|
|
1099
|
+
store: notesStore,
|
|
1100
|
+
});
|
|
1101
|
+
// Acknowledge put clears the tile's NEW badge
|
|
1102
|
+
if (typeof app.registerPutHandler === "function") {
|
|
1103
|
+
app.registerPutHandler(
|
|
1104
|
+
"vessels.self",
|
|
1105
|
+
BRIEF_ACK_PATH,
|
|
1106
|
+
async (_ctx, _path, value) => {
|
|
1107
|
+
if (typeof value === "string" && value) {
|
|
1108
|
+
lastAckAt = value;
|
|
1109
|
+
try {
|
|
1110
|
+
const dir = join(app.getDataDirPath(), "weather");
|
|
1111
|
+
await mkdir(dir, { recursive: true });
|
|
1112
|
+
await writeFile(
|
|
1113
|
+
join(dir, "ack.json"),
|
|
1114
|
+
JSON.stringify({ acknowledgedAt: value }),
|
|
1115
|
+
);
|
|
1116
|
+
} catch (_error) {
|
|
1117
|
+
// Persistence is best-effort; the badge still clears
|
|
1118
|
+
}
|
|
1119
|
+
publishBriefMeta();
|
|
1120
|
+
}
|
|
1121
|
+
return { state: "COMPLETED" };
|
|
1122
|
+
},
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
// Seed the tile from the cache after a restart
|
|
1126
|
+
seedBriefMeta().catch(() => {});
|
|
1127
|
+
|
|
1128
|
+
setStatus("Passage briefing started");
|
|
1129
|
+
return config;
|
|
1130
|
+
},
|
|
1131
|
+
|
|
1132
|
+
stop: () => {
|
|
1133
|
+
if (cronTimer) {
|
|
1134
|
+
clearInterval(cronTimer);
|
|
1135
|
+
cronTimer = null;
|
|
1136
|
+
}
|
|
1137
|
+
if (teardownNotesProvider) {
|
|
1138
|
+
teardownNotesProvider();
|
|
1139
|
+
teardownNotesProvider = null;
|
|
1140
|
+
}
|
|
1141
|
+
if (teardownStatusTiles) {
|
|
1142
|
+
teardownStatusTiles();
|
|
1143
|
+
teardownStatusTiles = null;
|
|
1144
|
+
}
|
|
1145
|
+
if (teardownPlotterExt) {
|
|
1146
|
+
teardownPlotterExt();
|
|
1147
|
+
teardownPlotterExt = null;
|
|
1148
|
+
}
|
|
1149
|
+
for (const unsubscribe of unsubscribes) {
|
|
1150
|
+
unsubscribe();
|
|
1151
|
+
}
|
|
1152
|
+
unsubscribes.length = 0;
|
|
1153
|
+
if (db) {
|
|
1154
|
+
db.close();
|
|
1155
|
+
db = null;
|
|
1156
|
+
}
|
|
1157
|
+
stateMachine = null;
|
|
1158
|
+
setStatus("Passage briefing stopped");
|
|
1159
|
+
},
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* REST API routes under `/plugins/<id>/`:
|
|
1163
|
+
*
|
|
1164
|
+
* - `GET /api/status` — state machine state and next cron window
|
|
1165
|
+
* - `GET /api/matrix` — learned sail preference matrix (SPEC §3.2)
|
|
1166
|
+
* - `GET /api/events` — recorded logbook sail events
|
|
1167
|
+
* - `GET /api/logbook-events` — sail events extracted from the
|
|
1168
|
+
* logbook store (not yet necessarily learned)
|
|
1169
|
+
* - `POST /api/backfill` — run the SPEC §4.2 backfill. Query
|
|
1170
|
+
* params: `from`/`to` (ISO window, optional), `source`
|
|
1171
|
+
* (`logbook` default: wind snapshots written in the log entries;
|
|
1172
|
+
* `history`: Signal K History API, needs `baseUrl` and works
|
|
1173
|
+
* only on board with history present).
|
|
1174
|
+
*
|
|
1175
|
+
* @param {object} router - Express router mounted at the plugin root
|
|
1176
|
+
*/
|
|
1177
|
+
registerWithRouter: (router) => {
|
|
1178
|
+
router.get("/api/config", (_req, res) => {
|
|
1179
|
+
res.json(simulationConfig);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
router.get("/api/status", (_req, res) => {
|
|
1183
|
+
res.json({
|
|
1184
|
+
state: stateMachine ? stateMachine.state : null,
|
|
1185
|
+
nextCronRun: stateMachine?.scheduledCronRun?.toISOString() ?? null,
|
|
1186
|
+
online: isOnline(),
|
|
1187
|
+
activeRouteId: activeRouteId(observations[ACTIVE_ROUTE_PATH]),
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
router.get("/api/matrix", (_req, res) => {
|
|
1192
|
+
res.json(db ? db.getSailPreferenceMatrix() : null);
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
router.get("/api/events", (_req, res) => {
|
|
1196
|
+
res.json(db ? db.getSailEvents({ limit: 1000 }) : []);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
router.get("/api/logbook-events", async (req, res) => {
|
|
1200
|
+
try {
|
|
1201
|
+
const events = await readLogbookSailEvents({
|
|
1202
|
+
dir: logbookStoreDir(),
|
|
1203
|
+
from:
|
|
1204
|
+
typeof req.query.from === "string" ? req.query.from : undefined,
|
|
1205
|
+
to: typeof req.query.to === "string" ? req.query.to : undefined,
|
|
1206
|
+
knownSailKeys: await knownSailKeys(),
|
|
1207
|
+
});
|
|
1208
|
+
res.json(events);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
res.status(500).json({ error: error.message });
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
router.post("/api/briefing/refresh", async (req, res) => {
|
|
1215
|
+
const routeId =
|
|
1216
|
+
typeof req.query.route === "string" ? req.query.route : "";
|
|
1217
|
+
if (!isOnline()) {
|
|
1218
|
+
res
|
|
1219
|
+
.status(503)
|
|
1220
|
+
.json({ error: "Offline: serving cached briefings only" });
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
// No route requested: refresh conditions at the vessel
|
|
1225
|
+
// (work doc #7 here mode)
|
|
1226
|
+
if (!routeId) {
|
|
1227
|
+
const here = await refreshHere();
|
|
1228
|
+
res.json({ mode: "here", cached: true, cachedAt: here.cachedAt });
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
const days =
|
|
1232
|
+
typeof req.query.days === "string" && Number(req.query.days) > 0
|
|
1233
|
+
? Number(req.query.days)
|
|
1234
|
+
: DEFAULT_FORECAST_DAYS;
|
|
1235
|
+
res.json(await refreshBriefing(routeId, days));
|
|
1236
|
+
} catch (error) {
|
|
1237
|
+
app.error(`Briefing refresh failed: ${error.message}`);
|
|
1238
|
+
res.status(502).json({ error: error.message });
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* Serves a briefing payload with a top-level `mode` the webapp
|
|
1244
|
+
* routes on: `"route"` for the two-screen passage view,
|
|
1245
|
+
* `"here"` for the single conditions view (work doc #7). Mode
|
|
1246
|
+
* is derived from state: an explicit or active route wins,
|
|
1247
|
+
* everything else is here mode.
|
|
1248
|
+
*/
|
|
1249
|
+
router.get("/api/briefing", async (req, res) => {
|
|
1250
|
+
const routeId =
|
|
1251
|
+
typeof req.query.route === "string" ? req.query.route : "";
|
|
1252
|
+
const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
|
|
1253
|
+
const effectiveRoute = routeId || activeId;
|
|
1254
|
+
if (!effectiveRoute) {
|
|
1255
|
+
const here = await loadHere();
|
|
1256
|
+
if (!here) {
|
|
1257
|
+
// Empty, not 404: the webapp renders its refresh strip
|
|
1258
|
+
// instead of logging a failed request
|
|
1259
|
+
res.json({
|
|
1260
|
+
mode: "here",
|
|
1261
|
+
payload: null,
|
|
1262
|
+
cached: false,
|
|
1263
|
+
online: isOnline(),
|
|
1264
|
+
});
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
res.json({
|
|
1268
|
+
mode: "here",
|
|
1269
|
+
payload: here.payload,
|
|
1270
|
+
cachedAt: here.cachedAt,
|
|
1271
|
+
stale: here.stale,
|
|
1272
|
+
online: isOnline(),
|
|
1273
|
+
});
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const cached = await loadPayload(app.getDataDirPath(), effectiveRoute);
|
|
1277
|
+
if (!cached) {
|
|
1278
|
+
res.json({
|
|
1279
|
+
mode: "route",
|
|
1280
|
+
routeId: effectiveRoute,
|
|
1281
|
+
payload: null,
|
|
1282
|
+
cached: false,
|
|
1283
|
+
online: isOnline(),
|
|
1284
|
+
});
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
// Splice the freshest cached bulletin into older briefings so
|
|
1288
|
+
// the warning panel stays current through the offline hours
|
|
1289
|
+
if (!cached.payload?.metareaBulletin) {
|
|
1290
|
+
const bulletin = await bulletinForTrack(
|
|
1291
|
+
cached.payload?.waypoints ?? [],
|
|
1292
|
+
);
|
|
1293
|
+
if (bulletin) {
|
|
1294
|
+
cached.payload.metareaBulletin = bulletin;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
res.json({
|
|
1298
|
+
mode: "route",
|
|
1299
|
+
routeId: effectiveRoute,
|
|
1300
|
+
...cached,
|
|
1301
|
+
online: isOnline(),
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
router.get("/api/bulletin", async (req, res) => {
|
|
1306
|
+
// Filtered against the queried route's track, else unfiltered
|
|
1307
|
+
let waypoints = [];
|
|
1308
|
+
if (typeof req.query.route === "string") {
|
|
1309
|
+
try {
|
|
1310
|
+
const route = await app.resourcesApi.getResource(
|
|
1311
|
+
"routes",
|
|
1312
|
+
req.query.route,
|
|
1313
|
+
);
|
|
1314
|
+
waypoints = sampleRoutePoints(
|
|
1315
|
+
route?.feature?.geometry?.coordinates ?? [],
|
|
1316
|
+
);
|
|
1317
|
+
} catch {
|
|
1318
|
+
// Unknown route: serve the unfiltered bulletin
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
res.json(await bulletinForTrack(waypoints));
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
router.post("/api/bulletin/refresh", async (_req, res) => {
|
|
1325
|
+
if (!isOnline()) {
|
|
1326
|
+
res.status(503).json({
|
|
1327
|
+
error: "Offline: bulletins are only fetched while online",
|
|
1328
|
+
});
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
// Zone-targeted pull against the vessel position when known
|
|
1332
|
+
// (work doc #9), then the configured extra feeds
|
|
1333
|
+
const position = vesselPosition();
|
|
1334
|
+
await refreshBulletinsOnline(
|
|
1335
|
+
"manual",
|
|
1336
|
+
position ? [{ lat: position.lat, lon: position.lon }] : [],
|
|
1337
|
+
);
|
|
1338
|
+
const result = await refreshBulletins({
|
|
1339
|
+
dataDir: app.getDataDirPath(),
|
|
1340
|
+
urls: bulletinUrls,
|
|
1341
|
+
});
|
|
1342
|
+
res.json(result);
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
router.get("/api/brief-meta", (_req, res) => {
|
|
1346
|
+
res.json(briefMetaView());
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
router.get("/api/synoptic", async (req, res) => {
|
|
1350
|
+
let zone =
|
|
1351
|
+
typeof req.query.zone === "string" && /^\d+$/.test(req.query.zone)
|
|
1352
|
+
? Number(req.query.zone)
|
|
1353
|
+
: null;
|
|
1354
|
+
if (zone == null) {
|
|
1355
|
+
const position = vesselPosition();
|
|
1356
|
+
zone = position
|
|
1357
|
+
? (resolveZones([[position.lon, position.lat]])[0] ?? null)
|
|
1358
|
+
: null;
|
|
1359
|
+
}
|
|
1360
|
+
if (zone == null) {
|
|
1361
|
+
res.status(404).json({ error: "No zone to serve a chart for" });
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
const chart = await loadSynoptic(app.getDataDirPath(), zone);
|
|
1365
|
+
if (!chart) {
|
|
1366
|
+
const lastFailure = await loadSynopticFailure(
|
|
1367
|
+
app.getDataDirPath(),
|
|
1368
|
+
zone,
|
|
1369
|
+
);
|
|
1370
|
+
res.status(404).json({
|
|
1371
|
+
error: "No cached chart for this zone",
|
|
1372
|
+
...(lastFailure ? { lastFailure } : {}),
|
|
1373
|
+
});
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
res
|
|
1377
|
+
.type(chart.format === "gif" ? "image/gif" : "image/png")
|
|
1378
|
+
.send(chart.bytes);
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
router.get("/api/cached", async (_req, res) => {
|
|
1382
|
+
res.json(await listCachedRoutes(app.getDataDirPath()));
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
router.get("/api/routes", async (_req, res) => {
|
|
1386
|
+
try {
|
|
1387
|
+
if (typeof app.resourcesApi?.listResources !== "function") {
|
|
1388
|
+
res
|
|
1389
|
+
.status(501)
|
|
1390
|
+
.json({ error: "Resources API not available on this server" });
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
const routes = await app.resourcesApi.listResources("routes", {});
|
|
1394
|
+
const activeId = activeRouteId(observations[ACTIVE_ROUTE_PATH]);
|
|
1395
|
+
const list = Object.entries(routes || {}).map(([id, route]) => ({
|
|
1396
|
+
id,
|
|
1397
|
+
name: route?.name ?? id,
|
|
1398
|
+
distanceNm: routeDistanceNm(
|
|
1399
|
+
route?.feature?.geometry?.coordinates ?? [],
|
|
1400
|
+
),
|
|
1401
|
+
active: id === activeId,
|
|
1402
|
+
}));
|
|
1403
|
+
res.json(list);
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
res.status(500).json({ error: error.message });
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
router.get("/api/polar", async (_req, res) => {
|
|
1410
|
+
try {
|
|
1411
|
+
const { parseActivePolarId, parsePerformanceFactor } = await import(
|
|
1412
|
+
"../public/polar.mjs"
|
|
1413
|
+
);
|
|
1414
|
+
const raw =
|
|
1415
|
+
typeof app.getSelfPath === "function"
|
|
1416
|
+
? app.getSelfPath("polars.activePolar")
|
|
1417
|
+
: null;
|
|
1418
|
+
const id = parseActivePolarId(raw);
|
|
1419
|
+
if (!id) {
|
|
1420
|
+
res.json(null); // No active polar: webapp falls back to default
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
const table = await app.resourcesApi.getResource("polars", id);
|
|
1424
|
+
res.json({
|
|
1425
|
+
id,
|
|
1426
|
+
table,
|
|
1427
|
+
performanceFactor: parsePerformanceFactor(
|
|
1428
|
+
typeof app.getSelfPath === "function"
|
|
1429
|
+
? app.getSelfPath("polars.performanceFactor")
|
|
1430
|
+
: null,
|
|
1431
|
+
),
|
|
1432
|
+
});
|
|
1433
|
+
} catch {
|
|
1434
|
+
res.json(null);
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
router.post("/api/backfill", async (req, res) => {
|
|
1439
|
+
if (!db) {
|
|
1440
|
+
res.status(503).json({ error: "Plugin not started" });
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
try {
|
|
1444
|
+
const from =
|
|
1445
|
+
typeof req.query.from === "string" ? req.query.from : undefined;
|
|
1446
|
+
const to =
|
|
1447
|
+
typeof req.query.to === "string" ? req.query.to : undefined;
|
|
1448
|
+
const events = await readLogbookSailEvents({
|
|
1449
|
+
dir: logbookStoreDir(),
|
|
1450
|
+
from,
|
|
1451
|
+
to,
|
|
1452
|
+
knownSailKeys: await knownSailKeys(),
|
|
1453
|
+
});
|
|
1454
|
+
const source =
|
|
1455
|
+
req.query.source === "history" &&
|
|
1456
|
+
typeof req.query.baseUrl === "string"
|
|
1457
|
+
? createHistoryWindStats({ baseUrl: req.query.baseUrl })
|
|
1458
|
+
: createLogbookWindStats(
|
|
1459
|
+
await readLogbookEntries(logbookStoreDir()),
|
|
1460
|
+
);
|
|
1461
|
+
const summary = await backfillSailEvents({
|
|
1462
|
+
db,
|
|
1463
|
+
events,
|
|
1464
|
+
getWindStats: source,
|
|
1465
|
+
});
|
|
1466
|
+
res.json(summary);
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
res.status(500).json({ error: error.message });
|
|
1469
|
+
}
|
|
1470
|
+
});
|
|
1471
|
+
},
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
return plugin;
|
|
1475
|
+
};
|
|
1476
|
+
|
|
1477
|
+
module.exports.PLUGIN_ID = PLUGIN_ID;
|
|
1478
|
+
module.exports.DEFAULTS = DEFAULTS;
|
|
1479
|
+
module.exports.WATCHED_PATHS = WATCHED_PATHS;
|