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.
@@ -0,0 +1,310 @@
1
+ 'use strict'
2
+
3
+ const DEFAULT_METRICS = {
4
+ headingMagnetic: 'navigation_headingMagnetic',
5
+ courseOverGroundTrue: 'navigation_courseOverGroundTrue',
6
+ speedOverGround: 'navigation_speedOverGround',
7
+ magneticVariation: 'navigation_magneticVariation'
8
+ }
9
+
10
+ const PATH_TO_METRIC_KEY = {
11
+ 'navigation.headingMagnetic': 'headingMagnetic',
12
+ 'navigation.courseOverGroundTrue': 'courseOverGroundTrue',
13
+ 'navigation.speedOverGround': 'speedOverGround',
14
+ 'navigation.magneticVariation': 'magneticVariation'
15
+ }
16
+
17
+ class PrometheusHistoryProvider {
18
+ constructor (options = {}) {
19
+ this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '')
20
+ this.context = options.context || 'vessels.self'
21
+ this.metrics = { ...DEFAULT_METRICS, ...(options.metrics || {}) }
22
+ this.auth = normalizeAuth(options.auth)
23
+ this.fetchImpl = options.fetch || global.fetch
24
+ if (!this.fetchImpl) {
25
+ throw new Error('A fetch implementation is required')
26
+ }
27
+ }
28
+
29
+ metricForPath (path) {
30
+ const key = PATH_TO_METRIC_KEY[path]
31
+ return key ? this.metrics[key] : path.replace(/\./g, '_')
32
+ }
33
+
34
+ async listSources (path, range = {}) {
35
+ const metric = this.metricForPath(path)
36
+ const sources = await this.sourcesForMetric(metric)
37
+ const rows = []
38
+ for (const source of sources) {
39
+ const coverage = await this.getCoverage(path, source, range).catch(error => ({
40
+ source,
41
+ path,
42
+ metric,
43
+ error: error.message,
44
+ sampleCount: 0,
45
+ coveragePercent: 0
46
+ }))
47
+ if (coverage.sampleCount > 0) rows.push(coverage)
48
+ }
49
+ return rows
50
+ }
51
+
52
+ async sourcesForMetric (metric) {
53
+ const series = await this.instantSeries(metric)
54
+ const sources = unique(series
55
+ .filter(item => !this.context || item.metric && item.metric.context === this.context)
56
+ .map(item => item.metric && item.metric.source)
57
+ .filter(Boolean))
58
+ if (sources.length > 0) return sources
59
+ return this.labelValues('source')
60
+ }
61
+
62
+ async discover (paths, range = {}, resolutionSeconds = 30) {
63
+ const result = {}
64
+ for (const path of paths) {
65
+ result[path] = await this.listSources(path, { ...range, resolutionSeconds })
66
+ }
67
+ return result
68
+ }
69
+
70
+ async detectContextFromPath (path, range = {}, resolutionSeconds = 30) {
71
+ const metric = this.metricForPath(path)
72
+ const result = await this.queryRange(metric, range, resolutionSeconds)
73
+ const contexts = unique(result.map(series => series.metric && series.metric.context).filter(Boolean))
74
+ if (contexts.length === 0) return null
75
+ if (contexts.length === 1) return contexts[0]
76
+
77
+ const ranked = result
78
+ .map(series => ({
79
+ context: series.metric && series.metric.context,
80
+ samples: Array.isArray(series.values) ? series.values.length : 0
81
+ }))
82
+ .filter(item => item.context)
83
+ .sort((a, b) => b.samples - a.samples)
84
+ return ranked[0] ? ranked[0].context : contexts[0]
85
+ }
86
+
87
+ withContext (context) {
88
+ return new PrometheusHistoryProvider({
89
+ baseUrl: this.baseUrl,
90
+ context,
91
+ metrics: this.metrics,
92
+ auth: this.auth,
93
+ fetch: this.fetchImpl
94
+ })
95
+ }
96
+
97
+ async diagnosePath (path, range = {}, resolutionSeconds = 30) {
98
+ const metric = this.metricForPath(path)
99
+ const metricSeries = await this.instantSeries(metric).catch(error => ({ error: error.message, result: [] }))
100
+ const metricMatches = Array.isArray(metricSeries) ? metricSeries : metricSeries.result
101
+ const contexts = unique(metricMatches.map(series => series.metric && series.metric.context).filter(Boolean))
102
+ const sourcesForContext = unique(metricMatches
103
+ .filter(series => !this.context || series.metric && series.metric.context === this.context)
104
+ .map(series => series.metric && series.metric.source)
105
+ .filter(Boolean))
106
+ const rangeSeries = await this.queryRange(this.selector(metric), range, resolutionSeconds)
107
+ .catch(error => ({ error: error.message, result: [] }))
108
+ const rangeMatches = Array.isArray(rangeSeries) ? rangeSeries : rangeSeries.result
109
+ const sampleCount = countRangeSamples(rangeMatches)
110
+
111
+ return {
112
+ path,
113
+ metric,
114
+ selector: this.selector(metric),
115
+ metricSeriesCount: metricMatches.length,
116
+ contexts,
117
+ sourcesForSelectedContext: sourcesForContext,
118
+ rangeSeriesCount: rangeMatches.length,
119
+ rangeSampleCount: sampleCount,
120
+ error: metricSeries.error || rangeSeries.error || null
121
+ }
122
+ }
123
+
124
+ async getSeries (path, source, range = {}, resolutionSeconds = 1) {
125
+ const metric = this.metricForPath(path)
126
+ const selector = this.selector(metric, source)
127
+ const result = await this.queryRange(selector, range, resolutionSeconds)
128
+ return rangeResultToSamples(result)
129
+ }
130
+
131
+ async getSeriesChunked (path, source, range = {}, resolutionSeconds = 1, maxPointsPerQuery = 10000) {
132
+ const from = normalizeTime(range.from)
133
+ const to = normalizeTime(range.to)
134
+ if (!from || !to || to <= from) throw new Error('A valid from/to range is required')
135
+
136
+ const samples = []
137
+ const stepMs = Math.max(Number(resolutionSeconds || 1) * 1000, 1000)
138
+ const chunkMs = Math.max((Number(maxPointsPerQuery) || 10000) * stepMs, stepMs)
139
+ for (let chunkFrom = from; chunkFrom <= to; chunkFrom += chunkMs) {
140
+ const chunkTo = Math.min(chunkFrom + chunkMs - stepMs, to)
141
+ const chunk = await this.getSeries(path, source, {
142
+ from: new Date(chunkFrom).toISOString(),
143
+ to: new Date(chunkTo).toISOString()
144
+ }, resolutionSeconds)
145
+ samples.push(...chunk)
146
+ }
147
+ samples.sort((a, b) => a.t - b.t)
148
+ return dedupeSamples(samples)
149
+ }
150
+
151
+ async instantSeries (query) {
152
+ const params = new URLSearchParams({ query })
153
+ const data = await this.request(`/api/v1/query?${params}`)
154
+ return data.result || []
155
+ }
156
+
157
+ async queryRange (query, range = {}, resolutionSeconds = 1) {
158
+ const params = new URLSearchParams({
159
+ query,
160
+ start: toUnixSeconds(range.from),
161
+ end: toUnixSeconds(range.to),
162
+ step: String(resolutionSeconds || range.resolutionSeconds || 1)
163
+ })
164
+ const data = await this.request(`/api/v1/query_range?${params}`)
165
+ return data.result || []
166
+ }
167
+
168
+ async getCoverage (path, source, range = {}) {
169
+ const resolutionSeconds = Number(range.resolutionSeconds || range.step || 30)
170
+ const samples = await this.getSeries(path, source, range, resolutionSeconds)
171
+ const from = normalizeTime(range.from)
172
+ const to = normalizeTime(range.to)
173
+ const expected = from && to ? Math.max(Math.floor((to - from) / 1000 / resolutionSeconds) + 1, 1) : samples.length
174
+ const values = samples.map(sample => sample.value)
175
+ return {
176
+ path,
177
+ metric: this.metricForPath(path),
178
+ source,
179
+ sampleCount: samples.length,
180
+ firstSample: samples[0] ? new Date(samples[0].t).toISOString() : null,
181
+ lastSample: samples[samples.length - 1] ? new Date(samples[samples.length - 1].t).toISOString() : null,
182
+ latestValue: values.length ? values[values.length - 1] : null,
183
+ coveragePercent: expected ? Math.min(100, Math.round(samples.length / expected * 1000) / 10) : 0
184
+ }
185
+ }
186
+
187
+ async labelValues (label) {
188
+ const data = await this.request(`/api/v1/label/${encodeURIComponent(label)}/values`)
189
+ return Array.isArray(data.result) ? data.result : []
190
+ }
191
+
192
+ selector (metric, source) {
193
+ const labels = [`context="${escapeLabel(this.context)}"`]
194
+ if (source) labels.push(`source="${escapeLabel(source)}"`)
195
+ return `${metric}{${labels.join(',')}}`
196
+ }
197
+
198
+ async request (path) {
199
+ if (!this.baseUrl) throw new Error('Prometheus baseUrl is required')
200
+ let response
201
+ try {
202
+ response = await this.fetchImpl(`${this.baseUrl}${path}`, {
203
+ headers: this.headers()
204
+ })
205
+ } catch (error) {
206
+ throw new Error(`Prometheus connection failed: ${error.message}`)
207
+ }
208
+ if (!response.ok) {
209
+ const body = await readErrorBody(response)
210
+ const detail = response.status === 401
211
+ ? 'authentication failed'
212
+ : response.status === 403
213
+ ? 'access forbidden'
214
+ : response.statusText
215
+ throw new Error(`Prometheus request failed: ${response.status} ${detail}${body ? `: ${body}` : ''}`)
216
+ }
217
+ let body
218
+ try {
219
+ body = await response.json()
220
+ } catch (error) {
221
+ throw new Error(`Prometheus returned invalid JSON: ${error.message}`)
222
+ }
223
+ if (body.status && body.status !== 'success') {
224
+ throw new Error(body.error || 'Prometheus request did not succeed')
225
+ }
226
+ return body.data || body
227
+ }
228
+
229
+ headers () {
230
+ if (!this.auth || this.auth.type !== 'basic' || !this.auth.username) return {}
231
+ return {
232
+ authorization: `Basic ${Buffer.from(`${this.auth.username}:${this.auth.password || ''}`).toString('base64')}`
233
+ }
234
+ }
235
+ }
236
+
237
+ async function readErrorBody (response) {
238
+ try {
239
+ const text = await response.text()
240
+ if (!text) return ''
241
+ try {
242
+ const parsed = JSON.parse(text)
243
+ return parsed.error || parsed.message || text.slice(0, 500)
244
+ } catch (error) {
245
+ return text.slice(0, 500)
246
+ }
247
+ } catch (error) {
248
+ return ''
249
+ }
250
+ }
251
+
252
+ function normalizeAuth (auth) {
253
+ if (!auth || typeof auth !== 'object') return null
254
+ if (auth.type && auth.type !== 'basic') return null
255
+ if (!auth.username) return null
256
+ return {
257
+ type: 'basic',
258
+ username: String(auth.username),
259
+ password: auth.password ? String(auth.password) : ''
260
+ }
261
+ }
262
+
263
+ function unique (values) {
264
+ return Array.from(new Set(values)).sort()
265
+ }
266
+
267
+ function countRangeSamples (series) {
268
+ return series.reduce((sum, item) => sum + (Array.isArray(item.values) ? item.values.length : 0), 0)
269
+ }
270
+
271
+ function rangeResultToSamples (result) {
272
+ const samples = []
273
+ for (const series of result) {
274
+ for (const [timestamp, value] of series.values || []) {
275
+ const number = Number(value)
276
+ if (Number.isFinite(number)) samples.push({ t: Number(timestamp) * 1000, value: number })
277
+ }
278
+ }
279
+ samples.sort((a, b) => a.t - b.t)
280
+ return samples
281
+ }
282
+
283
+ function dedupeSamples (samples) {
284
+ const byTime = new Map()
285
+ for (const sample of samples) byTime.set(sample.t, sample)
286
+ return Array.from(byTime.values()).sort((a, b) => a.t - b.t)
287
+ }
288
+
289
+ function escapeLabel (value) {
290
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
291
+ }
292
+
293
+ function normalizeTime (value) {
294
+ if (!value) return null
295
+ if (value instanceof Date) return value.getTime()
296
+ if (typeof value === 'number') return value < 100000000000 ? value * 1000 : value
297
+ const time = new Date(value).getTime()
298
+ return Number.isFinite(time) ? time : null
299
+ }
300
+
301
+ function toUnixSeconds (value) {
302
+ const time = normalizeTime(value)
303
+ if (!time) throw new Error('A valid from/to range is required')
304
+ return String(Math.floor(time / 1000))
305
+ }
306
+
307
+ module.exports = {
308
+ DEFAULT_METRICS,
309
+ PrometheusHistoryProvider
310
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "signalk-compass-calibrator",
3
+ "version": "0.2.0",
4
+ "description": "Signal K plugin that calibrates magnetic heading from source-aware historical navigation data.",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "lib",
12
+ "public",
13
+ "README.md",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "keywords": [
17
+ "signalk-node-server-plugin",
18
+ "signalk-embeddable-webapp",
19
+ "signalk-webapp",
20
+ "signalk-category-utility",
21
+ "signalk",
22
+ "compass",
23
+ "calibration",
24
+ "victoriametrics",
25
+ "prometheus"
26
+ ],
27
+ "author": "Jean-Laurent Girod (@macjl)",
28
+ "license": "Apache-2.0",
29
+ "homepage": "https://github.com/macjl/signalk-compass-calibrator#readme",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/macjl/signalk-compass-calibrator.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/macjl/signalk-compass-calibrator/issues"
36
+ },
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {},
41
+ "devDependencies": {},
42
+ "signalk-plugin-enabled-by-default": false,
43
+ "signalk": {
44
+ "displayName": "Compass Calibrator",
45
+ "appIcon": "./public/icon.svg",
46
+ "screenshots": [
47
+ "./public/screenshot.png"
48
+ ]
49
+ }
50
+ }