speedandcurrent 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -518,6 +518,9 @@ module.exports = function (app) {
518
518
  if (table && !isTableEmpty(table)) {
519
519
  saveTableSync(table, path.join(app.getDataDirPath(), table.id + '.json'));
520
520
  }
521
+ // Clear all active output paths from the SK bus before teardown.
522
+ if (smoothedCurrent) PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
523
+ if (options.estimateBoatSpeed && correctedBoatSpeed) Polar.clear(app, plugin.id, [correctedBoatSpeed]);
521
524
  smoothedHeading = smoothedHeading?.terminate();
522
525
  smoothedAttitude = smoothedAttitude?.terminate();
523
526
  rawCurrent = rawCurrent?.terminate();
@@ -750,6 +753,10 @@ module.exports = function (app) {
750
753
  // All other keys (sogFallback, estimateBoatSpeed, assumeCurrent,
751
754
  // stability, smootherClass, etc.) are read
752
755
  // directly from options.* so no extra action needed.
756
+ if (key === 'estimateBoatSpeed' && !value && correctedBoatSpeed) {
757
+ Polar.clear(app, plugin.id, [correctedBoatSpeed]);
758
+ PolarSmoother.clear(app, plugin.id, [smoothedCurrent]);
759
+ }
753
760
 
754
761
  delete changedOptions[key];
755
762
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "speedandcurrent",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "A plugin that automatically corrects boat speed and calculates current and leeway using sensor fusion.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "homepage": "https://github.com/Asw1n/speedandcurrent#readme",
19
19
  "engines": {
20
- "node": ">=22.0.0"
20
+ "node": ">=22.0.0",
21
+ "signalk-server": ">=2.28.0"
21
22
  },
22
23
  "keywords": [
23
24
  "signalk-webapp",
@@ -37,13 +38,12 @@
37
38
  },
38
39
  "dependencies": {
39
40
  "kalman-filter": "^2.3.0",
40
- "signalkutilities": "^2.0.0"
41
+ "signalkutilities": "^2.1.0"
41
42
  },
42
43
  "files": [
43
44
  "index.js",
44
45
  "correctionTable.js",
45
46
  "public/",
46
- "docs/",
47
- "test"
47
+ "docs/"
48
48
  ]
49
49
  }
package/public/index.html CHANGED
@@ -54,7 +54,7 @@
54
54
  <img src="icon.png" alt="" style="height:48px;width:auto">
55
55
  <span class="app-title">Speed &amp; Current</span>
56
56
  </a>
57
- <button class="navbar-toggler sidebar-toggler d-md-down-none ms-2" type="button" id="sidebarToggler">
57
+ <button class="navbar-toggler sidebar-toggler ms-2" type="button" id="sidebarToggler">
58
58
  <span class="navbar-toggler-icon"></span>
59
59
  </button>
60
60
  </div>
@@ -214,8 +214,13 @@
214
214
  document.body.classList.toggle('sidebar-minimized');
215
215
  document.body.classList.toggle('brand-minimized');
216
216
  });
217
+ const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
217
218
  document.getElementById('sidebarToggler').addEventListener('click', () => {
218
- document.body.classList.toggle('sidebar-hidden');
219
+ if (isMobile()) {
220
+ document.body.classList.toggle('sidebar-mobile-show');
221
+ } else {
222
+ document.body.classList.toggle('sidebar-hidden');
223
+ }
219
224
  });
220
225
  document.querySelectorAll('#main-nav .nav-link').forEach(link => {
221
226
  link.addEventListener('click', e => {
@@ -225,6 +230,7 @@
225
230
  const target = link.dataset.section;
226
231
  document.querySelectorAll('.app-section').forEach(s => s.style.display = 'none');
227
232
  document.getElementById('section-' + target).style.display = '';
233
+ if (isMobile()) document.body.classList.remove('sidebar-mobile-show');
228
234
  });
229
235
  });
230
236
  </script>
