signalk-wakelogger 0.2.0-beta.5 → 0.2.0-beta.6

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 (78) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -0
  3. package/dist/config/defaults.d.ts +1 -0
  4. package/dist/config/defaults.js +2 -1
  5. package/dist/config/defaults.js.map +1 -1
  6. package/dist/config/schema.d.ts +8 -0
  7. package/dist/config/schema.js +8 -0
  8. package/dist/config/schema.js.map +1 -1
  9. package/dist/courses/protocol.d.ts +1 -0
  10. package/dist/courses/protocol.js +5 -0
  11. package/dist/courses/protocol.js.map +1 -1
  12. package/dist/index.js +269 -7
  13. package/dist/index.js.map +1 -1
  14. package/dist/race/averages.d.ts +12 -0
  15. package/dist/race/averages.js +44 -0
  16. package/dist/race/averages.js.map +1 -0
  17. package/dist/race/detection.d.ts +65 -0
  18. package/dist/race/detection.js +150 -0
  19. package/dist/race/detection.js.map +1 -0
  20. package/dist/race/dynamic.d.ts +114 -0
  21. package/dist/race/dynamic.js +265 -0
  22. package/dist/race/dynamic.js.map +1 -0
  23. package/dist/race/observation-window.d.ts +81 -0
  24. package/dist/race/observation-window.js +198 -0
  25. package/dist/race/observation-window.js.map +1 -0
  26. package/dist/race/observations.d.ts +54 -0
  27. package/dist/race/observations.js +212 -0
  28. package/dist/race/observations.js.map +1 -0
  29. package/dist/race/onboard-service.d.ts +58 -0
  30. package/dist/race/onboard-service.js +229 -0
  31. package/dist/race/onboard-service.js.map +1 -0
  32. package/dist/race/onboard-store.d.ts +66 -0
  33. package/dist/race/onboard-store.js +141 -0
  34. package/dist/race/onboard-store.js.map +1 -0
  35. package/dist/race/pack.d.ts +126 -0
  36. package/dist/race/pack.js +317 -0
  37. package/dist/race/pack.js.map +1 -0
  38. package/dist/race/plan.d.ts +21 -0
  39. package/dist/race/plan.js +45 -0
  40. package/dist/race/plan.js.map +1 -0
  41. package/dist/race/progression-service.d.ts +33 -0
  42. package/dist/race/progression-service.js +84 -0
  43. package/dist/race/progression-service.js.map +1 -0
  44. package/dist/race/progression-store.d.ts +27 -0
  45. package/dist/race/progression-store.js +90 -0
  46. package/dist/race/progression-store.js.map +1 -0
  47. package/dist/race/race-pack-protocol.d.ts +114 -0
  48. package/dist/race/race-pack-protocol.js +463 -0
  49. package/dist/race/race-pack-protocol.js.map +1 -0
  50. package/dist/race/race-pack-store.d.ts +34 -0
  51. package/dist/race/race-pack-store.js +181 -0
  52. package/dist/race/race-pack-store.js.map +1 -0
  53. package/dist/race/sailing/physics.d.ts +86 -0
  54. package/dist/race/sailing/physics.js +254 -0
  55. package/dist/race/sailing/physics.js.map +1 -0
  56. package/dist/race/sailing/selection.d.ts +109 -0
  57. package/dist/race/sailing/selection.js +452 -0
  58. package/dist/race/sailing/selection.js.map +1 -0
  59. package/dist/signalk/normaliser.d.ts +6 -0
  60. package/dist/signalk/normaliser.js +11 -0
  61. package/dist/signalk/normaliser.js.map +1 -1
  62. package/dist/signalk/subscriber.js +3 -1
  63. package/dist/signalk/subscriber.js.map +1 -1
  64. package/dist/telemetry/types.d.ts +4 -0
  65. package/dist/transport/mqtt-client.d.ts +16 -2
  66. package/dist/transport/mqtt-client.js +80 -6
  67. package/dist/transport/mqtt-client.js.map +1 -1
  68. package/dist/transport/topics.d.ts +3 -0
  69. package/dist/transport/topics.js +3 -1
  70. package/dist/transport/topics.js.map +1 -1
  71. package/docs/onboard-race-guidance.md +547 -0
  72. package/docs/race-pack-schema.md +315 -0
  73. package/package.json +7 -5
  74. package/public/api-client.mjs +7 -0
  75. package/public/app.mjs +121 -2
  76. package/public/index.html +1 -1
  77. package/public/race-plan.mjs +89 -0
  78. package/public/style.css +1 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Updates for boat owners and crew: what's new, what's improved, and anything you need to do.
4
4
 
