signalk-compass-calibrator 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +78 -0
- package/index.js +1111 -0
- package/lib/angles.js +70 -0
- package/lib/calibration.js +360 -0
- package/lib/plugin-schema.js +14 -0
- package/lib/profile-store.js +150 -0
- package/lib/prometheus-history-provider.js +310 -0
- package/package.json +50 -0
- package/public/app.js +1062 -0
- package/public/icon.svg +12 -0
- package/public/index.html +179 -0
- package/public/remoteEntry.js +54 -0
- package/public/screenshot.png +0 -0
- package/public/styles.css +527 -0
package/lib/angles.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const TAU = Math.PI * 2
|
|
4
|
+
|
|
5
|
+
function wrap360Rad (value) {
|
|
6
|
+
const wrapped = value % TAU
|
|
7
|
+
return wrapped < 0 ? wrapped + TAU : wrapped
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function wrap180Rad (value) {
|
|
11
|
+
const wrapped = (value + Math.PI) % TAU
|
|
12
|
+
return (wrapped < 0 ? wrapped + TAU : wrapped) - Math.PI
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function wrap360Deg (value) {
|
|
16
|
+
const wrapped = value % 360
|
|
17
|
+
return wrapped < 0 ? wrapped + 360 : wrapped
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function wrap180Deg (value) {
|
|
21
|
+
const wrapped = (value + 180) % 360
|
|
22
|
+
return (wrapped < 0 ? wrapped + 360 : wrapped) - 180
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function radToDeg (value) {
|
|
26
|
+
return value * 180 / Math.PI
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function degToRad (value) {
|
|
30
|
+
return value * Math.PI / 180
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function circularMeanDeg (values) {
|
|
34
|
+
if (!values.length) return null
|
|
35
|
+
let sin = 0
|
|
36
|
+
let cos = 0
|
|
37
|
+
for (const value of values) {
|
|
38
|
+
const radians = degToRad(value)
|
|
39
|
+
sin += Math.sin(radians)
|
|
40
|
+
cos += Math.cos(radians)
|
|
41
|
+
}
|
|
42
|
+
if (sin === 0 && cos === 0) return 0
|
|
43
|
+
return wrap180Deg(radToDeg(Math.atan2(sin / values.length, cos / values.length)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function angularStddevDeg (values, meanDeg = circularMeanDeg(values)) {
|
|
47
|
+
if (!values.length || meanDeg === null) return null
|
|
48
|
+
const variance = values.reduce((sum, value) => {
|
|
49
|
+
const diff = wrap180Deg(value - meanDeg)
|
|
50
|
+
return sum + diff * diff
|
|
51
|
+
}, 0) / values.length
|
|
52
|
+
return Math.sqrt(variance)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function interpolateCircularDeg (fromDeg, toDeg, fraction) {
|
|
56
|
+
return wrap180Deg(fromDeg + wrap180Deg(toDeg - fromDeg) * fraction)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
TAU,
|
|
61
|
+
wrap360Rad,
|
|
62
|
+
wrap180Rad,
|
|
63
|
+
wrap360Deg,
|
|
64
|
+
wrap180Deg,
|
|
65
|
+
radToDeg,
|
|
66
|
+
degToRad,
|
|
67
|
+
circularMeanDeg,
|
|
68
|
+
angularStddevDeg,
|
|
69
|
+
interpolateCircularDeg
|
|
70
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
wrap360Rad,
|
|
5
|
+
wrap180Rad,
|
|
6
|
+
wrap180Deg,
|
|
7
|
+
radToDeg,
|
|
8
|
+
degToRad,
|
|
9
|
+
circularMeanDeg,
|
|
10
|
+
angularStddevDeg,
|
|
11
|
+
interpolateCircularDeg
|
|
12
|
+
} = require('./angles')
|
|
13
|
+
|
|
14
|
+
const DEFAULT_FILTERS = {
|
|
15
|
+
minSog: 1.5,
|
|
16
|
+
maxCogRate: 1,
|
|
17
|
+
maxSampleGapSeconds: 2,
|
|
18
|
+
minSamplesPerBin: 10,
|
|
19
|
+
binSize: 10
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeTimestamp (value) {
|
|
23
|
+
if (value instanceof Date) return value.getTime()
|
|
24
|
+
if (typeof value === 'string') return new Date(value).getTime()
|
|
25
|
+
if (typeof value !== 'number') return NaN
|
|
26
|
+
return value < 100000000000 ? value * 1000 : value
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeSeries (series) {
|
|
30
|
+
return (series || [])
|
|
31
|
+
.map(sample => ({
|
|
32
|
+
t: normalizeTimestamp(sample.t ?? sample.ts ?? sample.time ?? sample.timestamp),
|
|
33
|
+
value: Number(sample.value)
|
|
34
|
+
}))
|
|
35
|
+
.filter(sample => Number.isFinite(sample.t) && Number.isFinite(sample.value))
|
|
36
|
+
.sort((a, b) => a.t - b.t)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nearestSample (series, targetTime, maxGapMs) {
|
|
40
|
+
if (!series.length) return null
|
|
41
|
+
let lo = 0
|
|
42
|
+
let hi = series.length - 1
|
|
43
|
+
while (lo < hi) {
|
|
44
|
+
const mid = Math.floor((lo + hi) / 2)
|
|
45
|
+
if (series[mid].t < targetTime) lo = mid + 1
|
|
46
|
+
else hi = mid
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const candidates = [series[lo]]
|
|
50
|
+
if (lo > 0) candidates.push(series[lo - 1])
|
|
51
|
+
let best = null
|
|
52
|
+
for (const candidate of candidates) {
|
|
53
|
+
if (!candidate) continue
|
|
54
|
+
const gap = Math.abs(candidate.t - targetTime)
|
|
55
|
+
if (gap <= maxGapMs && (!best || gap < best.gap)) best = { ...candidate, gap }
|
|
56
|
+
}
|
|
57
|
+
return best
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildAlignedSamples (input, filters = {}) {
|
|
61
|
+
const effective = { ...DEFAULT_FILTERS, ...filters }
|
|
62
|
+
const maxGapMs = effective.maxSampleGapSeconds * 1000
|
|
63
|
+
const heading = normalizeSeries(input.heading)
|
|
64
|
+
const cog = normalizeSeries(input.cog)
|
|
65
|
+
const sog = normalizeSeries(input.sog)
|
|
66
|
+
const variation = normalizeSeries(input.variation)
|
|
67
|
+
const aligned = []
|
|
68
|
+
const rejected = {
|
|
69
|
+
missing: 0,
|
|
70
|
+
slow: 0,
|
|
71
|
+
unstableCog: 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let previousAccepted = null
|
|
75
|
+
let previousSegmentIndex = null
|
|
76
|
+
for (const headingSample of heading) {
|
|
77
|
+
const localFilters = filtersForTime(headingSample.t, effective)
|
|
78
|
+
if (!localFilters) {
|
|
79
|
+
previousAccepted = null
|
|
80
|
+
previousSegmentIndex = null
|
|
81
|
+
rejected.missing += 1
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
if (previousSegmentIndex !== null && localFilters.segmentIndex !== previousSegmentIndex) {
|
|
85
|
+
previousAccepted = null
|
|
86
|
+
}
|
|
87
|
+
const cogSample = nearestSample(cog, headingSample.t, maxGapMs)
|
|
88
|
+
const sogSample = nearestSample(sog, headingSample.t, maxGapMs)
|
|
89
|
+
const variationSample = nearestSample(variation, headingSample.t, maxGapMs)
|
|
90
|
+
if (!cogSample || !sogSample || !variationSample) {
|
|
91
|
+
rejected.missing += 1
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (sogSample.value < localFilters.minSog) {
|
|
95
|
+
rejected.slow += 1
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (previousAccepted) {
|
|
100
|
+
const dt = Math.max((headingSample.t - previousAccepted.t) / 1000, 0.001)
|
|
101
|
+
const cogRateDeg = Math.abs(wrap180Deg(radToDeg(cogSample.value - previousAccepted.cogRad))) / dt
|
|
102
|
+
if (cogRateDeg > localFilters.maxCogRate) {
|
|
103
|
+
rejected.unstableCog += 1
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rawHeadingDeg = radToDeg(wrap360Rad(headingSample.value))
|
|
109
|
+
const headingTrueDeg = rawHeadingDeg + radToDeg(variationSample.value)
|
|
110
|
+
const cogTrueDeg = radToDeg(cogSample.value)
|
|
111
|
+
const errorDeg = wrap180Deg(headingTrueDeg - cogTrueDeg)
|
|
112
|
+
const correctionDeg = wrap180Deg(-errorDeg)
|
|
113
|
+
const sample = {
|
|
114
|
+
t: headingSample.t,
|
|
115
|
+
headingRad: wrap360Rad(headingSample.value),
|
|
116
|
+
headingDeg: rawHeadingDeg,
|
|
117
|
+
cogRad: cogSample.value,
|
|
118
|
+
cogDeg: radToDeg(cogSample.value),
|
|
119
|
+
sog: sogSample.value,
|
|
120
|
+
variationRad: variationSample.value,
|
|
121
|
+
variationDeg: radToDeg(variationSample.value),
|
|
122
|
+
errorDeg,
|
|
123
|
+
correctionDeg
|
|
124
|
+
}
|
|
125
|
+
aligned.push(sample)
|
|
126
|
+
previousAccepted = sample
|
|
127
|
+
previousSegmentIndex = localFilters.segmentIndex
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { samples: aligned, rejected }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function filtersForTime (time, filters) {
|
|
134
|
+
if (!Array.isArray(filters.segments) || filters.segments.length === 0) return filters
|
|
135
|
+
for (let index = 0; index < filters.segments.length; index += 1) {
|
|
136
|
+
const segment = filters.segments[index]
|
|
137
|
+
if (segment.quality === 'rejected') continue
|
|
138
|
+
const from = normalizeTimestamp(segment.from)
|
|
139
|
+
const to = normalizeTimestamp(segment.to)
|
|
140
|
+
if (time >= from && time <= to) {
|
|
141
|
+
return {
|
|
142
|
+
...filters,
|
|
143
|
+
minSog: Number(segment.minSog ?? filters.minSog),
|
|
144
|
+
maxCogRate: Number(segment.maxCogRate ?? filters.maxCogRate),
|
|
145
|
+
segmentIndex: index
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildBins (samples, filters = {}) {
|
|
153
|
+
const effective = { ...DEFAULT_FILTERS, ...filters }
|
|
154
|
+
const binSize = Number(effective.binSize) || DEFAULT_FILTERS.binSize
|
|
155
|
+
const binCount = Math.ceil(360 / binSize)
|
|
156
|
+
const minSamples = Number(effective.minSamplesPerBin) || DEFAULT_FILTERS.minSamplesPerBin
|
|
157
|
+
const bins = Array.from({ length: binCount }, (_, index) => ({
|
|
158
|
+
headingDeg: index * binSize,
|
|
159
|
+
fromDeg: index * binSize,
|
|
160
|
+
toDeg: Math.min((index + 1) * binSize, 360),
|
|
161
|
+
samples: 0,
|
|
162
|
+
quality: 'missing',
|
|
163
|
+
meanErrorDeg: null,
|
|
164
|
+
stddevDeg: null,
|
|
165
|
+
correctionDeg: null,
|
|
166
|
+
interpolated: false,
|
|
167
|
+
_errors: [],
|
|
168
|
+
_corrections: []
|
|
169
|
+
}))
|
|
170
|
+
|
|
171
|
+
for (const sample of samples) {
|
|
172
|
+
const index = Math.min(Math.floor(sample.headingDeg / binSize), binCount - 1)
|
|
173
|
+
bins[index].samples += 1
|
|
174
|
+
bins[index]._errors.push(sample.errorDeg)
|
|
175
|
+
bins[index]._corrections.push(sample.correctionDeg)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const bin of bins) {
|
|
179
|
+
if (bin.samples > 0) {
|
|
180
|
+
bin.meanErrorDeg = circularMeanDeg(bin._errors)
|
|
181
|
+
bin.stddevDeg = angularStddevDeg(bin._errors, bin.meanErrorDeg)
|
|
182
|
+
bin.correctionDeg = circularMeanDeg(bin._corrections)
|
|
183
|
+
bin.quality = bin.samples >= minSamples ? 'good' : 'weak'
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interpolateMissingBins(bins)
|
|
188
|
+
return bins.map(({ _errors, _corrections, ...bin }) => bin)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function interpolateMissingBins (bins) {
|
|
192
|
+
const reliable = bins
|
|
193
|
+
.map((bin, index) => ({ ...bin, index }))
|
|
194
|
+
.filter(bin => bin.quality === 'good' && Number.isFinite(bin.correctionDeg))
|
|
195
|
+
|
|
196
|
+
if (reliable.length < 2) return
|
|
197
|
+
|
|
198
|
+
for (let index = 0; index < bins.length; index += 1) {
|
|
199
|
+
const bin = bins[index]
|
|
200
|
+
if (bin.quality === 'good') continue
|
|
201
|
+
|
|
202
|
+
let previous = null
|
|
203
|
+
let next = null
|
|
204
|
+
for (let offset = 1; offset <= bins.length; offset += 1) {
|
|
205
|
+
const candidate = reliable.find(item => item.index === (index - offset + bins.length) % bins.length)
|
|
206
|
+
if (candidate) {
|
|
207
|
+
previous = candidate
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (let offset = 1; offset <= bins.length; offset += 1) {
|
|
212
|
+
const candidate = reliable.find(item => item.index === (index + offset) % bins.length)
|
|
213
|
+
if (candidate) {
|
|
214
|
+
next = candidate
|
|
215
|
+
break
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!previous || !next || previous.index === next.index) continue
|
|
220
|
+
const distanceToNext = (next.index - previous.index + bins.length) % bins.length
|
|
221
|
+
const distanceFromPrevious = (index - previous.index + bins.length) % bins.length
|
|
222
|
+
const fraction = distanceFromPrevious / distanceToNext
|
|
223
|
+
bin.correctionDeg = interpolateCircularDeg(previous.correctionDeg, next.correctionDeg, fraction)
|
|
224
|
+
bin.interpolated = true
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function buildQuality (samples, bins) {
|
|
229
|
+
const goodBins = bins.filter(bin => bin.quality === 'good')
|
|
230
|
+
const errors = samples.map(sample => sample.errorDeg)
|
|
231
|
+
const meanErrorDeg = circularMeanDeg(errors)
|
|
232
|
+
const stddevDeg = angularStddevDeg(errors, meanErrorDeg)
|
|
233
|
+
const coverageDeg = goodBins.reduce((sum, bin) => sum + (bin.toDeg - bin.fromDeg), 0)
|
|
234
|
+
const interpolatedBinCount = bins.filter(bin => bin.interpolated).length
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
sampleCount: samples.length,
|
|
238
|
+
usableBinCount: goodBins.length,
|
|
239
|
+
coverageDeg,
|
|
240
|
+
meanErrorDeg,
|
|
241
|
+
stddevDeg,
|
|
242
|
+
interpolatedBinCount
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function buildWarnings (profile, filters = {}) {
|
|
247
|
+
const effective = { ...DEFAULT_FILTERS, ...filters }
|
|
248
|
+
const warnings = []
|
|
249
|
+
if (profile.quality.sampleCount < effective.minSamplesPerBin * 4) {
|
|
250
|
+
warnings.push('Too few accepted samples for a robust calibration.')
|
|
251
|
+
}
|
|
252
|
+
if (profile.quality.coverageDeg < 180) {
|
|
253
|
+
warnings.push('Angular coverage is poor; many headings rely on interpolation or have no support.')
|
|
254
|
+
}
|
|
255
|
+
if (profile.quality.usableBinCount < Math.ceil(360 / effective.binSize / 2)) {
|
|
256
|
+
warnings.push('Fewer than half of the heading bins are reliable.')
|
|
257
|
+
}
|
|
258
|
+
if (profile.quality.stddevDeg !== null && profile.quality.stddevDeg > 8) {
|
|
259
|
+
warnings.push('The calibration error has a high standard deviation.')
|
|
260
|
+
}
|
|
261
|
+
if (profile.sources && profile.sources.cog && profile.sources.sog && profile.sources.cog !== profile.sources.sog) {
|
|
262
|
+
warnings.push('COG and SOG sources differ.')
|
|
263
|
+
}
|
|
264
|
+
return warnings
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function calibrate (input, options = {}) {
|
|
268
|
+
const filters = { ...DEFAULT_FILTERS, ...(options.filters || {}), ...(options.calibration || {}) }
|
|
269
|
+
const { samples, rejected } = buildAlignedSamples(input, filters)
|
|
270
|
+
const correctionTable = buildBins(samples, filters)
|
|
271
|
+
const profile = {
|
|
272
|
+
id: options.id || `profile-${new Date().toISOString().replace(/[:.]/g, '-')}`,
|
|
273
|
+
createdAt: new Date().toISOString(),
|
|
274
|
+
state: options.state || 'candidate',
|
|
275
|
+
range: options.range || null,
|
|
276
|
+
sources: options.sources || {},
|
|
277
|
+
filters,
|
|
278
|
+
quality: buildQuality(samples, correctionTable),
|
|
279
|
+
rejected,
|
|
280
|
+
correctionTable
|
|
281
|
+
}
|
|
282
|
+
profile.warnings = buildWarnings(profile, filters)
|
|
283
|
+
return profile
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function correctionForHeadingRad (profile, headingRad) {
|
|
287
|
+
return correctionForCompiledProfile(compileCalibrationProfile(profile), headingRad)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function compileCalibrationProfile (profile) {
|
|
291
|
+
if (!profile || !Array.isArray(profile.correctionTable) || profile.correctionTable.length === 0) return null
|
|
292
|
+
|
|
293
|
+
const bins = []
|
|
294
|
+
for (const bin of profile.correctionTable) {
|
|
295
|
+
if (!Number.isFinite(bin.headingDeg) || !Number.isFinite(bin.correctionDeg) || bin.quality === 'rejected') continue
|
|
296
|
+
bins.push({
|
|
297
|
+
headingRad: wrap360Rad(degToRad(bin.headingDeg)),
|
|
298
|
+
correctionRad: wrap180Rad(degToRad(bin.correctionDeg))
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
bins.sort((a, b) => a.headingRad - b.headingRad)
|
|
302
|
+
if (bins.length === 0) return null
|
|
303
|
+
|
|
304
|
+
const headingsRad = new Float64Array(bins.length)
|
|
305
|
+
const correctionsRad = new Float64Array(bins.length)
|
|
306
|
+
for (let index = 0; index < bins.length; index += 1) {
|
|
307
|
+
headingsRad[index] = bins[index].headingRad
|
|
308
|
+
correctionsRad[index] = bins[index].correctionRad
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
id: profile.id,
|
|
313
|
+
source: profile.sources && profile.sources.heading || null,
|
|
314
|
+
headingsRad,
|
|
315
|
+
correctionsRad
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function correctionForCompiledProfile (compiled, headingRad) {
|
|
320
|
+
if (!compiled || !compiled.headingsRad || !compiled.correctionsRad) return null
|
|
321
|
+
|
|
322
|
+
const headings = compiled.headingsRad
|
|
323
|
+
const corrections = compiled.correctionsRad
|
|
324
|
+
const count = headings.length
|
|
325
|
+
if (count === 0) return null
|
|
326
|
+
if (count === 1) return corrections[0]
|
|
327
|
+
|
|
328
|
+
const heading = wrap360Rad(headingRad)
|
|
329
|
+
let low = 0
|
|
330
|
+
let high = count
|
|
331
|
+
while (low < high) {
|
|
332
|
+
const middle = (low + high) >> 1
|
|
333
|
+
if (headings[middle] < heading) low = middle + 1
|
|
334
|
+
else high = middle
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const wrapsAfterLastBin = low === count
|
|
338
|
+
const upperIndex = wrapsAfterLastBin ? 0 : low
|
|
339
|
+
const lowerIndex = upperIndex === 0 ? count - 1 : upperIndex - 1
|
|
340
|
+
let lowerHeading = headings[lowerIndex]
|
|
341
|
+
let upperHeading = headings[upperIndex]
|
|
342
|
+
let normalizedHeading = heading
|
|
343
|
+
|
|
344
|
+
if (wrapsAfterLastBin) upperHeading += Math.PI * 2
|
|
345
|
+
if (low === 0) lowerHeading -= Math.PI * 2
|
|
346
|
+
if (normalizedHeading < lowerHeading) normalizedHeading += Math.PI * 2
|
|
347
|
+
|
|
348
|
+
const span = upperHeading - lowerHeading
|
|
349
|
+
const fraction = span > 0 ? (normalizedHeading - lowerHeading) / span : 0
|
|
350
|
+
return wrap180Rad(corrections[lowerIndex] + wrap180Rad(corrections[upperIndex] - corrections[lowerIndex]) * fraction)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = {
|
|
354
|
+
DEFAULT_FILTERS,
|
|
355
|
+
calibrate,
|
|
356
|
+
buildAlignedSamples,
|
|
357
|
+
compileCalibrationProfile,
|
|
358
|
+
correctionForCompiledProfile,
|
|
359
|
+
correctionForHeadingRad
|
|
360
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
function buildSchema () {
|
|
4
|
+
return {
|
|
5
|
+
type: 'object',
|
|
6
|
+
title: 'Compass Calibrator',
|
|
7
|
+
description: 'This plugin is configured from the Compass Calibrator web app in the Signal K admin interface. Open Webapps > Compass Calibrator to discover historical sources, run calibration, manage saved tables, and activate or deactivate runtime publishing.',
|
|
8
|
+
properties: {}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
buildSchema
|
|
14
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
class ProfileStore {
|
|
7
|
+
constructor (filePath) {
|
|
8
|
+
this.filePath = filePath
|
|
9
|
+
this.state = {
|
|
10
|
+
profiles: [],
|
|
11
|
+
activeProfileId: null,
|
|
12
|
+
activeInputSource: null,
|
|
13
|
+
lastCalibrationReport: null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
load () {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'))
|
|
20
|
+
this.state = {
|
|
21
|
+
profiles: Array.isArray(parsed.profiles) ? parsed.profiles : [],
|
|
22
|
+
activeProfileId: parsed.activeProfileId || null,
|
|
23
|
+
activeInputSource: parsed.activeInputSource || null,
|
|
24
|
+
lastCalibrationReport: parsed.lastCalibrationReport || null
|
|
25
|
+
}
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code !== 'ENOENT') throw error
|
|
28
|
+
}
|
|
29
|
+
return this.state
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
save () {
|
|
33
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
|
34
|
+
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
|
35
|
+
fs.writeFileSync(tmpPath, `${JSON.stringify(this.state, null, 2)}\n`)
|
|
36
|
+
fs.renameSync(tmpPath, this.filePath)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
list () {
|
|
40
|
+
return this.state.profiles
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get (id) {
|
|
44
|
+
return this.state.profiles.find(profile => profile.id === id) || null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
upsert (profile) {
|
|
48
|
+
const index = this.state.profiles.findIndex(item => item.id === profile.id)
|
|
49
|
+
if (index === -1) this.state.profiles.push(profile)
|
|
50
|
+
else this.state.profiles[index] = profile
|
|
51
|
+
this.state.lastCalibrationReport = profile
|
|
52
|
+
this.save()
|
|
53
|
+
return profile
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
saveProfile (profile) {
|
|
57
|
+
const now = new Date().toISOString()
|
|
58
|
+
const saved = {
|
|
59
|
+
...profile,
|
|
60
|
+
state: 'saved',
|
|
61
|
+
savedAt: profile.savedAt || now,
|
|
62
|
+
displayName: profile.displayName || now.replace('T', ' ').slice(0, 19)
|
|
63
|
+
}
|
|
64
|
+
return this.upsert(saved)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
activate (id) {
|
|
68
|
+
const activated = this.get(id)
|
|
69
|
+
if (!activated) return null
|
|
70
|
+
this.state.activeProfileId = id
|
|
71
|
+
this.save()
|
|
72
|
+
return activated
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
configureRuntime (profileId, inputSource) {
|
|
76
|
+
if (profileId && !this.get(profileId)) return null
|
|
77
|
+
this.state.activeProfileId = profileId || null
|
|
78
|
+
this.state.activeInputSource = inputSource || null
|
|
79
|
+
this.save()
|
|
80
|
+
return {
|
|
81
|
+
activeProfileId: this.state.activeProfileId,
|
|
82
|
+
activeInputSource: this.state.activeInputSource
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
activeInputSource () {
|
|
87
|
+
return this.state.activeInputSource || null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
archive (id) {
|
|
91
|
+
const profile = this.get(id)
|
|
92
|
+
if (!profile) return null
|
|
93
|
+
const archived = { ...profile, state: 'archived', archivedAt: new Date().toISOString() }
|
|
94
|
+
const index = this.state.profiles.findIndex(item => item.id === archived.id)
|
|
95
|
+
if (index === -1) this.state.profiles.push(archived)
|
|
96
|
+
else this.state.profiles[index] = archived
|
|
97
|
+
if (this.state.activeProfileId === id) {
|
|
98
|
+
this.state.activeProfileId = null
|
|
99
|
+
this.state.activeInputSource = null
|
|
100
|
+
}
|
|
101
|
+
this.save()
|
|
102
|
+
return archived
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
reject (id) {
|
|
106
|
+
const profile = this.get(id)
|
|
107
|
+
if (!profile) return null
|
|
108
|
+
const rejected = { ...profile, state: 'rejected', rejectedAt: new Date().toISOString() }
|
|
109
|
+
const index = this.state.profiles.findIndex(item => item.id === rejected.id)
|
|
110
|
+
if (index === -1) this.state.profiles.push(rejected)
|
|
111
|
+
else this.state.profiles[index] = rejected
|
|
112
|
+
if (this.state.activeProfileId === id) {
|
|
113
|
+
this.state.activeProfileId = null
|
|
114
|
+
this.state.activeInputSource = null
|
|
115
|
+
}
|
|
116
|
+
this.save()
|
|
117
|
+
return rejected
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
delete (id) {
|
|
121
|
+
const before = this.state.profiles.length
|
|
122
|
+
this.state.profiles = this.state.profiles.filter(profile => profile.id !== id)
|
|
123
|
+
if (this.state.activeProfileId === id) {
|
|
124
|
+
this.state.activeProfileId = null
|
|
125
|
+
this.state.activeInputSource = null
|
|
126
|
+
}
|
|
127
|
+
this.save()
|
|
128
|
+
return this.state.profiles.length !== before
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
active () {
|
|
132
|
+
if (!this.state.activeProfileId) return null
|
|
133
|
+
return this.get(this.state.activeProfileId)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function createProfileStore (app, pluginId) {
|
|
138
|
+
let root
|
|
139
|
+
if (app && typeof app.getDataDirPath === 'function') {
|
|
140
|
+
root = app.getDataDirPath()
|
|
141
|
+
} else {
|
|
142
|
+
root = path.join(process.cwd(), 'data')
|
|
143
|
+
}
|
|
144
|
+
return new ProfileStore(path.join(root, pluginId, 'profiles.json'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
ProfileStore,
|
|
149
|
+
createProfileStore
|
|
150
|
+
}
|