@@ -1,414 +0,0 @@
1
- 'use strict';
2
-
3
- const { describe, it, before, after } = require('node:test');
4
- const assert = require('node:assert/strict');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const path = require('path');
8
-
9
- // ---------------------------------------------------------------------------
10
- // App shim helpers
11
- // ---------------------------------------------------------------------------
12
-
13
- /**
14
- * Minimal BaconJS-style reactive-stream bus mock.
15
- * Every chaining method returns `this`; onValue returns an unsubscribe no-op.
16
- * This satisfies the internal stream API used by signalkutilities.
17
- */
18
- function createMockBus() {
19
- const bus = {};
20
- const chainMethods = [
21
- 'onError', 'onEnd', 'skipDuplicates', 'map', 'filter', 'take', 'first',
22
- 'toPromise', 'flatMap', 'flatMapLatest', 'merge', 'debounce',
23
- 'debounceImmediate', 'throttle', 'delay', 'bufferWithTime', 'bufferWithCount',
24
- 'combine', 'sampledBy', 'scan', 'fold', 'zip', 'awaiting', 'not', 'log',
25
- 'doAction', 'doLog', 'doError', 'doEnd', 'withHandler', 'name',
26
- 'withDescription', 'skip', 'slidingWindow', 'startWith', 'mapEnd',
27
- 'skipWhile', 'takeWhile', 'takeUntil', 'errors', 'mapError', 'subscribe',
28
- ];
29
- for (const m of chainMethods) bus[m] = () => bus;
30
- bus.onValue = (_cb) => () => {};
31
- bus.push = () => {};
32
- bus.plug = () => () => {};
33
- bus.end = () => {};
34
- return bus;
35
- }
36
-
37
- /**
38
- * Create a minimal SignalK app shim that satisfies the speedandcurrent plugin.
39
- * A Proxy is used so that any method not explicitly stubbed returns a no-op
40
- * instead of throwing a TypeError.
41
- *
42
- * @returns {{ app: object, cleanup: () => void }}
43
- */
44
- function createAppShim() {
45
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'speedandcurrent-test-'));
46
- const dataDir = path.join(tmpDir, 'data');
47
- fs.mkdirSync(dataDir, { recursive: true });
48
- const configFile = path.join(tmpDir, 'SpeedAndCurrent.json');
49
-
50
- const base = {
51
- // Logging
52
- debug: () => {},
53
- error: () => {},
54
-
55
- // Plugin status
56
- setPluginStatus: () => {},
57
- setPluginError: () => {},
58
-
59
- // Delta output
60
- handleMessage: () => {},
61
-
62
- // Data access
63
- getSelfPath: () => undefined,
64
- getPath: () => undefined,
65
- getMetadata: () => undefined,
66
- putSelfPath: (_p, _v, cb) => { if (cb) cb({ state: 'COMPLETED' }); },
67
- putPath: (_p, _v, cb) => { if (cb) cb({ state: 'COMPLETED' }); },
68
-
69
- // Plugin config persistence
70
- readPluginOptions: () => {
71
- try { return JSON.parse(fs.readFileSync(configFile, 'utf-8')); } catch { return {}; }
72
- },
73
- savePluginOptions: (config, cb) => {
74
- fs.writeFileSync(configFile, JSON.stringify(config));
75
- if (cb) cb();
76
- },
77
- getPluginOptions: () => ({}),
78
- getDataDirPath: () => dataDir,
79
-
80
- // Subscription infrastructure
81
- registerDeltaInputHandler: () => () => {},
82
- registerPutHandler: () => () => {},
83
-
84
- streambundle: {
85
- getSelfBus: () => createMockBus(),
86
- getBus: () => createMockBus(),
87
- getSelfStream: () => createMockBus(),
88
- getAvailablePaths: () => [],
89
- },
90
-
91
- subscriptionmanager: {
92
- subscribe: (_msg, unsubscribes, _errorCb, _deltaCb) => {
93
- const unsub = () => {};
94
- if (Array.isArray(unsubscribes)) unsubscribes.push(unsub);
95
- },
96
- },
97
-
98
- // Event emitter API
99
- on: () => {},
100
- once: () => {},
101
- emit: () => {},
102
- removeListener: () => {},
103
- removeAllListeners: () => {},
104
-
105
- // Server identity
106
- selfId: 'urn:mrn:signalk:uuid:00000000-0000-0000-0000-000000000000',
107
- selfType: 'vessels',
108
- selfContext: 'vessels.urn:mrn:signalk:uuid:00000000-0000-0000-0000-000000000000',
109
-
110
- config: {
111
- configPath: tmpDir,
112
- appPath: tmpDir,
113
- version: '2.24.0',
114
- name: 'signalk-server',
115
- basePath: '/signalk/v1',
116
- defaults: {},
117
- },
118
-
119
- reportOutputMessages: () => {},
120
-
121
- wrappedEmitter: {
122
- bindMethodsById: () => ({ on: () => {}, removeListener: () => {} }),
123
- },
124
- };
125
-
126
- // Proxy: any property not found in base returns a no-op function so unstubbed
127
- // accesses from signalkutilities don't throw.
128
- const app = new Proxy(base, {
129
- get(target, prop) {
130
- if (prop in target) return target[prop];
131
- if (typeof prop === 'symbol') return undefined;
132
- return () => {};
133
- },
134
- });
135
-
136
- const cleanup = () => {
137
- try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
138
- };
139
-
140
- return { app, cleanup };
141
- }
142
-
143
- // ---------------------------------------------------------------------------
144
- // Tests
145
- // ---------------------------------------------------------------------------
146
-
147
- describe('module export', () => {
148
- it('exports a factory function', () => {
149
- const factory = require('../index.js');
150
- assert.strictEqual(typeof factory, 'function', 'module.exports must be a function');
151
- });
152
- });
153
-
154
- describe('plugin object shape', () => {
155
- let plugin;
156
- let cleanup;
157
-
158
- before(() => {
159
- const shim = createAppShim();
160
- cleanup = shim.cleanup;
161
- plugin = require('../index.js')(shim.app);
162
- });
163
-
164
- after(() => cleanup());
165
-
166
- it('has a non-empty string id', () => {
167
- assert.strictEqual(typeof plugin.id, 'string');
168
- assert.ok(plugin.id.length > 0, 'plugin.id must not be empty');
169
- });
170
-
171
- it('has a non-empty string name', () => {
172
- assert.strictEqual(typeof plugin.name, 'string');
173
- assert.ok(plugin.name.length > 0, 'plugin.name must not be empty');
174
- });
175
-
176
- it('has a string description', () => {
177
- assert.strictEqual(typeof plugin.description, 'string');
178
- });
179
-
180
- it('exposes a valid JSON Schema object', () => {
181
- assert.strictEqual(typeof plugin.schema, 'object', 'plugin.schema must be an object');
182
- assert.ok(plugin.schema !== null);
183
- assert.strictEqual(plugin.schema.type, 'object', 'schema.type must be "object"');
184
- assert.strictEqual(typeof plugin.schema.properties, 'object', 'schema.properties must be an object');
185
- });
186
-
187
- it('has start and stop functions', () => {
188
- assert.strictEqual(typeof plugin.start, 'function', 'plugin.start must be a function');
189
- assert.strictEqual(typeof plugin.stop, 'function', 'plugin.stop must be a function');
190
- });
191
-
192
- it('has a registerWithRouter function', () => {
193
- assert.strictEqual(typeof plugin.registerWithRouter, 'function', 'plugin.registerWithRouter must be a function');
194
- });
195
- });
196
-
197
- describe('registerWithRouter', () => {
198
- it('registers the expected routes', () => {
199
- const { app, cleanup } = createAppShim();
200
- try {
201
- const plugin = require('../index.js')(app);
202
- const registered = [];
203
- const mockRouter = {
204
- get: (p) => registered.push(`GET ${p}`),
205
- put: (p) => registered.push(`PUT ${p}`),
206
- post: (p) => registered.push(`POST ${p}`),
207
- };
208
- plugin.registerWithRouter(mockRouter);
209
-
210
- assert.ok(registered.includes('GET /api/report'), 'missing GET /api/report');
211
- assert.ok(registered.includes('GET /api/meta'), 'missing GET /api/meta');
212
- assert.ok(registered.includes('GET /api/status'), 'missing GET /api/status');
213
- assert.ok(registered.includes('GET /api/settings'), 'missing GET /api/settings');
214
- assert.ok(registered.includes('PUT /api/settings'), 'missing PUT /api/settings');
215
- assert.ok(registered.includes('GET /api/tables'), 'missing GET /api/tables');
216
- assert.ok(registered.includes('POST /api/tables/create'), 'missing POST /api/tables/create');
217
- assert.ok(registered.includes('POST /api/tables/load'), 'missing POST /api/tables/load');
218
- assert.ok(registered.includes('POST /api/tables/copy'), 'missing POST /api/tables/copy');
219
- assert.ok(registered.includes('POST /api/tables/resize'), 'missing POST /api/tables/resize');
220
- } finally {
221
- cleanup();
222
- }
223
- });
224
-
225
- it('GET /api/settings returns options with expected default keys', () => {
226
- const { app, cleanup } = createAppShim();
227
- try {
228
- const plugin = require('../index.js')(app);
229
- const routes = {};
230
- const mockRouter = {
231
- get: (p, h) => { routes[`GET ${p}`] = h; },
232
- put: (p, h) => { routes[`PUT ${p}`] = h; },
233
- post: (p, h) => { routes[`POST ${p}`] = h; },
234
- };
235
- plugin.registerWithRouter(mockRouter);
236
-
237
- let response = null;
238
- routes['GET /api/settings']({}, { json: (d) => { response = d; } });
239
-
240
- assert.ok(response !== null, 'GET /api/settings returned no response');
241
- assert.ok('estimateBoatSpeed' in response, 'missing estimateBoatSpeed');
242
- assert.ok('updateCorrectionTable' in response, 'missing updateCorrectionTable');
243
- assert.ok('smootherClass' in response, 'missing smootherClass');
244
- assert.ok('stability' in response, 'missing stability');
245
- } finally {
246
- cleanup();
247
- }
248
- });
249
-
250
- it('PUT /api/settings rejects blocked key "tableName" with 400', () => {
251
- const { app, cleanup } = createAppShim();
252
- try {
253
- const plugin = require('../index.js')(app);
254
- const routes = {};
255
- const mockRouter = {
256
- get: (p, h) => { routes[`GET ${p}`] = h; },
257
- put: (p, h) => { routes[`PUT ${p}`] = h; },
258
- post: (p, h) => { routes[`POST ${p}`] = h; },
259
- };
260
- plugin.registerWithRouter(mockRouter);
261
-
262
- let statusCode = null;
263
- let response = null;
264
- const res = {
265
- status: (code) => { statusCode = code; return res; },
266
- json: (d) => { response = d; },
267
- };
268
- routes['PUT /api/settings']({ body: { tableName: 'hack' } }, res);
269
-
270
- assert.strictEqual(statusCode, 400, 'should respond 400 for blocked key "tableName"');
271
- assert.ok(response && typeof response.error === 'string', 'should return an error message');
272
- } finally {
273
- cleanup();
274
- }
275
- });
276
-
277
- it('PUT /api/settings rejects blocked key "correctionTable" with 400', () => {
278
- const { app, cleanup } = createAppShim();
279
- try {
280
- const plugin = require('../index.js')(app);
281
- const routes = {};
282
- const mockRouter = {
283
- get: (p, h) => { routes[`GET ${p}`] = h; },
284
- put: (p, h) => { routes[`PUT ${p}`] = h; },
285
- post: (p, h) => { routes[`POST ${p}`] = h; },
286
- };
287
- plugin.registerWithRouter(mockRouter);
288
-
289
- let statusCode = null;
290
- let response = null;
291
- const res = {
292
- status: (code) => { statusCode = code; return res; },
293
- json: (d) => { response = d; },
294
- };
295
- routes['PUT /api/settings']({ body: { correctionTable: {} } }, res);
296
-
297
- assert.strictEqual(statusCode, 400, 'should respond 400 for blocked key "correctionTable"');
298
- assert.ok(response && typeof response.error === 'string', 'should return an error message');
299
- } finally {
300
- cleanup();
301
- }
302
- });
303
-
304
- it('PUT /api/settings accepts valid settings and reflects them back', () => {
305
- const { app, cleanup } = createAppShim();
306
- try {
307
- const plugin = require('../index.js')(app);
308
- const routes = {};
309
- const mockRouter = {
310
- get: (p, h) => { routes[`GET ${p}`] = h; },
311
- put: (p, h) => { routes[`PUT ${p}`] = h; },
312
- post: (p, h) => { routes[`POST ${p}`] = h; },
313
- };
314
- plugin.registerWithRouter(mockRouter);
315
-
316
- let response = null;
317
- const res = { json: (d) => { response = d; } };
318
- routes['PUT /api/settings']({ body: { estimateBoatSpeed: true } }, res);
319
-
320
- assert.ok(response !== null, 'PUT /api/settings returned no response');
321
- assert.strictEqual(response.estimateBoatSpeed, true, 'response should reflect the updated value');
322
- } finally {
323
- cleanup();
324
- }
325
- });
326
-
327
- it('GET /api/status returns isRunning and status fields', () => {
328
- const { app, cleanup } = createAppShim();
329
- try {
330
- const plugin = require('../index.js')(app);
331
- const routes = {};
332
- const mockRouter = {
333
- get: (p, h) => { routes[`GET ${p}`] = h; },
334
- put: (p, h) => { routes[`PUT ${p}`] = h; },
335
- post: (p, h) => { routes[`POST ${p}`] = h; },
336
- };
337
- plugin.registerWithRouter(mockRouter);
338
-
339
- let response = null;
340
- routes['GET /api/status']({}, { json: (d) => { response = d; } });
341
-
342
- assert.ok(response !== null, 'GET /api/status returned no response');
343
- assert.ok('isRunning' in response, 'missing isRunning field');
344
- assert.ok('status' in response, 'missing status field');
345
- assert.strictEqual(response.isRunning, false, 'plugin should not be running before start()');
346
- } finally {
347
- cleanup();
348
- }
349
- });
350
- });
351
-
352
- describe('plugin lifecycle', () => {
353
- it('start() completes without throwing', () => {
354
- const { app, cleanup } = createAppShim();
355
- let plugin;
356
- try {
357
- plugin = require('../index.js')(app);
358
- assert.doesNotThrow(() => plugin.start(), 'plugin.start() must not throw');
359
- } finally {
360
- if (plugin) plugin.stop();
361
- cleanup();
362
- }
363
- });
364
-
365
- it('stop() resolves cleanly after start()', async () => {
366
- const { app, cleanup } = createAppShim();
367
- try {
368
- const plugin = require('../index.js')(app);
369
- plugin.start();
370
- await assert.doesNotReject(
371
- () => plugin.stop(),
372
- 'plugin.stop() must resolve without rejection'
373
- );
374
- } finally {
375
- cleanup();
376
- }
377
- });
378
-
379
- it('can be restarted (start → stop → start → stop)', async () => {
380
- const { app, cleanup } = createAppShim();
381
- try {
382
- const plugin = require('../index.js')(app);
383
- plugin.start();
384
- await plugin.stop();
385
- assert.doesNotThrow(() => plugin.start(), 'second start() must not throw');
386
- await assert.doesNotReject(() => plugin.stop(), 'second stop() must resolve');
387
- } finally {
388
- cleanup();
389
- }
390
- });
391
-
392
- it('GET /api/status reports isRunning=true after start()', async () => {
393
- const { app, cleanup } = createAppShim();
394
- try {
395
- const plugin = require('../index.js')(app);
396
- const routes = {};
397
- const mockRouter = {
398
- get: (p, h) => { routes[`GET ${p}`] = h; },
399
- put: (p, h) => { routes[`PUT ${p}`] = h; },
400
- post: (p, h) => { routes[`POST ${p}`] = h; },
401
- };
402
- plugin.registerWithRouter(mockRouter);
403
- plugin.start();
404
-
405
- let response = null;
406
- routes['GET /api/status']({}, { json: (d) => { response = d; } });
407
- assert.strictEqual(response.isRunning, true, 'plugin should be running after start()');
408
-
409
- await plugin.stop();
410
- } finally {
411
- cleanup();
412
- }
413
- });
414
- });