5
+ ## [0.2.0-beta.6] - 2026-09-26
6
+
7
+ ### Onboard race planning matches Wake Logger exactly
8
+
9
+ - The onboard planner now starts the current leg from your boat's actual position, uses the real current in its speed and arrival estimates, and no longer assumes a fixed six-knot midpoint speed. Remaining distance and arrival times reflect where you actually are.
10
+ - Onboard wind is only used for the current leg once a genuine five-minute observation window is ready (30 good samples across at least four minutes, newest within 30 seconds). Until then the downloaded forecast is used, with a clear note.
11
+ - A downloaded course is only used when its course definition matches the Race Pack, so an edited course can't be planned against an old pack. Reversed courses are not planned rather than being calculated the wrong way round.
12
+ - Wake Logger cloud now calculates the remaining race with exactly the same rules as the onboard planner, so the two agree. Raw instrument samples are aggregated with the same method on both sides, and the Live tracking ON and OFF views show the same plan and sail recommendation.
13
+
14
+ ### Reliable Race Pack and race-plan hand-over
15
+
16
+ - A removed or archived course now clears the onboard Race Pack durably, so a stale copy can't reappear after a restart or reconnect.
17
+ - Onboard race-plan updates are only removed once Wake Logger confirms it stored them; if the confirmation is missed they are sent again after reconnecting or restarting.
18
+ - Onboard race-plan updates now carry the recording session they came from, so Wake Logger links them to the right outing even when they arrive late.
19
+ - Switching Live tracking off sends one final onboard status and then stops transmitting immediately. The final onboard status is kept by the server after the plugin disconnects, and an unexpected connection loss still reports the vessel offline.
20
+ - Added a production-like broker test (`npm run test:broker:dynsec`) that runs against a real Mosquitto dynamic-security broker and the Wake Logger cloud provisioning/framing functions.
21
+
5
22
  ## [0.2.0-beta.5] - 2026-09-16
6
23
 
7
24
  ### Live tracking when you want it
package/README.md CHANGED
@@ -55,6 +55,8 @@ Open the **Wake Logger webapp** from Signal K's Web Apps list, using a phone, ta
55
55
 
56
56
  The onboard map uses the same instrument styling as Wake Logger's live view, adapts to phone and tablet screens, and includes a full-screen view. It shows the complete course, your boat, the next mark and current leg. Distance, bearing, cross-track error, VMG and arrival estimates appear when Signal K can calculate them; unavailable readings are left clearly marked.
57
57
 
58
+ Alongside the map, the webapp shows an **onboard race plan**. While **Live tracking** is on, Wake Logger cloud calculates the remaining race: it starts the current leg from your boat's actual position, samples the downloaded forecast at each leg's estimated time, and accounts for current, so the suggested heading, sail plan and arrival estimate keep up with how the race is actually going. When **Live tracking** is off, the vessel performs the same calculation on board from the Race Pack synchronised before departure, so the plan is still available with no internet access. If no Race Pack has synchronised yet, the map and course still work and the plan is clearly marked as unavailable.
59
+
58
60
  Use **Advance point** to move to the next course point, or select a point and choose **Set point** to correct your progress. Progression is manual in this release: it does not automatically judge start-line crossings, mark roundings, gates or finishes.
59
61
 
60
62
  Your course is also available as a standard Signal K route for other onboard applications. If another app selects a different active route, Wake Logger shows that state. Choose **Activate Wake Logger course** when you want to return to it.
@@ -15,5 +15,6 @@ export interface PluginConfig {
15
15
  maxOutboxMb: number;
16
16
  maxOutboxDays: number;
17
17
  debugTelemetry: boolean;
18
+ raceProgressionMode: 'auto' | 'suggest' | 'off';
18
19
  }
19
20
  export declare function parseConfig(value: object): PluginConfig;
@@ -22,7 +22,8 @@ function parseConfig(value) {
22
22
  samplePeriodMs: boundedNumber(input.samplePeriodMs, exports.DEFAULTS.samplePeriodMs, 250, 60_000),
23
23
  maxOutboxMb: boundedNumber(input.maxOutboxMb, 250, 10, 4096),
24
24
  maxOutboxDays: boundedNumber(input.maxOutboxDays, 7, 1, 30),
25
- debugTelemetry: input.debugTelemetry === true
25
+ debugTelemetry: input.debugTelemetry === true,
26
+ raceProgressionMode: input.raceProgressionMode === 'suggest' || input.raceProgressionMode === 'off' ? input.raceProgressionMode : 'auto'
26
27
  };
27
28
  }
