@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.
Files changed (86) hide show
  1. package/.editorconfig +5 -0
  2. package/.github/workflows/publish.yml +34 -0
  3. package/.github/workflows/signalk-ci.yml +11 -0
  4. package/.github/workflows/test.yml +21 -0
  5. package/CHANGELOG.md +423 -0
  6. package/README.md +86 -0
  7. package/SPEC.md +488 -0
  8. package/bin/backfill-report.js +218 -0
  9. package/bin/backtest-cli.js +132 -0
  10. package/biome.json +6 -0
  11. package/doc/here-brief.png +0 -0
  12. package/package.json +48 -0
  13. package/plugin/backtest.js +742 -0
  14. package/plugin/brief-ext.js +173 -0
  15. package/plugin/bulletin-engine.js +746 -0
  16. package/plugin/bulletin-source.js +150 -0
  17. package/plugin/celestial-source.js +417 -0
  18. package/plugin/fetch-engine.js +717 -0
  19. package/plugin/history-backfill.js +540 -0
  20. package/plugin/index.js +1479 -0
  21. package/plugin/logbook-source.js +463 -0
  22. package/plugin/notes-publisher.js +228 -0
  23. package/plugin/notes-store.js +241 -0
  24. package/plugin/raster-convert.js +168 -0
  25. package/plugin/sails-configuration.js +111 -0
  26. package/plugin/spool-watcher.js +218 -0
  27. package/plugin/sqlite-db.js +378 -0
  28. package/plugin/state-machine.js +249 -0
  29. package/plugin/statustilesexamples.js +101 -0
  30. package/plugin/synoptic-map.json +45 -0
  31. package/plugin/synoptic-source.js +227 -0
  32. package/plugin/zone-source.js +339 -0
  33. package/public/app.js +18 -0
  34. package/public/brief-ext-model.js +64 -0
  35. package/public/brief-ext-widget.html +14 -0
  36. package/public/brief-ext-widget.js +294 -0
  37. package/public/components/backfill-controls.js +100 -0
  38. package/public/components/comfort-info.js +94 -0
  39. package/public/components/conditions-here.js +234 -0
  40. package/public/components/horizon-sparkline.js +77 -0
  41. package/public/components/models.mjs +372 -0
  42. package/public/components/passage-outlook.js +368 -0
  43. package/public/components/sk-api.js +146 -0
  44. package/public/components/sk-base-css.js +177 -0
  45. package/public/components/strategic-outlook.js +248 -0
  46. package/public/components/synoptic-chart.js +48 -0
  47. package/public/components/tactical-dashboard.js +172 -0
  48. package/public/css/visuals.css +292 -0
  49. package/public/gmdss-zones-min.json +287 -0
  50. package/public/icon.png +0 -0
  51. package/public/index.html +13 -0
  52. package/public/polar.mjs +303 -0
  53. package/public/route-sim.mjs +750 -0
  54. package/public/sereno-physics.mjs +592 -0
  55. package/public/tack-gybe.js +174 -0
  56. package/public/vendor/plotterext-bus/LICENSE +21 -0
  57. package/public/vendor/plotterext-bus/README.md +17 -0
  58. package/public/vendor/plotterext-bus/chunk-4W6N34SD.js +333 -0
  59. package/public/vendor/plotterext-bus/chunk-7XRFPDQL.js +263 -0
  60. package/public/vendor/plotterext-bus/chunk-RED55KML.js +117 -0
  61. package/public/vendor/plotterext-bus/extension.js +29 -0
  62. package/public/vendor/plotterext-bus/host.js +28 -0
  63. package/public/vendor/utif/LICENSE +21 -0
  64. package/public/vendor/utif/UTIF.js +1171 -0
  65. package/public/worker.js +35 -0
  66. package/status-tiles-examples.json +55 -0
  67. package/tests/backtest.test.js +461 -0
  68. package/tests/brief-ext.test.js +194 -0
  69. package/tests/bulletin-engine.test.js +361 -0
  70. package/tests/celestial-source.test.js +265 -0
  71. package/tests/fetch-engine.test.js +297 -0
  72. package/tests/history-backfill.test.js +341 -0
  73. package/tests/logbook-source.test.js +324 -0
  74. package/tests/notes-publisher.test.js +161 -0
  75. package/tests/notes-store.test.js +138 -0
  76. package/tests/openmeteo-mock.js +100 -0
  77. package/tests/plugin.test.js +1344 -0
  78. package/tests/route-sim.test.js +533 -0
  79. package/tests/sereno-physics.test.js +333 -0
  80. package/tests/sqlite-db.test.js +164 -0
  81. package/tests/state-machine.test.js +182 -0
  82. package/tests/statustilesexamples.test.js +86 -0
  83. package/tests/synoptic-source.test.js +195 -0
  84. package/tests/tack-gybe.test.js +211 -0
  85. package/tests/webapp.test.js +257 -0
  86. package/tests/zone-source.test.js +226 -0
@@ -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;