@tetherto/mdk-core 0.0.0 → 0.12.1

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 (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +572 -0
  3. package/index.js +519 -0
  4. package/lib/address-policy.js +124 -0
  5. package/lib/local-discovery.js +321 -0
  6. package/lib/services/actions.service.js +68 -0
  7. package/lib/services/alerts.service.js +207 -0
  8. package/lib/services/comments.service.js +118 -0
  9. package/lib/services/log-history.service.js +307 -0
  10. package/lib/services/logs.service.js +249 -0
  11. package/lib/services/pool-utils/constants.js +20 -0
  12. package/lib/services/pool-utils/time.js +49 -0
  13. package/lib/services/pool.service.js +397 -0
  14. package/lib/services/provisioning.service.js +420 -0
  15. package/lib/services/settings.service.js +26 -0
  16. package/lib/services/snaps.service.js +181 -0
  17. package/lib/services/stats.service.js +205 -0
  18. package/lib/stats/README.md +80 -0
  19. package/lib/stats/index.js +57 -0
  20. package/lib/stats/ops/alerts_aggr.js +40 -0
  21. package/lib/stats/ops/alerts_group_cnt.js +43 -0
  22. package/lib/stats/ops/arr_concat.js +27 -0
  23. package/lib/stats/ops/array_obj_calc.js +41 -0
  24. package/lib/stats/ops/avg.js +42 -0
  25. package/lib/stats/ops/cnt.js +19 -0
  26. package/lib/stats/ops/group.js +38 -0
  27. package/lib/stats/ops/group_avg.js +53 -0
  28. package/lib/stats/ops/group_cnt.js +38 -0
  29. package/lib/stats/ops/group_max.js +40 -0
  30. package/lib/stats/ops/group_multiple_stats.js +40 -0
  31. package/lib/stats/ops/group_sum.js +40 -0
  32. package/lib/stats/ops/index.js +35 -0
  33. package/lib/stats/ops/nested_obj_concat.js +54 -0
  34. package/lib/stats/ops/obj_concat.js +33 -0
  35. package/lib/stats/ops/sum.js +27 -0
  36. package/lib/stats/utils.js +99 -0
  37. package/lib/templates/alerts.js +128 -0
  38. package/lib/templates/stats.js +476 -0
  39. package/lib/things/constants.js +65 -0
  40. package/lib/things/container.js +84 -0
  41. package/lib/things/miner.js +190 -0
  42. package/lib/things/powermeter.js +30 -0
  43. package/lib/things/sensor.js +18 -0
  44. package/lib/things/thing.js +93 -0
  45. package/lib/utils.js +603 -0
  46. package/lib/worker-infra.js +221 -0
  47. package/package.json +64 -1
  48. package/services.js +246 -0
  49. package/utils/compose-yaml.js +56 -0
  50. package/utils/constants.js +30 -0
  51. package/utils/index.js +29 -0
  52. package/utils/initialize.js +148 -0
  53. package/utils/service-bootstrap.js +271 -0
  54. package/worker.js +9 -0
@@ -0,0 +1,420 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug')('mdk:provisioning')
4
+ const { v4: uuidv4 } = require('uuid')
5
+ const gLibUtilBase = require('@bitfinex/lib-js-util-base')
6
+ const utilsStore = require('@tetherto/hp-svc-facs-store/utils')
7
+ const { applyFilters, getJsonChanges, redactSecrets, stripRedacted, isSecretKey } = require('../utils')
8
+ const { validateAddress, validatePort } = require('../address-policy')
9
+
10
+ // Drops every secret-named key (deep) — used when a device is repointed to a
11
+ // new address so the stored credentials are not sent to the new host.
12
+ function omitSecrets (value) {
13
+ if (value === null || typeof value !== 'object') return value
14
+ if (Array.isArray(value)) return value.map(omitSecrets)
15
+ const out = {}
16
+ for (const [k, v] of Object.entries(value)) {
17
+ if (isSecretKey(k)) continue
18
+ out[k] = omitSecrets(v)
19
+ }
20
+ return out
21
+ }
22
+
23
+ // info.container / info.pos become stats group keys (groupBy('info.container'))
24
+ // and `container-*` / `pos-*` tags, so they are kept to short labels. The
25
+ // prototype-reaching names are refused even though the stats ops skip them.
26
+ const INFO_LABEL_MAX_LENGTH = 64
27
+ const INFO_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 _.:#-]*$/
28
+ const INFO_LABEL_FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype'])
29
+
30
+ function validateInfoLabel (value, code) {
31
+ if (value === undefined || value === null || value === '') return
32
+ if (
33
+ typeof value !== 'string' ||
34
+ value.length > INFO_LABEL_MAX_LENGTH ||
35
+ !INFO_LABEL_RE.test(value) ||
36
+ INFO_LABEL_FORBIDDEN.has(value)
37
+ ) {
38
+ throw new Error(code)
39
+ }
40
+ }
41
+
42
+ // Caller-supplied tags become stats group keys and each gets its own time-log
43
+ // store per stats interval, so they get the same label rules and a count cap.
44
+ const MAX_AUX_TAGS = 32
45
+
46
+ function validateAuxTags (tags) {
47
+ if (!Array.isArray(tags) || tags.length > MAX_AUX_TAGS) {
48
+ throw new Error('ERR_THING_TAGS_INVALID')
49
+ }
50
+ for (const tag of tags) {
51
+ if (typeof tag !== 'string' || tag === '') throw new Error('ERR_THING_TAGS_INVALID')
52
+ validateInfoLabel(tag, 'ERR_THING_TAGS_INVALID')
53
+ }
54
+ }
55
+
56
+ function validateInfo (info) {
57
+ if (info === undefined || info === null) return
58
+ if (typeof info !== 'object' || Array.isArray(info)) {
59
+ throw new Error('ERR_THING_INFO_INVALID')
60
+ }
61
+ validateInfoLabel(info.container, 'ERR_THING_INFO_CONTAINER_INVALID')
62
+ validateInfoLabel(info.pos, 'ERR_THING_INFO_POS_INVALID')
63
+ }
64
+
65
+ /**
66
+ * Persisted device configs for a runtime-hosted worker. Replaces the
67
+ * registerThing/updateThing/forgetThings provisioning path of the manager
68
+ * stack: writes go to the injected bee; the boot script builds the runtime's
69
+ * fixed `devices` array from buildRuntimeDevices(). Changing the device set
70
+ * is a persisted-config change + process restart — this service never touches
71
+ * live connections.
72
+ *
73
+ * Record shape (kept compatible with the legacy thing record so comments,
74
+ * tailLog transforms and stats tags keep working):
75
+ * { id, type, opts, info, code, tags, comments }
76
+ *
77
+ * Caller-facing reads (getThingConf, getDeviceMeta, listDevices) return
78
+ * redacted copies: secret fields (password, apiKey, token, pools[].pass, ...)
79
+ * read as '[REDACTED]', and list filters run over that redacted projection.
80
+ * Remote writes validate opts.address/opts.port (see lib/address-policy.js);
81
+ * conf.allowedAddressCidrs is the operator allowlist that replaces the
82
+ * default loopback/link-local/metadata blocklist. Changing a device's address
83
+ * drops its stored credentials unless the update supplies new ones.
84
+ */
85
+ class DeviceProvisioningService {
86
+ constructor ({ db, logs = null, deviceType, deviceTags = [], conf = {}, lookup = null }) {
87
+ if (!db) throw new Error('ERR_PROVISIONING_DB_REQUIRED')
88
+ if (!deviceType || typeof deviceType !== 'string') throw new Error('ERR_PROVISIONING_DEVICE_TYPE_REQUIRED')
89
+ this.db = db
90
+ this.logs = logs
91
+ this.deviceType = deviceType
92
+ this.deviceTags = deviceTags
93
+ this.conf = conf
94
+ this._lookup = lookup
95
+ this._devices = {}
96
+ this._initialized = false
97
+ }
98
+
99
+ async init () {
100
+ if (this._initialized) return
101
+ const stream = this.db.createReadStream()
102
+ for await (const data of stream) {
103
+ const entry = JSON.parse(data.value.toString())
104
+ this._devices[entry.id] = entry
105
+ }
106
+ this._initialized = true
107
+ debug('loaded %d device records (type %s)', Object.keys(this._devices).length, this.deviceType)
108
+ }
109
+
110
+ generateId () {
111
+ return uuidv4()
112
+ }
113
+
114
+ getDevice (id) {
115
+ return this._devices[id]
116
+ }
117
+
118
+ listDeviceIds () {
119
+ return Object.keys(this._devices)
120
+ }
121
+
122
+ _redactedDevices () {
123
+ return Object.values(this._devices).map((dev) => redactSecrets(dev))
124
+ }
125
+
126
+ listDevices (req = {}) {
127
+ return applyFilters(this._redactedDevices(), req, true)
128
+ }
129
+
130
+ getDeviceMeta (id) {
131
+ const dev = this._devices[id]
132
+ if (!dev) return null
133
+ return redactSecrets(gLibUtilBase.pick(dev, ['id', 'tags', 'info', 'opts', 'type', 'code']))
134
+ }
135
+
136
+ buildRuntimeDevices () {
137
+ return Object.values(this._devices).map((dev) => ({
138
+ deviceId: dev.id,
139
+ config: { ...dev.opts, type: dev.type }
140
+ }))
141
+ }
142
+
143
+ getThingConf (query = {}) {
144
+ if (query.requestType === 'poolConfig') return redactSecrets(this.conf.pools)
145
+ const dev = query.thingId ? this._devices[query.thingId] : null
146
+ return dev ? redactSecrets(gLibUtilBase.pick(dev, ['id', 'type', 'opts', 'info', 'code', 'tags', 'comments'])) : null
147
+ }
148
+
149
+ // Remote-write validation of the dialable part of opts. Throws
150
+ // ERR_THING_ADDRESS_* / ERR_THING_PORT_INVALID.
151
+ async _validateOptsTarget (opts) {
152
+ if (!opts || typeof opts !== 'object' || Array.isArray(opts)) {
153
+ throw new Error('ERR_THING_VALIDATE_OPTS_INVALID')
154
+ }
155
+ if (opts.address !== undefined) {
156
+ await validateAddress(opts.address, {
157
+ allowedCidrs: this.conf.allowedAddressCidrs,
158
+ ...(this._lookup ? { lookup: this._lookup } : {})
159
+ })
160
+ }
161
+ if (opts.port !== undefined) opts.port = validatePort(opts.port)
162
+ }
163
+
164
+ _prepDeviceTags (dev, aux, devPrev) {
165
+ const parts = this.deviceType.split('-')
166
+ parts.push('_')
167
+
168
+ let tags = []
169
+
170
+ for (let i = 1; i < parts.length; i++) {
171
+ tags.push(`t-${parts.slice(0, -i).join('-')}`)
172
+ }
173
+
174
+ tags = tags.concat(this.deviceTags)
175
+
176
+ if (aux) {
177
+ validateAuxTags(aux)
178
+ tags = tags.concat(aux)
179
+ }
180
+
181
+ if (Array.isArray(devPrev?.tags)) {
182
+ tags = tags.concat(devPrev.tags)
183
+ }
184
+
185
+ tags.push(`id-${dev.id}`)
186
+ tags.push(`code-${dev.code}`)
187
+
188
+ tags = tags.filter(val => !val.includes('pos-') && !val.includes('container-'))
189
+ if (dev.info?.pos) tags.push(`pos-${dev.info.pos}`)
190
+ if (dev.info?.container) tags.push(`container-${dev.info.container}`)
191
+
192
+ return gLibUtilBase.getArrayUniq(tags)
193
+ }
194
+
195
+ _getMaxDeviceCode () {
196
+ const devices = Object.values(this._devices).filter(dev => dev.code)
197
+ return devices.reduce((acc, cur) => {
198
+ const code = parseInt(cur.code.split('-').pop(), 10) || 0
199
+ return Math.max(acc, code)
200
+ }, 0)
201
+ }
202
+
203
+ _generateDeviceCode (seed) {
204
+ const prefix = this.deviceType.replace(/^[^-]+-/, '').toUpperCase()
205
+ const last = this._getMaxDeviceCode()
206
+ const nextCode = (seed ?? (last + 1)).toString().padStart(4, '0')
207
+ return `${prefix}-${nextCode}`
208
+ }
209
+
210
+ validateRegisterThing (data) {
211
+ if (data.id && this._devices[data.id]) {
212
+ throw new Error('ERR_THING_WITH_ID_ALREADY_EXISTS')
213
+ }
214
+ if (data.code && !(/-\d+$/.test(data.code))) {
215
+ throw new Error('ERR_THING_CODE_INVALID')
216
+ }
217
+ if (data.code && Object.values(this._devices).some(dev => dev.code === data.code)) {
218
+ throw new Error('ERR_THING_WITH_CODE_ALREADY_EXISTS')
219
+ }
220
+ if (!data.opts) {
221
+ throw new Error('ERR_THING_VALIDATE_OPTS_INVALID')
222
+ }
223
+ validateInfo(data.info)
224
+ this._validateDeviceDataChange(data)
225
+ }
226
+
227
+ _validateDeviceDataChange (data) {
228
+ for (const k in this._devices) {
229
+ const t = this._devices[k]
230
+ if (t.id === data.id) continue
231
+ if (t.info?.serialNum && t.info.serialNum === data.info?.serialNum) {
232
+ throw new Error('ERR_THING_SERIALNUM_EXISTS')
233
+ }
234
+ if (t.info?.macAddress && t.info.macAddress.toLowerCase() === data.info?.macAddress?.toLowerCase()) {
235
+ throw new Error('ERR_THING_MACADDRESS_EXISTS')
236
+ }
237
+ if (t.info?.pos && t.info.pos === data.info?.pos && t.info?.container && t.info.container === data.info?.container) {
238
+ throw new Error('ERR_THING_POS_EXISTS')
239
+ }
240
+ if (!this.conf.allowDuplicateIPs && t.opts?.address && t.opts.address === data.opts?.address) {
241
+ throw new Error('ERR_THING_IP_ADDRESS_EXISTS')
242
+ }
243
+ }
244
+ }
245
+
246
+ async _storeDeviceDb (dev) {
247
+ await this.db.put(dev.id, Buffer.from(JSON.stringify(dev)))
248
+ }
249
+
250
+ /**
251
+ * @param {object} req registerThing payload ({ id?, code?, opts, info, tags, ... })
252
+ * @param {object} [options]
253
+ * @param {boolean} [options.trusted] skip the address policy — for
254
+ * operator-supplied seed configs only, never for remote callers.
255
+ */
256
+ async registerThing (req, { trusted = false } = {}) {
257
+ this.validateRegisterThing(req)
258
+ req = { ...req, opts: stripRedacted(req.opts) }
259
+ if (!trusted) await this._validateOptsTarget(req.opts)
260
+
261
+ let info = req.info || {}
262
+ const comments = []
263
+
264
+ if (req.comment && req.user) {
265
+ comments.push({
266
+ ts: Date.now(),
267
+ comment: req.comment,
268
+ user: req.user
269
+ })
270
+ }
271
+
272
+ const createdAt = Date.now()
273
+ info = { ...info, createdAt, updatedAt: createdAt }
274
+
275
+ const id = req.id ?? this.generateId()
276
+ const code = req.code ?? this._generateDeviceCode()
277
+ const dev = {
278
+ id,
279
+ type: this.deviceType,
280
+ opts: req.opts,
281
+ info,
282
+ code,
283
+ tags: this._prepDeviceTags({ ...req, id, code, info }, req.tags),
284
+ comments
285
+ }
286
+
287
+ await this._storeDeviceDb(dev)
288
+ this._devices[id] = dev
289
+
290
+ debug('registered device %s (%s)', id, code)
291
+ return 1
292
+ }
293
+
294
+ checkThingExists (req) {
295
+ if (!req?.thingId || !this._devices[req.thingId]) {
296
+ throw new Error('ERR_THING_NOTFOUND')
297
+ }
298
+ }
299
+
300
+ async updateThing (req) {
301
+ if (!req.id || !this._devices[req.id]) {
302
+ throw new Error('ERR_THING_NOTFOUND')
303
+ }
304
+ validateInfo(req.info)
305
+ this._validateDeviceDataChange(req)
306
+
307
+ let dev = await this.db.get(req.id)
308
+ dev = JSON.parse(dev.value.toString())
309
+ const devPrev = { opts: { ...dev.opts }, info: { ...dev.info }, tags: [...(dev.tags || [])] }
310
+
311
+ if (!dev.code) {
312
+ dev.code = this._generateDeviceCode()
313
+ }
314
+
315
+ if (req.opts) {
316
+ const reqOpts = stripRedacted(req.opts)
317
+ await this._validateOptsTarget(reqOpts)
318
+ // Repointing a device must not carry its stored credentials to the new
319
+ // host: keep them only when the address is unchanged or the update
320
+ // supplies its own.
321
+ const prevOpts = reqOpts.address !== undefined && reqOpts.address !== dev.opts?.address
322
+ ? omitSecrets(dev.opts || {})
323
+ : dev.opts
324
+ dev.opts = req.forceOverwrite ? reqOpts : { ...prevOpts, ...reqOpts }
325
+ }
326
+
327
+ if (!Array.isArray(dev.comments)) {
328
+ dev.comments = []
329
+ }
330
+
331
+ if (req.comment && req.user) {
332
+ dev.comments.push({
333
+ ts: Date.now(),
334
+ comment: req.comment,
335
+ user: req.user
336
+ })
337
+ }
338
+
339
+ if (req.info) {
340
+ const lastActionId = req.actionId
341
+ dev.info = req.forceOverwrite
342
+ ? req.info
343
+ : {
344
+ ...dev.info,
345
+ ...req.info,
346
+ ...(lastActionId ? { lastActionId } : {})
347
+ }
348
+ }
349
+
350
+ dev.tags = this._prepDeviceTags(dev, req.tags, devPrev)
351
+ dev.info = { ...dev.info, updatedAt: Date.now() }
352
+
353
+ await this._storeInfoChanges(devPrev, dev)
354
+
355
+ await this._storeDeviceDb(dev)
356
+ this._devices[dev.id] = dev
357
+
358
+ return 1
359
+ }
360
+
361
+ async _storeInfoChanges (devPrev, dev) {
362
+ if (!this.logs) return
363
+ const log = await this.logs.getBeeTimeLog('thing-history-log', 0, true)
364
+ try {
365
+ const now = Date.now()
366
+ const existingEntry = await log.get(utilsStore.convIntToBin(now))
367
+ const existingInfoHistory =
368
+ existingEntry?.value
369
+ ? JSON.parse(existingEntry.value.toString())
370
+ : []
371
+ const updatedInfoHistory = [
372
+ ...existingInfoHistory,
373
+ {
374
+ ts: now,
375
+ changes: getJsonChanges(devPrev.info, dev.info),
376
+ id: dev.id
377
+ }
378
+ ]
379
+ await log.put(
380
+ utilsStore.convIntToBin(now),
381
+ Buffer.from(JSON.stringify(updatedInfoHistory))
382
+ )
383
+ } catch (e) {
384
+ debug('ERR_THING_SAVE_INFO_HISTORY %s', e.message)
385
+ }
386
+ await this.logs.releaseBeeTimeLog(log)
387
+ }
388
+
389
+ async forgetThings (req = {}) {
390
+ if (!req.query) {
391
+ req.query = { id: { $in: [] } }
392
+ }
393
+
394
+ if (req.all) {
395
+ req.query = { id: { $exists: true } }
396
+ }
397
+
398
+ const ids = applyFilters(Object.values(this._devices), req)
399
+
400
+ for (const id of ids) {
401
+ await this.db.del(id)
402
+ delete this._devices[id]
403
+ debug('forgot device %s', id)
404
+ }
405
+
406
+ return ids.length
407
+ }
408
+
409
+ async loadThing (req) {
410
+ const raw = await this.db.get(req.thingId)
411
+ return JSON.parse(raw.value.toString())
412
+ }
413
+
414
+ async saveThing (dev) {
415
+ await this._storeDeviceDb(dev)
416
+ this._devices[dev.id] = dev
417
+ }
418
+ }
419
+
420
+ module.exports = DeviceProvisioningService
@@ -0,0 +1,26 @@
1
+ 'use strict'
2
+
3
+ class SettingsService {
4
+ constructor ({ settingsDb }) {
5
+ if (!settingsDb) throw new Error('ERR_SETTINGS_DB_REQUIRED')
6
+ this.settingsDb = settingsDb
7
+ }
8
+
9
+ async getSettings () {
10
+ const meta = await this.settingsDb.get('settings_00')
11
+ return meta ? JSON.parse(meta.value) : {}
12
+ }
13
+
14
+ async saveSettingsEntries (entries) {
15
+ if (!entries || typeof entries !== 'object') {
16
+ throw new Error('ERR_ENTRIES_INVALID')
17
+ }
18
+
19
+ const existingSettings = await this.getSettings()
20
+ const newSettings = { ...existingSettings, ...entries }
21
+ await this.settingsDb.put('settings_00', JSON.stringify(newSettings))
22
+ return newSettings
23
+ }
24
+ }
25
+
26
+ module.exports = SettingsService
@@ -0,0 +1,181 @@
1
+ 'use strict'
2
+
3
+ const debug = require('debug')('mdk:snaps')
4
+ const async = require('async')
5
+ const utilsStore = require('@tetherto/hp-svc-facs-store/utils')
6
+ const { promiseTimeout } = require('@bitfinex/lib-js-util-promise')
7
+
8
+ /**
9
+ * Periodic snapshot collection. Owns the in-memory `last` map
10
+ * ({ snap, alerts, err, ts } per device) that StatsService/AlertsService
11
+ * aggregate over, and persists snaps to `thing-5m-<deviceId>` every
12
+ * conf.storeSnapItvMs. The device round-trip is the injected
13
+ * collectSnap(deviceId); a device in maintenance
14
+ * (getDeviceMeta(id).info.container === 'maintenance') is not contacted.
15
+ */
16
+ class SnapsService {
17
+ constructor ({ listDeviceIds, getDeviceMeta, collectSnap, processAlerts, saveAlerts, logs, conf }) {
18
+ if (typeof collectSnap !== 'function') throw new Error('ERR_SNAPS_COLLECT_SNAP_REQUIRED')
19
+ if (!logs) throw new Error('ERR_SNAPS_LOGS_REQUIRED')
20
+ this.listDeviceIds = listDeviceIds || (() => [])
21
+ this.getDeviceMeta = getDeviceMeta || ((id) => ({ id }))
22
+ this.collectSnap = collectSnap
23
+ this.processAlerts = processAlerts || (() => null)
24
+ this.saveAlerts = saveAlerts || (async () => {})
25
+ this.logs = logs
26
+ this.conf = conf || {}
27
+ this._last = {}
28
+ this._collecting = {}
29
+ this._timer = null
30
+ }
31
+
32
+ getLast (deviceId) {
33
+ return this._last[deviceId]
34
+ }
35
+
36
+ listLast () {
37
+ return this.listDeviceIds().map((id) => ({
38
+ ...(this.getDeviceMeta(id) || { id }),
39
+ last: this._last[id] || {}
40
+ }))
41
+ }
42
+
43
+ start () {
44
+ if (this._timer) return
45
+ this._timer = setInterval(() => {
46
+ this.collectSnaps().catch((e) => debug('ERR_COLLECT_SNAPS %s', e.message))
47
+ }, this.conf.collectSnapsItvMs || 60000)
48
+ this._timer.unref()
49
+ }
50
+
51
+ stop () {
52
+ if (this._timer) {
53
+ clearInterval(this._timer)
54
+ this._timer = null
55
+ }
56
+ }
57
+
58
+ _getOfflineSnap () {
59
+ return {
60
+ success: false,
61
+ stats: {
62
+ status: 'offline'
63
+ }
64
+ }
65
+ }
66
+
67
+ async _storeThingSnap ({ deviceId, thingLastCollectionTs, err, snap }) {
68
+ try {
69
+ const log = await this.logs.getBeeTimeLog(`thing-5m-${deviceId}`, 0, true)
70
+ const kts = utilsStore.convIntToBin(thingLastCollectionTs)
71
+
72
+ await log.put(kts, Buffer.from(JSON.stringify({
73
+ ts: thingLastCollectionTs,
74
+ err: err ? err.message : null,
75
+ snap
76
+ })))
77
+
78
+ await this.logs.releaseBeeTimeLog(log)
79
+ } catch (e) {
80
+ debug('ERR_SAVE_SNAP %s %s', deviceId, e.message)
81
+ }
82
+ }
83
+
84
+ async _collectSnap (deviceId) {
85
+ if (this._collecting[deviceId]?.isCollectingSnap) return
86
+
87
+ this._collecting[deviceId] = {
88
+ ...this._collecting[deviceId],
89
+ isCollectingSnap: true
90
+ }
91
+ const thingLastCollectionTs = Date.now()
92
+
93
+ if (!this._collecting[deviceId].tsThingCollectSnap) {
94
+ this._collecting[deviceId].tsThingCollectSnap = 0
95
+ }
96
+
97
+ const storeSnapItvMs = this.conf.storeSnapItvMs ?? 300000
98
+ const shouldStore =
99
+ thingLastCollectionTs - this._collecting[deviceId].tsThingCollectSnap >= storeSnapItvMs
100
+
101
+ const meta = this.getDeviceMeta(deviceId) || { id: deviceId }
102
+
103
+ await new Promise((resolve, reject) => {
104
+ async.retry(
105
+ this.conf.collectSnapRetry || 3,
106
+ async () => {
107
+ let snap = null
108
+ let err = null
109
+
110
+ if (meta.info?.container === 'maintenance') {
111
+ snap = this._getOfflineSnap()
112
+ } else {
113
+ try {
114
+ snap = await promiseTimeout(
115
+ this.collectSnap(deviceId),
116
+ this.conf.collectSnapTimeoutMs || 120000
117
+ )
118
+ } catch (e) {
119
+ if (e.message === 'ERR_PROMISE_TIMEOUT') {
120
+ snap = this._getOfflineSnap()
121
+ } else {
122
+ err = e
123
+ debug('ERR_COLLECT %s %s', deviceId, e.message)
124
+ }
125
+ }
126
+ }
127
+
128
+ const last = this._last[deviceId] || {}
129
+ last.snap = snap
130
+
131
+ if (snap) {
132
+ last.alerts = this.processAlerts({ ...meta, last })
133
+ }
134
+
135
+ last.err = err ? err.message : null
136
+ last.ts = thingLastCollectionTs
137
+ this._last[deviceId] = last
138
+
139
+ if (shouldStore) {
140
+ await this._storeThingSnap({ deviceId, err, snap, thingLastCollectionTs })
141
+ }
142
+ },
143
+ (retryErr) => {
144
+ this._collecting[deviceId] = {
145
+ isCollectingSnap: false,
146
+ thingLastCollectionTs,
147
+ tsThingCollectSnap: shouldStore
148
+ ? thingLastCollectionTs
149
+ : this._collecting[deviceId].tsThingCollectSnap
150
+ }
151
+ if (retryErr) return reject(retryErr)
152
+ resolve()
153
+ }
154
+ )
155
+ })
156
+ }
157
+
158
+ async collectSnaps () {
159
+ await new Promise((resolve, reject) => {
160
+ async.eachLimit(
161
+ this.listDeviceIds(),
162
+ this.conf.thingQueryConcurrency || 25,
163
+ async (deviceId) => {
164
+ await this._collectSnap(deviceId)
165
+ },
166
+ (err) => {
167
+ if (err) return reject(err)
168
+ resolve()
169
+ }
170
+ )
171
+ })
172
+
173
+ try {
174
+ await this.saveAlerts()
175
+ } catch (err) {
176
+ debug('ERR_SAVE_ALERTS %s', err.message)
177
+ }
178
+ }
179
+ }
180
+
181
+ module.exports = SnapsService