28
29
  function boundedNumber(value, fallback, min, max) {
@@ -1 +1 @@
1
- {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":";;;AAoBA,kCAcC;AAlCY,QAAA,QAAQ,GAAG;IACtB,aAAa,EAAE,gDAAgD;IAC/D,cAAc,EAAE,IAAI;IACpB,SAAS,EAAE,EAAE;IACb,eAAe,EAAE,EAAE,GAAG,IAAI;IAC1B,cAAc,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI;IACjC,cAAc,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;IACvC,YAAY,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;CACrB,CAAA;AAYV,SAAgB,WAAW,CAAC,KAAa;IACvC,MAAM,KAAK,GAAG,KAAgC,CAAA;IAC9C,OAAO;QACL,UAAU,EAAE,KAAK,CAAC,UAAU,KAAK,YAAY,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,WAAW;QAC1E,WAAW,EAAE,OAAO,KAAK,CAAC,WAAW,KAAK,QAAQ,IAAI,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS;QACrH,aAAa,EACX,OAAO,KAAK,CAAC,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC;YAChF,CAAC,CAAC,KAAK,CAAC,aAAa;YACrB,CAAC,CAAC,gBAAQ,CAAC,aAAa;QAC5B,cAAc,EAAE,aAAa,CAAC,KAAK,CAAC,cAAc,EAAE,gBAAQ,CAAC,cAAc,EAAE,GAAG,EAAE,MAAM,CAAC;QACzF,WAAW,EAAE,aAAa,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC;QAC5D,aAAa,EAAE,aAAa,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3D,cAAc,EAAE,KAAK,CAAC,cAAc,KAAK,IAAI;KAC9C,CAAA;AACH,CAAC;AAED,SAAS,aAAa,CAAC,KAAc,EAAE,QAAgB,EAAE,GAAW,EAAE,GAAW;IAC/E,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAA;AAC/G,CAAC"}
1
+ {"version":3,"file":"defaults.js","sourceRoot":"","sources":["../../src/config/defaults.ts"],"names":[],"mappings":";;;AAqBA,kCAeC;AApCY,QAAA,QAAQ,GAAG;IACtB,aAAa,EAAE,gDAAgD;IAC/D,cAAc,EAAE,IAAI;IACpB,SAAS,EAAE,EAAE;IACb,eAAe,EAAE,EAAE,GAAG,IAAI;IAC1B,cAAc,EAAE,GAAG,GAAG,IAAI,GAAG,IAAI;IACjC,cAAc,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI;IACvC,YAAY,EAAE,CAAC,GAAG,IAAI,GAAG,IAAI;CACrB,CAAA;AAaV,SAAgB,WAAW,CAAC,KAAa;IACvC,MAAM,KAAK,GAAG,KAAgC,CAAA;IAC9C,OAAO;QACL,UAAU,EAAE,KAAK,CAAC,UAAU,KAAK,YAAY,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,WAAW;QAC1E,WAAW,EAAE,OAAO,KAAK,CAAC,WAAW,KAAK,QAAQ,IAAI,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,SAAS;QACrH,aAAa,EACX,OAAO,KAAK,CAAC,aAAa,KAAK,QAAQ,IAAI,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,aAAa,CAAC;YAChF,CAAC,CAAC,KAAK,CAAC,aAAa;YACrB,CAAC,CAAC,gBAAQ,CAAC,aAAa;QAC5B,cAAc,EAAE,aAAa,CAAC,KAAK,CAAC,cAAc,EAAE,gBAAQ,CAAC,cAAc,EAAE,GAAG,EAAE,MAAM,CAAC;QACzF,WAAW,EAAE,aAAa,CAAC,KAAK,CAAC,WAAW,EAAE,GAAG,EAAE,EAAE,EAAE,IAAI,CAAC;QAC5D,aAAa,EAAE,aAAa,CAAC,KAAK,CAAC,aAAa,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3D,cAAc,EAAE,KAAK,CAAC,cAAc,KAAK,IAAI;QAC7C,mBAAmB,EAAE,KAAK,CAAC,mBAAmB,KAAK,SAAS,IAAI,KAAK,CAAC,mBAAmB,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,mBAAmB,CAAC,CAAC,CAAC,MAAM;KACzI,CAAA;AACH,CAAC;AAED,SAAS,aAAa,CAAC,KAAc,EAAE,QAAgB,EAAE,GAAW,EAAE,GAAW;IAC/E,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,GAAG,IAAI,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAA;AAC/G,CAAC"}
@@ -47,5 +47,13 @@ export declare const configSchema: {
47
47
  readonly title: "Enable telemetry debug logging";
48
48
  readonly default: false;
49
49
  };
50
+ readonly raceProgressionMode: {
51
+ readonly type: "string";
52
+ readonly title: "Mark detection mode";
53
+ readonly enum: readonly ["auto", "suggest", "off"];
54
+ readonly enumNames: readonly ["Automatic", "Suggest", "Off"];
55
+ readonly default: "auto";
56
+ readonly description: "Automatic advances the active course point when the onboard app detects a rounding, start or finish; it also records evidence for Wake Logger. Suggest asks the crew first. Off records evidence only.";
57
+ };
50
58
  };
51
59
  };
@@ -49,6 +49,14 @@ exports.configSchema = {
49
49
  type: 'boolean',
50
50
  title: 'Enable telemetry debug logging',
51
51
  default: false
52
+ },
53
+ raceProgressionMode: {
54
+ type: 'string',
55
+ title: 'Mark detection mode',
56
+ enum: ['auto', 'suggest', 'off'],
57
+ enumNames: ['Automatic', 'Suggest', 'Off'],
58
+ default: 'auto',
59
+ description: 'Automatic advances the active course point when the onboard app detects a rounding, start or finish; it also records evidence for Wake Logger. Suggest asks the crew first. Off records evidence only.'
52
60
  }
53
61
  }
54
62
  };
