iobroker.anomaly-detection 0.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/LICENSE +21 -0
- package/README.md +323 -0
- package/admin/anomaly-detection-icon.png +0 -0
- package/admin/anomaly-detection.png +0 -0
- package/admin/i18n/de.json +32 -0
- package/admin/i18n/en.json +32 -0
- package/admin/i18n/es.json +32 -0
- package/admin/i18n/fr.json +32 -0
- package/admin/i18n/it.json +32 -0
- package/admin/i18n/nl.json +32 -0
- package/admin/i18n/pl.json +32 -0
- package/admin/i18n/pt.json +32 -0
- package/admin/i18n/ru.json +32 -0
- package/admin/i18n/uk.json +32 -0
- package/admin/i18n/zh-cn.json +32 -0
- package/admin/jsonConfig.json +220 -0
- package/build/lib/advanced-detectors.js +149 -0
- package/build/lib/advanced-detectors.js.map +7 -0
- package/build/lib/context-value.js +42 -0
- package/build/lib/context-value.js.map +7 -0
- package/build/lib/detectors.js +76 -0
- package/build/lib/detectors.js.map +7 -0
- package/build/lib/history-provider.js +58 -0
- package/build/lib/history-provider.js.map +7 -0
- package/build/lib/history-source-resolver.js +109 -0
- package/build/lib/history-source-resolver.js.map +7 -0
- package/build/lib/model/contextual-model.js +98 -0
- package/build/lib/model/contextual-model.js.map +7 -0
- package/build/lib/model/statistics.js +78 -0
- package/build/lib/model/statistics.js.map +7 -0
- package/build/lib/model/temporal-model.js +98 -0
- package/build/lib/model/temporal-model.js.map +7 -0
- package/build/lib/scoring.js +57 -0
- package/build/lib/scoring.js.map +7 -0
- package/build/lib/source-cleanup.js +58 -0
- package/build/lib/source-cleanup.js.map +7 -0
- package/build/lib/source-monitor.js +348 -0
- package/build/lib/source-monitor.js.map +7 -0
- package/build/main.js +589 -0
- package/build/main.js.map +7 -0
- package/io-package.json +95 -0
- package/package.json +105 -0
package/build/main.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (let key of __getOwnPropNames(from))
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
12
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
13
|
+
}
|
|
14
|
+
return to;
|
|
15
|
+
};
|
|
16
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
17
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
18
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
19
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
20
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
21
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
22
|
+
mod
|
|
23
|
+
));
|
|
24
|
+
var utils = __toESM(require("@iobroker/adapter-core"));
|
|
25
|
+
var import_history_provider = require("./lib/history-provider");
|
|
26
|
+
var import_history_source_resolver = require("./lib/history-source-resolver");
|
|
27
|
+
var import_source_cleanup = require("./lib/source-cleanup");
|
|
28
|
+
var import_source_monitor = require("./lib/source-monitor");
|
|
29
|
+
var import_context_value = require("./lib/context-value");
|
|
30
|
+
const MODEL_STATE_ID = "models";
|
|
31
|
+
const PERSIST_DELAY_MS = 3e4;
|
|
32
|
+
const HISTORY_TIMEOUT_MS = 3e4;
|
|
33
|
+
const HISTORY_CONCURRENCY = 2;
|
|
34
|
+
class IoBrokerHistoryProvider {
|
|
35
|
+
constructor(adapter, instance) {
|
|
36
|
+
this.adapter = adapter;
|
|
37
|
+
this.instance = instance;
|
|
38
|
+
}
|
|
39
|
+
async getHistory(sourceId, start, end, limit) {
|
|
40
|
+
const response = await this.adapter.sendToAsync(this.instance, "getHistory", {
|
|
41
|
+
id: sourceId,
|
|
42
|
+
options: { start, end, aggregate: "none", count: limit, returnNewestEntries: false }
|
|
43
|
+
});
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
class AnomalyDetection extends utils.Adapter {
|
|
48
|
+
monitors = /* @__PURE__ */ new Map();
|
|
49
|
+
sourceIds = /* @__PURE__ */ new Map();
|
|
50
|
+
sources = /* @__PURE__ */ new Map();
|
|
51
|
+
invalidValueWarnings = /* @__PURE__ */ new Set();
|
|
52
|
+
invalidHistoryConfigurationWarnings = /* @__PURE__ */ new Set();
|
|
53
|
+
contextValues = /* @__PURE__ */ new Map();
|
|
54
|
+
contextSources = /* @__PURE__ */ new Map();
|
|
55
|
+
contextCategories = /* @__PURE__ */ new Map();
|
|
56
|
+
contextWarnings = /* @__PURE__ */ new Set();
|
|
57
|
+
historySourceResolver = new import_history_source_resolver.HistorySourceResolver(this);
|
|
58
|
+
persistTimer;
|
|
59
|
+
constructor(options = {}) {
|
|
60
|
+
super({ ...options, name: "anomaly-detection" });
|
|
61
|
+
this.on("ready", this.onReady.bind(this));
|
|
62
|
+
this.on("stateChange", this.onStateChange.bind(this));
|
|
63
|
+
this.on("message", this.onMessage.bind(this));
|
|
64
|
+
this.on("unload", this.onUnload.bind(this));
|
|
65
|
+
}
|
|
66
|
+
async onReady() {
|
|
67
|
+
var _a, _b, _c, _d;
|
|
68
|
+
await this.ensureModelState();
|
|
69
|
+
const persistedValue = (_a = await this.getStateAsync(MODEL_STATE_ID)) == null ? void 0 : _a.val;
|
|
70
|
+
let stored = (0, import_source_monitor.parseStoredModel)(persistedValue);
|
|
71
|
+
if (typeof persistedValue === "string" && persistedValue !== "" && persistedValue !== "{}" && Object.keys(stored).length === 0) {
|
|
72
|
+
this.log.warn("Ignoring corrupt or incompatible persisted anomaly models");
|
|
73
|
+
}
|
|
74
|
+
const configuredSourceIds = new Set(
|
|
75
|
+
((_b = this.config.sources) != null ? _b : []).flatMap((source) => {
|
|
76
|
+
var _a2;
|
|
77
|
+
return ((_a2 = source.id) == null ? void 0 : _a2.trim()) ? [source.id.trim()] : [];
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
stored = await this.cleanupStaleSources(configuredSourceIds, stored);
|
|
81
|
+
const configuredIds = /* @__PURE__ */ new Set();
|
|
82
|
+
const bootstrapTasks = [];
|
|
83
|
+
for (const source of (_c = this.config.sources) != null ? _c : []) {
|
|
84
|
+
if (!source.enabled || !((_d = source.id) == null ? void 0 : _d.trim())) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const id = source.id.trim();
|
|
88
|
+
if (configuredIds.has(id)) {
|
|
89
|
+
this.log.warn(`Ignoring duplicate configured source state: ${id}`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
configuredIds.add(id);
|
|
93
|
+
const object = await this.getForeignObjectAsync(id);
|
|
94
|
+
if (!object) {
|
|
95
|
+
this.log.warn(`Configured source state does not exist yet: ${id}`);
|
|
96
|
+
} else if (object.type !== "state" || object.common.type !== "number") {
|
|
97
|
+
this.log.warn(`Configured source is not a numerical state: ${id}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const safeId = this.createSourceId(id);
|
|
101
|
+
this.sourceIds.set(id, safeId);
|
|
102
|
+
const monitor = new import_source_monitor.SourceMonitor(source, stored[safeId]);
|
|
103
|
+
this.monitors.set(id, monitor);
|
|
104
|
+
this.sources.set(safeId, source);
|
|
105
|
+
await this.registerContexts(id, source);
|
|
106
|
+
await this.ensureSourceObjects(safeId, source, object == null ? void 0 : object.common.unit);
|
|
107
|
+
this.subscribeForeignStates(id);
|
|
108
|
+
if (this.shouldBootstrap(source, monitor)) {
|
|
109
|
+
bootstrapTasks.push(() => this.bootstrapSource(id, safeId, source, monitor));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.subscribeStates("sources.*.retrain");
|
|
113
|
+
await this.runWithConcurrency(bootstrapTasks, HISTORY_CONCURRENCY);
|
|
114
|
+
this.log.info(`Monitoring ${this.monitors.size} numerical state${this.monitors.size === 1 ? "" : "s"}`);
|
|
115
|
+
}
|
|
116
|
+
onStateChange(id, state) {
|
|
117
|
+
var _a, _b, _c;
|
|
118
|
+
if (!state) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const sourceId of (_a = this.contextSources.get(id)) != null ? _a : []) {
|
|
122
|
+
(_b = this.contextValues.get(sourceId)) == null ? void 0 : _b.set(id, state.val);
|
|
123
|
+
}
|
|
124
|
+
const retrainMatch = id.match(/sources\.([^.]+)\.retrain$/);
|
|
125
|
+
if (retrainMatch && state.ack === false && state.val === true) {
|
|
126
|
+
const safeId2 = retrainMatch[1];
|
|
127
|
+
const sourceId = (_c = [...this.sourceIds].find(([, value]) => value === safeId2)) == null ? void 0 : _c[0];
|
|
128
|
+
const source = this.sources.get(safeId2);
|
|
129
|
+
const monitor2 = sourceId ? this.monitors.get(sourceId) : void 0;
|
|
130
|
+
if (sourceId && source && monitor2) {
|
|
131
|
+
void this.retrainSource(sourceId, safeId2, source, monitor2);
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const monitor = this.monitors.get(id);
|
|
136
|
+
const safeId = this.sourceIds.get(id);
|
|
137
|
+
if (monitor && safeId) {
|
|
138
|
+
void this.processState(id, safeId, monitor, state);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
onMessage(message) {
|
|
142
|
+
if (!message.callback) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (message.command === "getHistoryProviders") {
|
|
146
|
+
void this.replyWithHistoryProviders(message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async replyWithHistoryProviders(message) {
|
|
150
|
+
var _a;
|
|
151
|
+
const sourceId = typeof ((_a = message.message) == null ? void 0 : _a.sourceId) === "string" ? message.message.sourceId.trim() : "";
|
|
152
|
+
const sources = await this.historySourcesForAdmin(sourceId);
|
|
153
|
+
this.sendTo(
|
|
154
|
+
message.from,
|
|
155
|
+
message.command,
|
|
156
|
+
sources.map((source) => ({ value: source.instanceId, label: source.instanceId })),
|
|
157
|
+
message.callback
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
async historySourcesForAdmin(sourceId) {
|
|
161
|
+
if (!sourceId) {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return await this.historySourceResolver.resolve(sourceId);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
this.log.warn(`Could not resolve history sources for ${sourceId}: ${error.message}`);
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async processState(id, safeId, monitor, state) {
|
|
172
|
+
try {
|
|
173
|
+
const result = monitor.observe(
|
|
174
|
+
state.val,
|
|
175
|
+
typeof state.ts === "number" ? state.ts : Date.now(),
|
|
176
|
+
this.contextKey(id)
|
|
177
|
+
);
|
|
178
|
+
if (!result) {
|
|
179
|
+
if (!this.invalidValueWarnings.has(id)) {
|
|
180
|
+
this.invalidValueWarnings.add(id);
|
|
181
|
+
this.log.warn(`Ignoring invalid non-numerical value from ${id}`);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.invalidValueWarnings.delete(id);
|
|
186
|
+
await this.writeResult(safeId, result);
|
|
187
|
+
this.schedulePersistence();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
this.log.error(`Could not process source state ${id}: ${error.message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async registerContexts(sourceId, source) {
|
|
193
|
+
var _a, _b, _c;
|
|
194
|
+
if (!source.enableContext) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (((_b = (_a = source.contextStates) == null ? void 0 : _a.length) != null ? _b : 0) > 3) {
|
|
198
|
+
this.warnContext(`Only the first 3 context states are used for ${sourceId}`);
|
|
199
|
+
}
|
|
200
|
+
for (const context of ((_c = source.contextStates) != null ? _c : []).filter((item) => {
|
|
201
|
+
var _a2;
|
|
202
|
+
return (_a2 = item.id) == null ? void 0 : _a2.trim();
|
|
203
|
+
}).slice(0, 3)) {
|
|
204
|
+
const id = context.id.trim();
|
|
205
|
+
let sources = this.contextSources.get(id);
|
|
206
|
+
if (!sources) {
|
|
207
|
+
sources = /* @__PURE__ */ new Set();
|
|
208
|
+
this.contextSources.set(id, sources);
|
|
209
|
+
this.subscribeForeignStates(id);
|
|
210
|
+
}
|
|
211
|
+
sources.add(sourceId);
|
|
212
|
+
const state = await this.getForeignStateAsync(id);
|
|
213
|
+
if (state) {
|
|
214
|
+
let values = this.contextValues.get(sourceId);
|
|
215
|
+
if (!values) {
|
|
216
|
+
values = /* @__PURE__ */ new Map();
|
|
217
|
+
this.contextValues.set(sourceId, values);
|
|
218
|
+
}
|
|
219
|
+
values.set(id, state.val);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
contextKey(sourceId) {
|
|
224
|
+
var _a;
|
|
225
|
+
const safeId = this.sourceIds.get(sourceId);
|
|
226
|
+
const source = safeId ? this.sources.get(safeId) : void 0;
|
|
227
|
+
if (!(source == null ? void 0 : source.enableContext)) {
|
|
228
|
+
return void 0;
|
|
229
|
+
}
|
|
230
|
+
const values = this.contextValues.get(sourceId);
|
|
231
|
+
const parts = ((_a = source.contextStates) != null ? _a : []).filter((item) => {
|
|
232
|
+
var _a2;
|
|
233
|
+
return (_a2 = item.id) == null ? void 0 : _a2.trim();
|
|
234
|
+
}).slice(0, 3).map((context) => {
|
|
235
|
+
const value = values == null ? void 0 : values.get(context.id.trim());
|
|
236
|
+
if (typeof value === "string" && value.trim() && value.length <= 96) {
|
|
237
|
+
const categoryKey = `${sourceId}\0${context.id}`;
|
|
238
|
+
let categories = this.contextCategories.get(categoryKey);
|
|
239
|
+
if (!categories) {
|
|
240
|
+
categories = /* @__PURE__ */ new Set();
|
|
241
|
+
this.contextCategories.set(categoryKey, categories);
|
|
242
|
+
}
|
|
243
|
+
if (!categories.has(value) && categories.size >= 16) {
|
|
244
|
+
this.warnContext(`Ignoring additional categorical values for ${context.id}; limit is 16`);
|
|
245
|
+
return void 0;
|
|
246
|
+
}
|
|
247
|
+
categories.add(value);
|
|
248
|
+
}
|
|
249
|
+
return (0, import_context_value.createContextPart)(context.id, value, context.bucketWidth);
|
|
250
|
+
});
|
|
251
|
+
return parts.length > 0 && parts.every(Boolean) ? parts.join("|") : void 0;
|
|
252
|
+
}
|
|
253
|
+
warnContext(message) {
|
|
254
|
+
if (!this.contextWarnings.has(message)) {
|
|
255
|
+
this.contextWarnings.add(message);
|
|
256
|
+
this.log.warn(message);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async ensureModelState() {
|
|
260
|
+
await this.setObjectNotExistsAsync(MODEL_STATE_ID, {
|
|
261
|
+
type: "state",
|
|
262
|
+
common: { name: "Persisted models", type: "string", role: "json", read: true, write: false, def: "" },
|
|
263
|
+
native: {}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
async cleanupStaleSources(configuredSourceIds, storedModels) {
|
|
267
|
+
try {
|
|
268
|
+
const result = await (0, import_source_cleanup.cleanupStaleSources)(
|
|
269
|
+
{
|
|
270
|
+
listGeneratedSourceDevices: async () => {
|
|
271
|
+
const devices = await this.getForeignObjectsAsync(`${this.namespace}.sources.*`, "device");
|
|
272
|
+
return Object.entries(devices).map(([id, device]) => {
|
|
273
|
+
var _a;
|
|
274
|
+
return {
|
|
275
|
+
id,
|
|
276
|
+
sourceId: typeof ((_a = device.native) == null ? void 0 : _a.sourceId) === "string" ? device.native.sourceId : void 0
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
deleteGeneratedSourceTree: (relativeId) => this.delObjectAsync(relativeId, { recursive: true })
|
|
281
|
+
},
|
|
282
|
+
this.namespace,
|
|
283
|
+
configuredSourceIds,
|
|
284
|
+
storedModels,
|
|
285
|
+
this.createSourceId.bind(this)
|
|
286
|
+
);
|
|
287
|
+
if (result.modelsChanged) {
|
|
288
|
+
await this.setStateAsync(MODEL_STATE_ID, { val: JSON.stringify(result.models), ack: true });
|
|
289
|
+
}
|
|
290
|
+
for (const sourceId of result.removedSourceIds) {
|
|
291
|
+
this.log.info(`Removed generated anomaly objects and model for deleted source: ${sourceId}`);
|
|
292
|
+
}
|
|
293
|
+
return result.models;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
this.log.error(`Could not clean up stale monitored sources: ${error.message}`);
|
|
296
|
+
return storedModels;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async ensureSourceObjects(safeId, source, unit) {
|
|
300
|
+
const base = `sources.${safeId}`;
|
|
301
|
+
await this.setObjectNotExistsAsync(base, {
|
|
302
|
+
type: "device",
|
|
303
|
+
common: { name: source.name || source.id },
|
|
304
|
+
native: { sourceId: source.id }
|
|
305
|
+
});
|
|
306
|
+
await this.setObjectNotExistsAsync(`${base}.analysis`, {
|
|
307
|
+
type: "channel",
|
|
308
|
+
common: { name: "Anomaly analysis" },
|
|
309
|
+
native: {}
|
|
310
|
+
});
|
|
311
|
+
const numberState = (name, role, extra = {}) => ({
|
|
312
|
+
type: "state",
|
|
313
|
+
common: { name, type: "number", role, read: true, write: false, ...extra },
|
|
314
|
+
native: {}
|
|
315
|
+
});
|
|
316
|
+
await this.setObjectNotExistsAsync(
|
|
317
|
+
`${base}.analysis.actual`,
|
|
318
|
+
numberState("Actual value", "value", unit ? { unit } : {})
|
|
319
|
+
);
|
|
320
|
+
await this.setObjectNotExistsAsync(
|
|
321
|
+
`${base}.analysis.expected`,
|
|
322
|
+
numberState("Expected value", "value", unit ? { unit } : {})
|
|
323
|
+
);
|
|
324
|
+
await this.setObjectNotExistsAsync(
|
|
325
|
+
`${base}.analysis.deviation`,
|
|
326
|
+
numberState("Deviation", "value", unit ? { unit } : {})
|
|
327
|
+
);
|
|
328
|
+
await this.setObjectNotExistsAsync(
|
|
329
|
+
`${base}.analysis.score`,
|
|
330
|
+
numberState("Anomaly score", "value", { min: 0, max: 100, unit: "%" })
|
|
331
|
+
);
|
|
332
|
+
await this.setObjectNotExistsAsync(
|
|
333
|
+
`${base}.analysis.sampleCount`,
|
|
334
|
+
numberState("Learned sample count", "value")
|
|
335
|
+
);
|
|
336
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.baselineScope`, {
|
|
337
|
+
type: "state",
|
|
338
|
+
common: { name: "Active baseline scope", type: "string", role: "info.status", read: true, write: false },
|
|
339
|
+
native: {}
|
|
340
|
+
});
|
|
341
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.activeContext`, {
|
|
342
|
+
type: "state",
|
|
343
|
+
common: { name: "Active context", type: "string", role: "text", read: true, write: false },
|
|
344
|
+
native: {}
|
|
345
|
+
});
|
|
346
|
+
await this.setObjectNotExistsAsync(
|
|
347
|
+
`${base}.analysis.baselineSampleCount`,
|
|
348
|
+
numberState("Active baseline sample count", "value")
|
|
349
|
+
);
|
|
350
|
+
await this.setObjectNotExistsAsync(
|
|
351
|
+
`${base}.analysis.contextSampleCount`,
|
|
352
|
+
numberState("Active context sample count", "value")
|
|
353
|
+
);
|
|
354
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.detected`, {
|
|
355
|
+
type: "state",
|
|
356
|
+
common: {
|
|
357
|
+
name: "Persistent anomaly detected",
|
|
358
|
+
type: "boolean",
|
|
359
|
+
role: "indicator",
|
|
360
|
+
read: true,
|
|
361
|
+
write: false,
|
|
362
|
+
def: false
|
|
363
|
+
},
|
|
364
|
+
native: {}
|
|
365
|
+
});
|
|
366
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.status`, {
|
|
367
|
+
type: "state",
|
|
368
|
+
common: { name: "Model status", type: "string", role: "info.status", read: true, write: false },
|
|
369
|
+
native: {}
|
|
370
|
+
});
|
|
371
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.reason`, {
|
|
372
|
+
type: "state",
|
|
373
|
+
common: { name: "Anomaly reason", type: "string", role: "text", read: true, write: false },
|
|
374
|
+
native: {}
|
|
375
|
+
});
|
|
376
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.lastAnomaly`, {
|
|
377
|
+
type: "state",
|
|
378
|
+
common: { name: "Last high-score anomaly", type: "string", role: "date", read: true, write: false },
|
|
379
|
+
native: {}
|
|
380
|
+
});
|
|
381
|
+
await this.setObjectNotExistsAsync(`${base}.retrain`, {
|
|
382
|
+
type: "state",
|
|
383
|
+
common: { name: "Retrain model", type: "boolean", role: "button", read: false, write: true, def: false },
|
|
384
|
+
native: {}
|
|
385
|
+
});
|
|
386
|
+
await this.setObjectNotExistsAsync(`${base}.analysis.bootstrapStatus`, {
|
|
387
|
+
type: "state",
|
|
388
|
+
common: {
|
|
389
|
+
name: "Historical bootstrap status",
|
|
390
|
+
type: "string",
|
|
391
|
+
role: "info.status",
|
|
392
|
+
read: true,
|
|
393
|
+
write: false
|
|
394
|
+
},
|
|
395
|
+
native: {}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
shouldBootstrap(source, monitor) {
|
|
399
|
+
var _a;
|
|
400
|
+
return source.initialTraining === "history" && !!((_a = source.historyInstance) == null ? void 0 : _a.trim()) && (!monitor.hasSufficientData || !monitor.bootstrapMatches(this.bootstrapConfigKey(source)));
|
|
401
|
+
}
|
|
402
|
+
async bootstrapSource(sourceId, safeId, source, monitor) {
|
|
403
|
+
var _a, _b, _c, _d;
|
|
404
|
+
const providerName = source.historyInstance.trim();
|
|
405
|
+
const end = Date.now();
|
|
406
|
+
const start = end - Math.max(7, (_a = source.trainingDays) != null ? _a : 30) * 24 * 60 * 60 * 1e3;
|
|
407
|
+
await this.setStateAsync(`sources.${safeId}.analysis.status`, { val: "importingHistory", ack: true });
|
|
408
|
+
await this.setStateAsync(`sources.${safeId}.analysis.bootstrapStatus`, {
|
|
409
|
+
val: "Importing historical data",
|
|
410
|
+
ack: true
|
|
411
|
+
});
|
|
412
|
+
try {
|
|
413
|
+
const historySource = await this.resolveConfiguredHistorySource(sourceId, providerName);
|
|
414
|
+
await this.setStateAsync(`sources.${safeId}.analysis.bootstrapStatus`, {
|
|
415
|
+
val: `Importing history from ${historySource.sourceId} via ${providerName}`,
|
|
416
|
+
ack: true
|
|
417
|
+
});
|
|
418
|
+
const provider = new IoBrokerHistoryProvider(this, providerName);
|
|
419
|
+
const raw = await this.withHistoryTimeout(
|
|
420
|
+
provider.getHistory(historySource.sourceId, start, end, (_b = source.maxHistorySamples) != null ? _b : 1e3)
|
|
421
|
+
);
|
|
422
|
+
const samples = (0, import_history_provider.sanitizeHistory)(raw, (_c = source.maxHistorySamples) != null ? _c : 1e3);
|
|
423
|
+
if (samples.length === 0) {
|
|
424
|
+
throw new Error("History provider returned no valid samples");
|
|
425
|
+
}
|
|
426
|
+
monitor.reset();
|
|
427
|
+
const imported = monitor.bootstrapFromHistory(
|
|
428
|
+
samples,
|
|
429
|
+
providerName,
|
|
430
|
+
historySource.sourceId,
|
|
431
|
+
start,
|
|
432
|
+
end,
|
|
433
|
+
this.bootstrapConfigKey(source)
|
|
434
|
+
);
|
|
435
|
+
if (imported === 0) {
|
|
436
|
+
throw new Error("No usable samples remained after historical outlier filtering");
|
|
437
|
+
}
|
|
438
|
+
await this.persistModels();
|
|
439
|
+
await this.setStateAsync(`sources.${safeId}.analysis.sampleCount`, { val: monitor.sampleCount, ack: true });
|
|
440
|
+
const status = monitor.hasSufficientData && ((_d = source.autoStartMonitoringAfterImport) != null ? _d : true) ? "monitoring" : "learning";
|
|
441
|
+
await this.setStateAsync(`sources.${safeId}.analysis.status`, { val: status, ack: true });
|
|
442
|
+
await this.setStateAsync(`sources.${safeId}.analysis.bootstrapStatus`, {
|
|
443
|
+
val: `Historical training completed (${imported} samples)`,
|
|
444
|
+
ack: true
|
|
445
|
+
});
|
|
446
|
+
} catch (error) {
|
|
447
|
+
this.log.warn(`Historical bootstrap failed for ${sourceId}: ${error.message}`);
|
|
448
|
+
await this.persistModels();
|
|
449
|
+
await this.setStateAsync(`sources.${safeId}.analysis.status`, {
|
|
450
|
+
val: monitor.hasSufficientData ? "monitoring" : "learning",
|
|
451
|
+
ack: true
|
|
452
|
+
});
|
|
453
|
+
await this.setStateAsync(`sources.${safeId}.analysis.bootstrapStatus`, {
|
|
454
|
+
val: "Insufficient historical data, continuing live learning",
|
|
455
|
+
ack: true
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async resolveConfiguredHistorySource(sourceId, instanceId) {
|
|
460
|
+
try {
|
|
461
|
+
return (0, import_history_source_resolver.selectHistorySource)(sourceId, instanceId, await this.historySourceResolver.resolve(sourceId));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
this.warnInvalidHistoryConfiguration(error.message);
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
warnInvalidHistoryConfiguration(message) {
|
|
468
|
+
if (!this.invalidHistoryConfigurationWarnings.has(message)) {
|
|
469
|
+
this.invalidHistoryConfigurationWarnings.add(message);
|
|
470
|
+
this.log.warn(message);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
async retrainSource(sourceId, safeId, source, monitor) {
|
|
474
|
+
var _a;
|
|
475
|
+
try {
|
|
476
|
+
monitor.reset();
|
|
477
|
+
if (source.initialTraining === "history" && ((_a = source.historyInstance) == null ? void 0 : _a.trim())) {
|
|
478
|
+
await this.bootstrapSource(sourceId, safeId, source, monitor);
|
|
479
|
+
} else {
|
|
480
|
+
await this.setStateAsync(`sources.${safeId}.analysis.status`, { val: "learning", ack: true });
|
|
481
|
+
await this.setStateAsync(`sources.${safeId}.analysis.bootstrapStatus`, {
|
|
482
|
+
val: "Model reset; learning live values",
|
|
483
|
+
ack: true
|
|
484
|
+
});
|
|
485
|
+
await this.persistModels();
|
|
486
|
+
}
|
|
487
|
+
} finally {
|
|
488
|
+
await this.setStateAsync(`sources.${safeId}.retrain`, { val: false, ack: true });
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async runWithConcurrency(tasks, concurrency) {
|
|
492
|
+
const queue = [...tasks];
|
|
493
|
+
await Promise.all(
|
|
494
|
+
Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
|
|
495
|
+
while (queue.length) {
|
|
496
|
+
await queue.shift()();
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
async withHistoryTimeout(operation) {
|
|
502
|
+
return new Promise((resolve, reject) => {
|
|
503
|
+
const timer = this.setTimeout(() => reject(new Error("History request timed out")), HISTORY_TIMEOUT_MS);
|
|
504
|
+
operation.then(
|
|
505
|
+
(value) => {
|
|
506
|
+
this.clearTimeout(timer);
|
|
507
|
+
resolve(value);
|
|
508
|
+
},
|
|
509
|
+
(error) => {
|
|
510
|
+
this.clearTimeout(timer);
|
|
511
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
bootstrapConfigKey(source) {
|
|
517
|
+
var _a, _b, _c;
|
|
518
|
+
return JSON.stringify({
|
|
519
|
+
provider: (_a = source.historyInstance) == null ? void 0 : _a.trim(),
|
|
520
|
+
days: (_b = source.trainingDays) != null ? _b : 30,
|
|
521
|
+
limit: (_c = source.maxHistorySamples) != null ? _c : 1e3
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
async writeResult(safeId, result) {
|
|
525
|
+
var _a, _b;
|
|
526
|
+
const base = `sources.${safeId}.analysis`;
|
|
527
|
+
const values = {
|
|
528
|
+
actual: result.actual,
|
|
529
|
+
score: result.score,
|
|
530
|
+
detected: result.detected,
|
|
531
|
+
status: result.status,
|
|
532
|
+
reason: result.reason,
|
|
533
|
+
sampleCount: result.sampleCount,
|
|
534
|
+
baselineScope: result.baselineScope,
|
|
535
|
+
activeContext: result.activeContext,
|
|
536
|
+
baselineSampleCount: result.baselineSampleCount,
|
|
537
|
+
contextSampleCount: result.contextSampleCount
|
|
538
|
+
};
|
|
539
|
+
values.expected = (_a = result.expected) != null ? _a : null;
|
|
540
|
+
values.deviation = (_b = result.deviation) != null ? _b : null;
|
|
541
|
+
if (result.lastAnomaly !== void 0) {
|
|
542
|
+
values.lastAnomaly = new Date(result.lastAnomaly).toISOString();
|
|
543
|
+
}
|
|
544
|
+
await Promise.all(
|
|
545
|
+
Object.entries(values).map(
|
|
546
|
+
([key, value]) => this.setStateAsync(`${base}.${key}`, { val: value, ack: true })
|
|
547
|
+
)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
schedulePersistence() {
|
|
551
|
+
if (this.persistTimer) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.persistTimer = this.setTimeout(() => {
|
|
555
|
+
this.persistTimer = void 0;
|
|
556
|
+
void this.persistModels();
|
|
557
|
+
}, PERSIST_DELAY_MS);
|
|
558
|
+
}
|
|
559
|
+
async persistModels() {
|
|
560
|
+
try {
|
|
561
|
+
const models = Object.fromEntries(
|
|
562
|
+
[...this.monitors].map(([sourceId, monitor]) => [
|
|
563
|
+
this.sourceIds.get(sourceId),
|
|
564
|
+
{ ...monitor.toJSON(), configuredSourceId: sourceId }
|
|
565
|
+
])
|
|
566
|
+
);
|
|
567
|
+
await this.setStateAsync(MODEL_STATE_ID, { val: JSON.stringify(models), ack: true });
|
|
568
|
+
} catch (error) {
|
|
569
|
+
this.log.error(`Could not persist anomaly models: ${error.message}`);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
createSourceId(sourceId) {
|
|
573
|
+
const normalized = sourceId.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "source";
|
|
574
|
+
const hash = [...sourceId].reduce((value, character) => value * 31 + character.charCodeAt(0) >>> 0, 0).toString(36);
|
|
575
|
+
return `${normalized}_${hash}`;
|
|
576
|
+
}
|
|
577
|
+
onUnload(callback) {
|
|
578
|
+
if (this.persistTimer) {
|
|
579
|
+
this.clearTimeout(this.persistTimer);
|
|
580
|
+
}
|
|
581
|
+
void this.persistModels().finally(callback);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (require.main !== module) {
|
|
585
|
+
module.exports = (options) => new AnomalyDetection(options);
|
|
586
|
+
} else {
|
|
587
|
+
(() => new AnomalyDetection())();
|
|
588
|
+
}
|
|
589
|
+
//# sourceMappingURL=main.js.map
|