@@ -1 +1 @@
1
- {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":";;;AAAa,QAAA,YAAY,GAAG;IAC1B,IAAI,EAAE,QAAQ;IACd,oBAAoB,EAAE,KAAK;IAC3B,UAAU,EAAE;QACV,UAAU,EAAE;YACV,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,aAAa;YACpB,IAAI,EAAE,CAAC,WAAW,EAAE,YAAY,CAAC;YACjC,SAAS,EAAE,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;YACjD,OAAO,EAAE,WAAW;YACpB,WAAW,EAAE,qIAAqI;SACnJ;QACD,WAAW,EAAE;YACX,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,0BAA0B;YACjC,WAAW,EAAE,qFAAqF;SACnG;QACD,aAAa,EAAE;YACb,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,iBAAiB;YACxB,OAAO,EAAE,gDAAgD;YACzD,OAAO,EAAE,WAAW;SACrB;QACD,cAAc,EAAE;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,gCAAgC;YACvC,OAAO,EAAE,GAAG;YACZ,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,IAAI;SACd;QACD,WAAW,EAAE;YACX,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,iCAAiC;YACxC,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,GAAG;SACb;QACD,aAAa,EAAE;YACb,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,kCAAkC;YACzC,OAAO,EAAE,CAAC;YACV,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,CAAC;SACX;QACD,cAAc,EAAE;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,gCAAgC;YACvC,OAAO,EAAE,KAAK;SACf;KACF;CACO,CAAA"}
1
+ {"version":3,"file":"schema.js","sourceRoot":"","sources":["../../src/config/schema.ts"],"names":[],"mappings":";;;AAAa,QAAA,YAAY,GAAG;IAC1B,IAAI,EAAE,QAAQ;IACd,oBAAoB,EAAE,KAAK;IAC3B,UAAU,EAAE;QACV,UAAU,EAAE;YACV,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,aAAa;YACpB,IAAI,EAAE,CAAC,WAAW,EAAE,YAAY,CAAC;YACjC,SAAS,EAAE,CAAC,kBAAkB,EAAE,gBAAgB,CAAC;YACjD,OAAO,EAAE,WAAW;YACpB,WAAW,EAAE,qIAAqI;SACnJ;QACD,WAAW,EAAE;YACX,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,0BAA0B;YACjC,WAAW,EAAE,qFAAqF;SACnG;QACD,aAAa,EAAE;YACb,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,iBAAiB;YACxB,OAAO,EAAE,gDAAgD;YACzD,OAAO,EAAE,WAAW;SACrB;QACD,cAAc,EAAE;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,gCAAgC;YACvC,OAAO,EAAE,GAAG;YACZ,OAAO,EAAE,KAAK;YACd,OAAO,EAAE,IAAI;SACd;QACD,WAAW,EAAE;YACX,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,iCAAiC;YACxC,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,GAAG;SACb;QACD,aAAa,EAAE;YACb,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,kCAAkC;YACzC,OAAO,EAAE,CAAC;YACV,OAAO,EAAE,EAAE;YACX,OAAO,EAAE,CAAC;SACX;QACD,cAAc,EAAE;YACd,IAAI,EAAE,SAAS;YACf,KAAK,EAAE,gCAAgC;YACvC,OAAO,EAAE,KAAK;SACf;QACD,mBAAmB,EAAE;YACnB,IAAI,EAAE,QAAQ;YACd,KAAK,EAAE,qBAAqB;YAC5B,IAAI,EAAE,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAC;YAChC,SAAS,EAAE,CAAC,WAAW,EAAE,SAAS,EAAE,KAAK,CAAC;YAC1C,OAAO,EAAE,MAAM;YACf,WAAW,EAAE,wMAAwM;SACtN;KACF;CACO,CAAA"}
@@ -11,6 +11,7 @@ export interface ActiveCourseDocument {
11
11
  revision: number;
12
12
  courseId: string;
13
13
  racePlanId?: number;
14
+ courseDefinitionDigest?: string;
14
15
  name: string;
15
16
  updatedAt: string;
16
17
  start: CoursePoint;
@@ -41,13 +41,18 @@ function parseCourse(payload) {
41
41
  if (doc.activeWaypointIndex !== undefined && (!Number.isSafeInteger(doc.activeWaypointIndex)
42
42
  || doc.activeWaypointIndex < 0 || doc.activeWaypointIndex >= points.length))
43
43
  throw new CourseError('course_invalid_point_index');
44
+ if (doc.courseDefinitionDigest !== undefined && !isDigest(doc.courseDefinitionDigest))
45
+ throw new CourseError('course_invalid_digest');
44
46
  return {
45
47
  v: 1, action: 'activate', revision: doc.revision, courseId: doc.courseId, name: doc.name, updatedAt: doc.updatedAt,
46
48
  start: point(doc.start), marks: doc.marks.map(point), finish: point(doc.finish),
47
49
  ...(Number.isSafeInteger(doc.racePlanId) ? { racePlanId: doc.racePlanId } : {}),
50
+ ...(isDigest(doc.courseDefinitionDigest) ? { courseDefinitionDigest: doc.courseDefinitionDigest.toLowerCase() } : {}),
48
51
  ...(doc.activeWaypointIndex !== undefined ? { activeWaypointIndex: doc.activeWaypointIndex } : {})
49
52
  };
50
53
  }
54
+ const COURSE_DIGEST = /^[0-9a-fA-F]{64}$/;
55
+ function isDigest(value) { return typeof value === 'string' && COURSE_DIGEST.test(value); }
51
56
  function point(value) { return { id: value.id, name: value.name, latitude: value.latitude, longitude: value.longitude, ...(value.notes !== undefined ? { notes: value.notes } : {}) }; }
52
57
  function validText(value, maximum) { return typeof value === 'string' && value.trim().length > 0 && value.length <= maximum; }
53
58
  function validPoint(value) {
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/courses/protocol.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"protocol.js","sourceRoot":"","sources":["../../src/courses/protocol.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -7,6 +7,13 @@ const node_path_1 = __importDefault(require("node:path"));
7
7
  const course_store_1 = require("./courses/course-store");
8
8
  const protocol_1 = require("./courses/protocol");
9
9
  const native_course_1 = require("./courses/native-course");
10
+ const progression_service_1 = require("./race/progression-service");
11
+ const progression_store_1 = require("./race/progression-store");
12
+ const observations_1 = require("./race/observations");
13
+ const race_pack_store_1 = require("./race/race-pack-store");
14
+ const race_pack_protocol_1 = require("./race/race-pack-protocol");
15
+ const onboard_store_1 = require("./race/onboard-store");
16
+ const onboard_service_1 = require("./race/onboard-service");
10
17
  const history_1 = require("./tracking/history");
11
18
  const schema_1 = require("./config/schema");
12
19
  const defaults_1 = require("./config/defaults");
@@ -32,6 +39,13 @@ const constructor = (app) => {
32
39
  let queueStatsAt;
33
40
  let transport;
34
41
  let courses;
42
+ let progression;
43
+ let racePacks;
44
+ let racePackReceiver;
45
+ let observations;
46
+ let onboardSnapshots;
47
+ let onboard;
48
+ let onboardCourse = null;
35
49
  let courseInitializationError;
36
50
  let courseFailureAcknowledgement;
37
51
  let tripState;
@@ -65,7 +79,7 @@ const constructor = (app) => {
65
79
  pairingAbortController?.abort();
66
80
  associationAbortController?.abort();
67
81
  // Stop all transmission immediately when a saved configuration restarts us.
68
- const stopping = transport?.stop(false);
82
+ const stopping = transport?.stop({ publishOffline: false, force: true });
69
83
  const previous = initialization;
70
84
  const thisGeneration = ++generation;
71
85
  initialization = (async () => {
@@ -142,10 +156,11 @@ const constructor = (app) => {
142
156
  await persistTrackingMode(mode, revision, thisGeneration);
143
157
  if (revision === trackingRevision && generation === thisGeneration) {
144
158
  activeUploadMode = mode;
159
+ await onboard?.setAuthority(mode);
145
160
  if (mode === 'automatic' && !wasAutomatic) {
146
161
  if (outbox)
147
162
  await updateHistory(await outbox.stats(), true);
148
- await transport?.stop(false);
163
+ await transport?.stop({ publishOffline: false, force: true });
149
164
  connectTransport?.();
150
165
  }
151
166
  await updateStatus();
@@ -181,6 +196,7 @@ const constructor = (app) => {
181
196
  if (!courses)
182
197
  throw new protocol_1.CourseError('no_selected_course');
183
198
  await courses.activate();
199
+ await syncProgressionCourse();
184
200
  void transport?.publishCourseAcknowledgement().catch((error) => app.error(`Course acknowledgement deferred: ${safeError(error)}`));
185
201
  response.status(200).json({ ...await courses.status(), uploadMode: activeUploadMode, connectionState });
186
202
  }
@@ -207,6 +223,87 @@ const constructor = (app) => {
207
223
  next(error);
208
224
  }
209
225
  });
226
+ readRouter.get?.('/progression', async (_request, response, next) => {
227
+ try {
228
+ response.status(200).json(progression?.status() ?? { mode: null, revision: 0, activeIndex: 0, pending: null, lastDetection: null });
229
+ }
230
+ catch (error) {
231
+ next(error);
232
+ }
233
+ });
234
+ writeRouter.post('/progression/mode', async (request, response, next) => {
235
+ const mode = request.body?.mode;
236
+ if (mode !== 'auto' && mode !== 'suggest' && mode !== 'off') {
237
+ response.status(400).json({ error: 'invalid_progression_mode' });
238
+ return;
239
+ }
240
+ if (!progression) {
241
+ response.status(409).json({ error: 'progression_unavailable' });
242
+ return;
243
+ }
244
+ try {
245
+ await progression.setMode(mode);
246
+ response.status(200).json(progression.status());
247
+ }
248
+ catch (error) {
249
+ next(error);
250
+ }
251
+ });
252
+ writeRouter.post('/progression/resolve', async (request, response, next) => {
253
+ const body = request.body;
254
+ if ((body?.resolution !== 'accepted' && body?.resolution !== 'dismissed') || !Number.isSafeInteger(body?.pointIndex)) {
255
+ response.status(400).json({ error: 'invalid_resolution' });
256
+ return;
257
+ }
258
+ if (!progression) {
259
+ response.status(409).json({ error: 'progression_unavailable' });
260
+ return;
261
+ }
262
+ try {
263
+ const resolved = await progression.resolve(body.resolution, body.pointIndex);
264
+ if (resolved)
265
+ response.status(200).json(progression.status());
266
+ else
267
+ response.status(409).json({ error: 'no_pending_detection', ...progression.status() });
268
+ }
269
+ catch (error) {
270
+ next(error);
271
+ }
272
+ });
273
+ readRouter.get?.('/race-plan', async (_request, response, next) => {
274
+ try {
275
+ const status = onboard?.status();
276
+ response.status(200).json({
277
+ ...(status ?? { calculationAuthority: activeUploadMode === 'local_only' ? 'onboard' : 'cloud', lastLocalCalculationAt: null, reason: 'onboard_unavailable' }),
278
+ uploadMode: activeUploadMode,
279
+ connectionState,
280
+ latestSnapshot: onboardSnapshots?.latest() ?? null
281
+ });
282
+ }
283
+ catch (error) {
284
+ next(error);
285
+ }
286
+ });
287
+ writeRouter.post('/race-plan/recalculate', async (_request, response, next) => {
288
+ try {
289
+ if (!onboard) {
290
+ response.status(409).json({ error: 'onboard_unavailable' });
291
+ return;
292
+ }
293
+ // Explicit recalculation respects the product authority rule: while
294
+ // Live tracking is automatic the cloud is authoritative and an
295
+ // onboard recalculation must not run, even if MQTT is offline.
296
+ if (activeUploadMode !== 'local_only') {
297
+ response.status(409).json({ error: 'cloud_authority', uploadMode: activeUploadMode, ...onboard.status() });
298
+ return;
299
+ }
300
+ const snapshot = await onboard.refresh(true);
301
+ response.status(200).json({ calculated: !!snapshot, snapshot, status: onboard.status() });
302
+ }
303
+ catch (error) {
304
+ next(error);
305
+ }
306
+ });
210
307
  // Registered directly: unpairing keeps Signal K's admin-only default.
211
308
  pluginRouter.post('/forget-credentials', async (_request, response, next) => {
212
309
  try {
@@ -242,6 +339,11 @@ const constructor = (app) => {
242
339
  '/course': { get: { summary: 'Read cached and native course state', responses: { '200': { description: 'Course state' } } } },
243
340
  '/course/map-readiness': { post: { summary: 'Report locally verified chart readiness for a course revision', responses: { '200': { description: 'Readiness saved' }, '409': { description: 'Invalid or stale revision' } } } },
244
341
  '/course/activate': { post: { summary: 'Explicitly activate the selected cached course', responses: { '200': { description: 'Course active' }, '409': { description: 'No selected course or native course unavailable' } } } },
342
+ '/progression': { get: { summary: 'Read mark detection mode, active point and any pending detection', responses: { '200': { description: 'Progression status' } } } },
343
+ '/progression/mode': { post: { summary: 'Change mark detection between automatic, suggest and off', responses: { '200': { description: 'Mode saved' }, '400': { description: 'Invalid mode' }, '409': { description: 'Progression unavailable' } } } },
344
+ '/progression/resolve': { post: { summary: 'Resolve a pending detection as accepted or dismissed for its point index', responses: { '200': { description: 'Resolution recorded' }, '400': { description: 'Invalid resolution' }, '409': { description: 'No matching pending detection' } } } },
345
+ '/race-plan': { get: { summary: 'Read onboard Race Plan calculation authority, Race Pack state and the latest onboard snapshot', responses: { '200': { description: 'Onboard race plan state' } } } },
346
+ '/race-plan/recalculate': { post: { summary: 'Request an explicit onboard Race Plan recalculation (local-only authority only)', responses: { '200': { description: 'Recalculation attempted' }, '409': { description: 'Onboard planner unavailable or cloud is authoritative' } } } },
245
347
  '/forget-credentials': {
246
348
  post: {
247
349
  summary: 'Forget Wake Logger credentials while preserving all device outboxes',
@@ -251,6 +353,90 @@ const constructor = (app) => {
251
353
  }
252
354
  })
253
355
  };
356
+ async function syncProgressionCourse() {
357
+ if (!courses) {
358
+ progression?.updateCourse(null);
359
+ onboardCourse = null;
360
+ return;
361
+ }
362
+ try {
363
+ const status = await courses.status();
364
+ progression?.updateCourse({
365
+ revision: status.cachedCourse?.revision ?? 0,
366
+ points: status.routePoints ?? [],
367
+ reverse: status.native?.course?.activeRoute?.reverse === true,
368
+ activeIndex: status.native?.course?.activeRoute?.pointIndex ?? 0,
369
+ matches: status.native?.activeMatchesDesired === true
370
+ });
371
+ const points = status.routePoints ?? [];
372
+ onboardCourse = status.native?.activeMatchesDesired === true && status.cachedCourse?.courseId && points.length >= 2
373
+ ? {
374
+ courseId: status.cachedCourse.courseId,
375
+ racePlanId: status.cachedCourse.racePlanId ?? null,
376
+ courseDefinitionDigest: status.cachedCourse.courseDefinitionDigest ?? null,
377
+ activeIndex: status.native?.course?.activeRoute?.pointIndex ?? 0,
378
+ totalPoints: points.length,
379
+ reverse: status.native?.course?.activeRoute?.reverse === true
380
+ }
381
+ : null;
382
+ }
383
+ catch {
384
+ progression?.updateCourse(null);
385
+ onboardCourse = null;
386
+ }
387
+ }
388
+ function racingUnderway() {
389
+ if (onboardCourse && onboardCourse.activeIndex > 0 && onboardCourse.activeIndex < onboardCourse.totalPoints)
390
+ return true;
391
+ return tripState?.currentState().state === 'MOVING';
392
+ }
393
+ async function flushProgressionEvidence() {
394
+ if (!progression || !transport)
395
+ return;
396
+ const pending = progression.pendingEvents();
397
+ if (!pending.length)
398
+ return;
399
+ const published = [];
400
+ for (const event of pending) {
401
+ try {
402
+ if (!await transport.publishEvidence(event))
403
+ break;
404
+ }
405
+ catch {
406
+ break;
407
+ }
408
+ published.push(event.sequence);
409
+ }
410
+ if (published.length)
411
+ await progression.markPublished(published);
412
+ }
413
+ // Onboard snapshots are historical evidence: they only leave the vessel once
414
+ // Live tracking is restored. A snapshot stays durable until Wake Logger
415
+ // application-ACKs it; published-but-unacknowledged snapshots are retried.
416
+ async function flushOnboardSnapshots() {
417
+ if (!onboardSnapshots || !transport || activeUploadMode !== 'automatic')
418
+ return;
419
+ const queue = [...onboardSnapshots.pending(), ...onboardSnapshots.unacknowledged()].slice(0, 5);
420
+ if (!queue.length)
421
+ return;
422
+ const published = [];
423
+ for (const event of queue) {
424
+ try {
425
+ if (!await transport.publishRacePlanSnapshot(event.snapshot))
426
+ break;
427
+ }
428
+ catch {
429
+ break;
430
+ }
431
+ published.push(event.sequence);
432
+ }
433
+ if (published.length)
434
+ await onboardSnapshots.markPublished(published);
435
+ }
436
+ function publishOnboardSnapshot(_snapshot) {
437
+ if (activeUploadMode === 'automatic')
438
+ void flushOnboardSnapshots();
439
+ }
254
440
  async function cleanupResources() {
255
441
  stopSubscription?.();
256
442
  stopSubscription = undefined;
@@ -267,6 +453,17 @@ const constructor = (app) => {
267
453
  currentSampler = undefined;
268
454
  connectTransport = undefined;
269
455
  await sampleOperation;
456
+ await progression?.close();
457
+ progression = undefined;
458
+ onboard?.close();
459
+ onboard = undefined;
460
+ await onboardSnapshots?.close();
461
+ onboardSnapshots = undefined;
462
+ await racePacks?.close();
463
+ racePacks = undefined;
464
+ racePackReceiver = undefined;
465
+ observations = undefined;
466
+ onboardCourse = null;
270
467
  await courses?.close();
271
468
  courses = undefined;
272
469
  courseInitializationError = undefined;
@@ -362,6 +559,43 @@ const constructor = (app) => {
362
559
  courseInitializationError = safeError(error);
363
560
  app.error(`Wake Logger course cache unavailable; telemetry recording continues: ${courseInitializationError}`);
364
561
  }
562
+ const progressionStore = new progression_store_1.RaceProgressionStore(node_path_1.default.join(dataDirectory, 'race-progression', credentials.deviceId, 'state.json'));
563
+ try {
564
+ await progressionStore.open();
565
+ progression = new progression_service_1.RaceProgressionService({ store: progressionStore, defaultMode: config.raceProgressionMode });
566
+ }
567
+ catch (error) {
568
+ progression = undefined;
569
+ app.error(`Wake Logger mark detection unavailable: ${safeError(error)}`);
570
+ }
571
+ await syncProgressionCourse();
572
+ observations = new observations_1.ObservationCollector();
573
+ const packs = new race_pack_store_1.RacePackStore(node_path_1.default.join(dataDirectory, 'race-packs', credentials.deviceId));
574
+ try {
575
+ await packs.open();
576
+ }
577
+ catch (error) {
578
+ app.error(`Wake Logger race pack store unavailable: ${safeError(error)}`);
579
+ }
580
+ racePacks = packs;
581
+ racePackReceiver = new race_pack_protocol_1.RacePackReceiver({ store: packs });
582
+ const snapshotStore = new onboard_store_1.OnboardSnapshotStore(node_path_1.default.join(dataDirectory, 'onboard-plans', credentials.deviceId, 'state.json'));
583
+ try {
584
+ await snapshotStore.open();
585
+ }
586
+ catch (error) {
587
+ app.error(`Wake Logger onboard plan history unavailable: ${safeError(error)}`);
588
+ }
589
+ onboardSnapshots = snapshotStore;
590
+ onboard = new onboard_service_1.OnboardRaceService({
591
+ packs,
592
+ snapshots: snapshotStore,
593
+ observations,
594
+ course: () => onboardCourse,
595
+ racing: racingUnderway,
596
+ trackingSessionId: () => tripState?.currentState().trackingSessionId ?? null,
597
+ onCalculated: publishOnboardSnapshot
598
+ });
365
599
  const normaliser = new normaliser_1.TelemetryNormaliser();
366
600
  const tripFile = node_path_1.default.join(dataDirectory, 'trip-state.json');
367
601
  const trip = new recording_store_1.RecordingStore(node_path_1.default.join(dataDirectory, 'recordings', credentials.deviceId, 'state.json'));
@@ -412,8 +646,11 @@ const constructor = (app) => {
412
646
  void confirmAssociation(credentials, thisGeneration);
413
647
  },
414
648
  onCourse: async (payload) => {
415
- if (courses)
416
- return courses.receive(payload);
649
+ if (courses) {
650
+ const acknowledgement = await courses.receive(payload);
651
+ await syncProgressionCourse();
652
+ return acknowledgement;
653
+ }
417
654
  let revision = 0;
418
655
  try {
419
656
  revision = (0, protocol_1.parseCourse)(payload).revision;
@@ -423,10 +660,16 @@ const constructor = (app) => {
423
660
  return courseFailureAcknowledgement;
424
661
  },
425
662
  getCourseAcknowledgement: () => courses?.acknowledgement() ?? courseFailureAcknowledgement,
663
+ onRacePackManifest: async (payload) => racePackReceiver?.acceptManifest(payload) ?? null,
664
+ onRacePackChunk: async (payload, topicIndex) => racePackReceiver?.acceptChunk(payload, topicIndex) ?? null,
665
+ getRacePackAck: () => racePackReceiver?.currentAck() ?? null,
426
666
  onRecordingAcks: async (acks) => {
427
667
  sampleOperation = sampleOperation.then(() => trip.acknowledge(acks));
428
668
  await sampleOperation;
429
669
  },
670
+ onRacePlanSnapshotAcks: async (ids) => {
671
+ await onboardSnapshots?.acknowledge(ids);
672
+ },
430
673
  onMode: (mode) => { if (transport === instance)
431
674
  sampler.updateMode(activeUploadMode === 'local_only' ? 'NORMAL' : mode); },
432
675
  onProfile: async (replacement) => {
@@ -443,7 +686,15 @@ const constructor = (app) => {
443
686
  if (activeUploadMode === 'local_only') {
444
687
  transport = new mqtt_client_1.WakeLoggerTransport(credentials, selected.store, { profile, onState: () => undefined });
445
688
  }
446
- stopSubscription = (0, subscriber_1.subscribeToTelemetry)(app, (delta) => normaliser.ingest(delta));
689
+ stopSubscription = (0, subscriber_1.subscribeToTelemetry)(app, (delta) => {
690
+ normaliser.ingest(delta);
691
+ observations?.ingest(delta);
692
+ if (!progression)
693
+ return;
694
+ const fix = normaliser.latestFix();
695
+ if (fix)
696
+ progression.fix({ at: Date.now(), ...fix });
697
+ });
447
698
  const sample = async () => {
448
699
  if (generation !== thisGeneration)
449
700
  return;
@@ -475,11 +726,12 @@ const constructor = (app) => {
475
726
  }, sampler.samplePeriodMs());
476
727
  };
477
728
  scheduleSample();
478
- statusTimer = setInterval(() => void updateStatus(), 10_000);
729
+ statusTimer = setInterval(() => { void syncProgressionCourse(); void onboard?.refresh(false); void updateStatus(); }, 10_000);
479
730
  if (activeUploadMode === 'automatic')
480
731
  connectTransport();
481
732
  else
482
733
  connectionState = 'recording_locally';
734
+ await onboard?.setAuthority(activeUploadMode);
483
735
  await updateStatus();
484
736
  }
485
737
  async function updateStatus(detail, beginHistory = false) {
@@ -514,6 +766,8 @@ const constructor = (app) => {
514
766
  profileRevision: activeProfile?.revision,
515
767
  ...transport?.transportMetrics()
516
768
  });
769
+ void flushProgressionEvidence();
770
+ void flushOnboardSnapshots();
517
771
  if (connectionState === 'device_revoked') {
518
772
  app.setPluginStatus(`Wake Logger: Device revoked — enter a new pairing code; ${queue}${dropped}${sequence}`);
519
773
  return;
@@ -550,9 +804,17 @@ const constructor = (app) => {
550
804
  activeUploadMode = 'local_only';
551
805
  pairingAbortController?.abort();
552
806
  associationAbortController?.abort();
553
- const stopping = transport?.stop(false);
807
+ // Send one bounded retained final status before shutting transmission down.
808
+ // Its failure cannot prevent the fail-closed local-only transition.
809
+ const stopping = (async () => {
810
+ await transport?.publishFinalLocalOnlyStatus().catch(() => undefined);
811
+ // Graceful end: a DISCONNECT suppresses the retained Last Will so it cannot
812
+ // overwrite the retained local_only status after the plugin is gone.
813
+ await transport?.stop({ publishOffline: false, force: false });
814
+ })();
554
815
  currentSampler?.updateMode('NORMAL');
555
816
  connectionState = 'recording_locally';
817
+ void onboard?.setAuthority('local_only');
556
818
  return stopping;
557
819
  }
558
820
  async function persistTrackingMode(mode, revision, thisGeneration) {