iobroker.ai-usage 0.4.0 → 0.6.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/README.md +31 -30
- package/admin/custom/assets/Components-52mNLgTp.js +1 -0
- package/admin/custom/assets/ConfigPanel-Balb6rXz.js +1 -0
- package/admin/custom/assets/index-CJLGptkU.js +1 -0
- package/admin/custom/assets/{mf-entry-bootstrap-0-eec08a33.js → mf-entry-bootstrap-0-fadc1b6e.js} +1 -1
- package/admin/custom/assets/virtualExposes-B7ft9DTx.js +1 -0
- package/admin/custom/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-CP4YA3u3.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__AiUsageComponentSet__customComponents_js-DblFyTyg.js} +1 -1
- package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DpYtjEm8.js +2 -0
- package/admin/custom/assets/{virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-CEfffc0D.js → virtual_mf-exposes___mfe_internal__AiUsageComponentSet__customComponents_js-B9kw23ao.js} +2 -2
- package/admin/custom/customComponents.js +1 -1
- package/admin/custom/i18n/de.json +4 -1
- package/admin/custom/i18n/en.json +4 -1
- package/admin/custom/i18n/es.json +4 -1
- package/admin/custom/i18n/fr.json +4 -1
- package/admin/custom/i18n/it.json +4 -1
- package/admin/custom/i18n/nl.json +4 -1
- package/admin/custom/i18n/pl.json +4 -1
- package/admin/custom/i18n/pt.json +4 -1
- package/admin/custom/i18n/ru.json +4 -1
- package/admin/custom/i18n/uk.json +4 -1
- package/admin/custom/i18n/zh-cn.json +4 -1
- package/admin/custom/mf-manifest.json +1 -1
- package/build/lib/poll-engine.js +66 -29
- package/build/lib/poll-engine.js.map +2 -2
- package/build/lib/snapshot-tree.js.map +2 -2
- package/build/lib/totals.js.map +1 -1
- package/build/main.js +35 -34
- package/build/main.js.map +2 -2
- package/io-package.json +27 -1
- package/package.json +1 -1
- package/admin/custom/assets/Components-Bq1fGAd7.js +0 -1
- package/admin/custom/assets/ConfigPanel-DGMF4_qT.js +0 -1
- package/admin/custom/assets/index-CKngiVu3.js +0 -1
- package/admin/custom/assets/virtualExposes-Dc9myzsk.js +0 -1
- package/admin/custom/assets/virtual_mf-exposes-ssr___mfe_internal__AiUsageComponentSet__customComponents_js-DlNU_Z38.js +0 -2
package/build/lib/poll-engine.js
CHANGED
|
@@ -54,6 +54,7 @@ class PollEngine {
|
|
|
54
54
|
authNotified: false,
|
|
55
55
|
serviceOnline: false,
|
|
56
56
|
state: "no-connection",
|
|
57
|
+
error: "waiting for the first query",
|
|
57
58
|
createdObjects: /* @__PURE__ */ new Set(),
|
|
58
59
|
written: /* @__PURE__ */ new Map()
|
|
59
60
|
});
|
|
@@ -127,6 +128,7 @@ class PollEngine {
|
|
|
127
128
|
runtime.status.reachable = true;
|
|
128
129
|
runtime.serviceOnline = true;
|
|
129
130
|
runtime.state = "ok";
|
|
131
|
+
runtime.error = "";
|
|
130
132
|
await this.applySnapshot(runtime, snapshot);
|
|
131
133
|
} catch (e) {
|
|
132
134
|
this.handleFailure(runtime, e);
|
|
@@ -188,6 +190,7 @@ class PollEngine {
|
|
|
188
190
|
runtime.status.reachable = false;
|
|
189
191
|
runtime.serviceOnline = true;
|
|
190
192
|
runtime.state = "unauthorized";
|
|
193
|
+
runtime.error = `Sign-in rejected \u2014 ${message}`;
|
|
191
194
|
if (!runtime.authNotified) {
|
|
192
195
|
runtime.authNotified = true;
|
|
193
196
|
const text = `${config.name}: credentials rejected \u2014 ${message}`;
|
|
@@ -199,6 +202,7 @@ class PollEngine {
|
|
|
199
202
|
if (error instanceof import_provider.FetchError && error.kind === "rate-limit") {
|
|
200
203
|
runtime.serviceOnline = true;
|
|
201
204
|
runtime.state = "rate-limited";
|
|
205
|
+
runtime.error = `Throttled by the provider \u2014 retrying in ${Math.round(runtime.backoffMs / 6e4)} min, last values kept`;
|
|
202
206
|
runtime.skipUntil = this.deps.now() + runtime.backoffMs;
|
|
203
207
|
this.deps.log.warn(
|
|
204
208
|
`${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 6e4)} min, keeping last values`
|
|
@@ -214,6 +218,7 @@ class PollEngine {
|
|
|
214
218
|
}
|
|
215
219
|
runtime.serviceOnline = false;
|
|
216
220
|
runtime.state = "service-down";
|
|
221
|
+
runtime.error = `The AI service reports a fault \u2014 ${message}`;
|
|
217
222
|
return;
|
|
218
223
|
}
|
|
219
224
|
runtime.failCount++;
|
|
@@ -225,6 +230,7 @@ class PollEngine {
|
|
|
225
230
|
}
|
|
226
231
|
runtime.serviceOnline = false;
|
|
227
232
|
runtime.state = "no-connection";
|
|
233
|
+
runtime.error = `Not reachable after ${runtime.failCount} attempts \u2014 ${message}`;
|
|
228
234
|
}
|
|
229
235
|
}
|
|
230
236
|
/**
|
|
@@ -234,13 +240,29 @@ class PollEngine {
|
|
|
234
240
|
*/
|
|
235
241
|
writeAccountInfo(runtime) {
|
|
236
242
|
const { config } = runtime;
|
|
237
|
-
this.
|
|
238
|
-
this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);
|
|
239
|
-
this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);
|
|
243
|
+
this.writeAccountStatus(runtime);
|
|
240
244
|
if (runtime.status.reachable) {
|
|
241
245
|
this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());
|
|
242
246
|
}
|
|
243
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Write the two status states.
|
|
250
|
+
*
|
|
251
|
+
* `unreach` drives the connection icon the admin draws next to the account, so it
|
|
252
|
+
* has to mean what a user reads into that icon: green while the account delivers.
|
|
253
|
+
* A throttle keeps the last values and the service is fine, so it stays green and
|
|
254
|
+
* only fills the error text; a dead sign-in, a broken service or no connection at
|
|
255
|
+
* all turn it off. Both states are written on change only — an indicator rewritten
|
|
256
|
+
* every cycle floods the history and hides the real transition.
|
|
257
|
+
*
|
|
258
|
+
* @param runtime the account's runtime
|
|
259
|
+
*/
|
|
260
|
+
writeAccountStatus(runtime) {
|
|
261
|
+
const { config } = runtime;
|
|
262
|
+
const delivering = runtime.state === "ok" || runtime.state === "rate-limited";
|
|
263
|
+
this.setIfChanged(runtime, `${config.id}.info.unreach`, !delivering);
|
|
264
|
+
this.setIfChanged(runtime, `${config.id}.info.error`, runtime.error);
|
|
265
|
+
}
|
|
244
266
|
/**
|
|
245
267
|
* Write a state only when its value actually changed since the last write.
|
|
246
268
|
*
|
|
@@ -277,34 +299,40 @@ class PollEngine {
|
|
|
277
299
|
async createAccountSkeleton(runtime) {
|
|
278
300
|
const { config } = runtime;
|
|
279
301
|
const defs = [
|
|
280
|
-
{ id: config.id, type: "device", common: { name: `${config.name} (${config.provider})` } },
|
|
281
|
-
{ id: `${config.id}.info`, type: "channel", common: { name: "Info" } },
|
|
282
302
|
{
|
|
283
|
-
id:
|
|
284
|
-
type: "
|
|
285
|
-
common: {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
},
|
|
292
|
-
{
|
|
293
|
-
id: `${config.id}.info.serviceOnline`,
|
|
294
|
-
type: "state",
|
|
295
|
-
common: { name: "AI service online", type: "boolean", role: "indicator.connected", read: true, write: false }
|
|
303
|
+
id: config.id,
|
|
304
|
+
type: "device",
|
|
305
|
+
common: {
|
|
306
|
+
name: `${config.name} (${config.provider})`,
|
|
307
|
+
// The admin's object tree draws its connection icon from this link and
|
|
308
|
+
// from nothing else — govee, beszel, homewizard and nut2 all do the same.
|
|
309
|
+
statusStates: { offlineId: "info.unreach" }
|
|
310
|
+
}
|
|
296
311
|
},
|
|
312
|
+
{ id: `${config.id}.info`, type: "channel", common: { name: "Info" } },
|
|
297
313
|
{
|
|
298
|
-
|
|
314
|
+
// The two slots ioBroker itself provides for this — measured against
|
|
315
|
+
// @iobroker/type-detector 6.0.0: `unreach` is the offline marker every
|
|
316
|
+
// device type carries (`indicator.reachable` is deprecated there), and
|
|
317
|
+
// `indicator.error` is the standard home for the reason.
|
|
318
|
+
id: `${config.id}.info.unreach`,
|
|
299
319
|
type: "state",
|
|
300
320
|
common: {
|
|
301
|
-
name: "
|
|
302
|
-
type: "
|
|
303
|
-
role: "
|
|
321
|
+
name: "AI service not reachable",
|
|
322
|
+
type: "boolean",
|
|
323
|
+
role: "indicator.maintenance.unreach",
|
|
304
324
|
read: true,
|
|
305
325
|
write: false
|
|
306
326
|
}
|
|
307
327
|
},
|
|
328
|
+
{
|
|
329
|
+
// NOT `indicator.error`: the two official sources disagree — the type-detector
|
|
330
|
+
// lists that role as a String, the repochecker's validity whitelist allows
|
|
331
|
+
// boolean only (E1009). Validity wins, so the message rides on `text`.
|
|
332
|
+
id: `${config.id}.info.error`,
|
|
333
|
+
type: "state",
|
|
334
|
+
common: { name: "Last error", type: "string", role: "text", read: true, write: false }
|
|
335
|
+
},
|
|
308
336
|
{
|
|
309
337
|
id: `${config.id}.info.lastUpdate`,
|
|
310
338
|
type: "state",
|
|
@@ -318,17 +346,20 @@ class PollEngine {
|
|
|
318
346
|
{
|
|
319
347
|
id: `${config.id}.limitReached`,
|
|
320
348
|
type: "state",
|
|
321
|
-
common: {
|
|
349
|
+
common: {
|
|
350
|
+
name: "A plan-wide limit window is full",
|
|
351
|
+
type: "boolean",
|
|
352
|
+
role: "indicator",
|
|
353
|
+
read: true,
|
|
354
|
+
write: false
|
|
355
|
+
}
|
|
322
356
|
}
|
|
323
357
|
];
|
|
324
358
|
for (const def of defs) {
|
|
325
359
|
await this.deps.upsertObject(def);
|
|
326
360
|
runtime.createdObjects.add(def.id);
|
|
327
361
|
}
|
|
328
|
-
this.
|
|
329
|
-
this.deps.setState(`${config.id}.info.reachable`, false);
|
|
330
|
-
this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);
|
|
331
|
-
this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);
|
|
362
|
+
this.writeAccountStatus(runtime);
|
|
332
363
|
}
|
|
333
364
|
/** The totals skeleton (channel + states). */
|
|
334
365
|
async createTotalsSkeleton() {
|
|
@@ -374,7 +405,7 @@ class PollEngine {
|
|
|
374
405
|
id: "total.maxLimitPercent",
|
|
375
406
|
type: "state",
|
|
376
407
|
common: {
|
|
377
|
-
name: "Highest
|
|
408
|
+
name: "Highest plan-wide utilisation of any account",
|
|
378
409
|
type: "number",
|
|
379
410
|
role: "value",
|
|
380
411
|
read: true,
|
|
@@ -396,7 +427,13 @@ class PollEngine {
|
|
|
396
427
|
{
|
|
397
428
|
id: "total.limitReached",
|
|
398
429
|
type: "state",
|
|
399
|
-
common: {
|
|
430
|
+
common: {
|
|
431
|
+
name: "Any plan-wide limit window full",
|
|
432
|
+
type: "boolean",
|
|
433
|
+
role: "indicator",
|
|
434
|
+
read: true,
|
|
435
|
+
write: false
|
|
436
|
+
}
|
|
400
437
|
},
|
|
401
438
|
{
|
|
402
439
|
id: "total.accountsReachable",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/poll-engine.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { limitingWindow, mapSnapshot, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/**\n * What the adapter currently knows about one account, in one word.\n *\n * `ok` and `rate-limited` mean the AI service is up and talking to us,\n * `unauthorized` means it is up but rejects our sign-in, `service-down` means the\n * service itself answered with a fault, `no-connection` means we never reached it.\n */\nexport type AccountState = \"ok\" | \"unauthorized\" | \"rate-limited\" | \"service-down\" | \"no-connection\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Write a state value with ack. */\n setState(id: string, value: boolean | number | string): void;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Whether the AI service itself answered on the last attempt. */\n serviceOnline: boolean;\n /** The account's one-word state. */\n state: AccountState;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n /** Last written value per state id \u2014 keeps unchanged values out of the history. */\n written: Map<string, boolean | number | string>;\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, network = tolerant), warn-threshold\n * transitions and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n serviceOnline: false,\n state: \"no-connection\",\n createdObjects: new Set(),\n written: new Map(),\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n await this.writeTotals();\n this.runtimes.forEach((runtime, index) => {\n this.handles.push(\n this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),\n this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /** The account ids the engine drives (for the stale-object cleanup). */\n public get accountIds(): string[] {\n return this.runtimes.map(runtime => runtime.config.id);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n runtime.serviceOnline = true;\n runtime.state = \"ok\";\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n await this.writeTotals();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, config.name, config.provider, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n // Only PLAN-WIDE windows speak for the account \u2014 a per-model bucket at 100 %\n // must not read as \"this AI is full\" (krobi 2026-08-26).\n const driver = limitingWindow(snapshot);\n const percent = driver?.percent ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n this.deps.setState(`${config.id}.warning`, runtime.status.warning);\n this.deps.setState(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n // Always name the window: \"usage at 100 %\" without it was misleading whenever\n // several windows existed.\n const window = driver ? `${driver.label} ` : \"\";\n const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Classify a fetch failure.\n *\n * The split matters for the online indicator: with `auth` and `rate-limit` the AI\n * service ANSWERED \u2014 it is online, it just said no \u2014 so only our own access is\n * broken. `service` means the service answered with a fault of its own and is\n * reported as down at once (it told us, that is not a flake). A `network` failure\n * is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so\n * a single hiccup does not make the indicator flap.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n runtime.serviceOnline = true;\n runtime.state = \"unauthorized\";\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.serviceOnline = true;\n runtime.state = \"rate-limited\";\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n if (error instanceof FetchError && error.kind === \"service\") {\n runtime.status.reachable = false;\n runtime.failCount = 0;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"service-down\";\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"no-connection\";\n }\n }\n\n /**\n * Write one account's info states (data received, service online, state, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n this.deps.setState(`${config.id}.info.reachable`, runtime.status.reachable);\n // Written only on change: an indicator rewritten every cycle floods the history\n // and makes a real transition impossible to spot.\n this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);\n this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /**\n * Write a state only when its value actually changed since the last write.\n *\n * @param runtime the account's runtime (holds the last-written cache)\n * @param id the state id\n * @param value the value\n */\n private setIfChanged(runtime: AccountRuntime, id: string, value: boolean | number | string): void {\n if (runtime.written.get(id) === value) {\n return;\n }\n runtime.written.set(id, value);\n this.deps.setState(id, value);\n }\n\n /** Recompute and write the totals + info.connection. */\n private async writeTotals(): Promise<void> {\n const totals = computeTotals(this.runtimes.map(runtime => runtime.status));\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n this.deps.setState(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.deps.setState(\"info.connection\", totals.accountsReachable > 0);\n return Promise.resolve();\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n { id: config.id, type: \"device\", common: { name: `${config.name} (${config.provider})` } },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n id: `${config.id}.info.provider`,\n type: \"state\",\n common: { name: \"Provider\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.reachable`,\n type: \"state\",\n common: { name: \"Reachable\", type: \"boolean\", role: \"indicator.reachable\", read: true, write: false },\n },\n {\n id: `${config.id}.info.serviceOnline`,\n type: \"state\",\n common: { name: \"AI service online\", type: \"boolean\", role: \"indicator.connected\", read: true, write: false },\n },\n {\n id: `${config.id}.info.state`,\n type: \"state\",\n common: {\n name: \"State (ok, unauthorized, rate-limited, service-down, no-connection)\",\n type: \"string\",\n role: \"text\",\n read: true,\n write: false,\n },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: { name: \"A limit window is full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n this.deps.setState(`${config.id}.info.provider`, config.provider);\n this.deps.setState(`${config.id}.info.reachable`, false);\n this.setIfChanged(runtime, `${config.id}.info.serviceOnline`, runtime.serviceOnline);\n this.setIfChanged(runtime, `${config.id}.info.state`, runtime.state);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest limit utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: { name: \"Any limit window full\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,sBAAmE;AACnE,2BAA4D;AAC5D,oBAAkD;AAYlD,MAAM,uBAAuB;AAE7B,MAAM,mBAAmB,KAAK,KAAK;AAEnC,MAAM,iBAAiB,KAAK,KAAK;AAEjC,MAAM,aAAa;
|
|
4
|
+
"sourcesContent": ["import type { AccountConfig } from \"./pure-helpers\";\nimport { FetchError, type UsageProvider, type UsageSnapshot } from \"./provider\";\nimport { limitingWindow, mapSnapshot, type ObjectDef } from \"./snapshot-tree\";\nimport { computeTotals, type AccountStatus } from \"./totals\";\n\n/**\n * What the adapter currently knows about one account, in one word.\n *\n * `ok` and `rate-limited` mean the AI service is up and talking to us,\n * `unauthorized` means it is up but rejects our sign-in, `service-down` means the\n * service itself answered with a fault, `no-connection` means we never reached it.\n */\nexport type AccountState = \"ok\" | \"unauthorized\" | \"rate-limited\" | \"service-down\" | \"no-connection\";\n\n/** Consecutive network failures after which an account is judged unreachable. */\nconst MAX_NETWORK_FAILURES = 3;\n/** First backoff after a rate-limit answer (ms); doubles per repeat. */\nconst BACKOFF_START_MS = 10 * 60 * 1000;\n/** Backoff ceiling (ms). */\nconst BACKOFF_MAX_MS = 60 * 60 * 1000;\n/** Stagger between the accounts' first polls (ms) so they never fire in one burst. */\nconst STAGGER_MS = 3000;\n\n/** The adapter callbacks the engine drives \u2014 narrow, so tests need no adapter mock. */\nexport interface EngineDeps {\n /** Create or update an object. */\n upsertObject(def: ObjectDef): Promise<void>;\n /** Write a state value with ack. */\n setState(id: string, value: boolean | number | string): void;\n /** Schedule a repeating callback; returns a cancel handle. */\n schedule(cb: () => void, ms: number): unknown;\n /** Schedule a one-shot callback; returns a cancel handle. */\n scheduleOnce(cb: () => void, ms: number): unknown;\n /** Cancel a handle from schedule/scheduleOnce. */\n cancel(handle: unknown): void;\n /** Current time (ms since epoch) \u2014 injected for tests. */\n now(): number;\n /** Adapter log. */\n log: { debug(m: string): void; info(m: string): void; warn(m: string): void; error(m: string): void };\n /** Raise a user-facing notification (threshold crossing, broken credentials). */\n notify?(accountName: string, message: string): void;\n}\n\n/** One account's runtime state inside the engine. */\ninterface AccountRuntime {\n config: AccountConfig;\n provider: UsageProvider;\n status: AccountStatus;\n /** Consecutive network failures. */\n failCount: number;\n /** Skip polls until this time (rate-limit backoff). */\n skipUntil: number;\n /** Current backoff length (ms). */\n backoffMs: number;\n /** Whether the auth-broken notification has been raised (reset on success). */\n authNotified: boolean;\n /** Whether the AI service itself answered on the last attempt. */\n serviceOnline: boolean;\n /** The account's one-word state. */\n state: AccountState;\n /** Plain-text reason shown in `info.error`; empty while everything works. */\n error: string;\n /** Object ids already created for this account (create-once cache). */\n createdObjects: Set<string>;\n /** Last written value per state id \u2014 keeps unchanged values out of the history. */\n written: Map<string, boolean | number | string>;\n}\n\n/**\n * Drives the polling of all configured accounts: staggered starts, one independent\n * cycle per account, typed failure handling (auth = immediate + one notification,\n * rate-limit = backoff keeping last values, service = reported down at once,\n * network = tolerated three times), warn-threshold transitions on the PLAN-WIDE\n * windows only, and the adapter-wide totals. Pure orchestration \u2014 all IO is injected.\n */\nexport class PollEngine {\n private readonly runtimes: AccountRuntime[] = [];\n private readonly handles: unknown[] = [];\n private stopped = false;\n\n /**\n * @param accounts the validated account configs\n * @param providers each account id's provider (accounts without one are skipped)\n * @param intervalSec the poll interval in seconds\n * @param deps the injected adapter callbacks\n */\n public constructor(\n accounts: readonly AccountConfig[],\n providers: ReadonlyMap<string, UsageProvider>,\n private readonly intervalSec: number,\n private readonly deps: EngineDeps,\n ) {\n for (const config of accounts) {\n const provider = providers.get(config.id);\n if (!provider) {\n deps.log.warn(`${config.name}: provider \"${config.provider}\" is not available \u2014 account skipped`);\n continue;\n }\n this.runtimes.push({\n config,\n provider,\n status: { reachable: false, warning: false },\n failCount: 0,\n skipUntil: 0,\n backoffMs: BACKOFF_START_MS,\n authNotified: false,\n serviceOnline: false,\n state: \"no-connection\",\n error: \"waiting for the first query\",\n createdObjects: new Set(),\n written: new Map(),\n });\n }\n }\n\n /** Create the static per-account and totals objects, then arm the poll cycles. */\n public async start(): Promise<void> {\n for (const runtime of this.runtimes) {\n await this.createAccountSkeleton(runtime);\n }\n await this.createTotalsSkeleton();\n await this.writeTotals();\n this.runtimes.forEach((runtime, index) => {\n this.handles.push(\n this.deps.scheduleOnce(() => void this.pollAccount(runtime), index * STAGGER_MS),\n this.deps.schedule(() => void this.pollAccount(runtime), this.intervalSec * 1000),\n );\n });\n }\n\n /** Cancel every timer. Synchronous \u2014 safe from onUnload. */\n public stop(): void {\n this.stopped = true;\n for (const handle of this.handles) {\n this.deps.cancel(handle);\n }\n this.handles.length = 0;\n }\n\n /** The account ids the engine drives (for the stale-object cleanup). */\n public get accountIds(): string[] {\n return this.runtimes.map(runtime => runtime.config.id);\n }\n\n /**\n * Poll one account immediately, by id. Used after a successful sign-in: waiting\n * up to a full interval there reads as \"the sign-in did not work\".\n *\n * @param accountId the account's object id\n */\n public async pollNow(accountId: string): Promise<void> {\n const runtime = this.runtimes.find(entry => entry.config.id === accountId);\n if (runtime) {\n // A fresh sign-in clears a previous auth failure and any backoff.\n runtime.authNotified = false;\n runtime.skipUntil = 0;\n await this.pollAccount(runtime);\n }\n }\n\n /**\n * Poll one account now (also used by the staggered first run).\n *\n * @param runtime the account's runtime\n */\n private async pollAccount(runtime: AccountRuntime): Promise<void> {\n if (this.stopped) {\n return;\n }\n const { config } = runtime;\n if (this.deps.now() < runtime.skipUntil) {\n this.deps.log.debug(`${config.name}: in rate-limit backoff \u2014 poll skipped`);\n return;\n }\n try {\n const snapshot = await runtime.provider.fetch();\n runtime.failCount = 0;\n runtime.backoffMs = BACKOFF_START_MS;\n runtime.authNotified = false;\n runtime.status.snapshot = snapshot;\n runtime.status.reachable = true;\n runtime.serviceOnline = true;\n runtime.state = \"ok\";\n runtime.error = \"\";\n await this.applySnapshot(runtime, snapshot);\n } catch (e) {\n this.handleFailure(runtime, e);\n }\n this.writeAccountInfo(runtime);\n await this.writeTotals();\n }\n\n /**\n * Write a successful snapshot: upsert new objects (create-once cache), write the\n * values, and run the warn-threshold transition.\n *\n * @param runtime the account's runtime\n * @param snapshot the fetched snapshot\n */\n private async applySnapshot(runtime: AccountRuntime, snapshot: UsageSnapshot): Promise<void> {\n const { config } = runtime;\n const { objects, writes } = mapSnapshot(config.id, config.name, config.provider, snapshot);\n for (const object of objects) {\n if (!runtime.createdObjects.has(object.id)) {\n await this.deps.upsertObject(object);\n runtime.createdObjects.add(object.id);\n }\n }\n for (const write of writes) {\n this.deps.setState(write.id, write.value);\n }\n // Only PLAN-WIDE windows speak for the account \u2014 a per-model bucket at 100 %\n // must not read as \"this AI is full\" (krobi 2026-08-26).\n const driver = limitingWindow(snapshot);\n const percent = driver?.percent ?? 0;\n const wasWarning = runtime.status.warning;\n runtime.status.warning = percent >= config.warnThreshold;\n this.deps.setState(`${config.id}.warning`, runtime.status.warning);\n this.deps.setState(`${config.id}.limitReached`, percent >= 100);\n if (runtime.status.warning && !wasWarning) {\n // Always name the window: \"usage at 100 %\" without it was misleading whenever\n // several windows existed.\n const window = driver ? `${driver.label} ` : \"\";\n const message = `${config.name}: ${window}at ${Math.round(percent)} % (threshold ${config.warnThreshold} %)`;\n this.deps.log.warn(message);\n this.deps.notify?.(config.name, message);\n }\n }\n\n /**\n * Classify a fetch failure.\n *\n * The split matters for the online indicator: with `auth` and `rate-limit` the AI\n * service ANSWERED \u2014 it is online, it just said no \u2014 so only our own access is\n * broken. `service` means the service answered with a fault of its own and is\n * reported as down at once (it told us, that is not a flake). A `network` failure\n * is tolerated MAX_NETWORK_FAILURES times before we call the connection gone, so\n * a single hiccup does not make the indicator flap.\n *\n * @param runtime the account's runtime\n * @param error the thrown error\n */\n private handleFailure(runtime: AccountRuntime, error: unknown): void {\n const { config } = runtime;\n const message = error instanceof Error ? error.message : String(error);\n if (error instanceof FetchError && error.kind === \"auth\") {\n runtime.status.reachable = false;\n runtime.serviceOnline = true;\n runtime.state = \"unauthorized\";\n runtime.error = `Sign-in rejected \u2014 ${message}`;\n if (!runtime.authNotified) {\n runtime.authNotified = true;\n const text = `${config.name}: credentials rejected \u2014 ${message}`;\n this.deps.log.warn(text);\n this.deps.notify?.(config.name, text);\n }\n return;\n }\n if (error instanceof FetchError && error.kind === \"rate-limit\") {\n runtime.serviceOnline = true;\n runtime.state = \"rate-limited\";\n runtime.error = `Throttled by the provider \u2014 retrying in ${Math.round(runtime.backoffMs / 60000)} min, last values kept`;\n runtime.skipUntil = this.deps.now() + runtime.backoffMs;\n this.deps.log.warn(\n `${config.name}: rate-limited \u2014 backing off for ${Math.round(runtime.backoffMs / 60000)} min, keeping last values`,\n );\n runtime.backoffMs = Math.min(BACKOFF_MAX_MS, runtime.backoffMs * 2);\n return;\n }\n if (error instanceof FetchError && error.kind === \"service\") {\n runtime.status.reachable = false;\n runtime.failCount = 0;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: the service reports a fault (${message}) \u2014 values kept`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"service-down\";\n runtime.error = `The AI service reports a fault \u2014 ${message}`;\n return;\n }\n runtime.failCount++;\n this.deps.log.debug(`${config.name}: fetch failed (${message}), attempt ${runtime.failCount}`);\n if (runtime.failCount >= MAX_NETWORK_FAILURES) {\n runtime.status.reachable = false;\n if (runtime.serviceOnline) {\n this.deps.log.warn(`${config.name}: not reachable after ${runtime.failCount} attempts (${message})`);\n }\n runtime.serviceOnline = false;\n runtime.state = \"no-connection\";\n runtime.error = `Not reachable after ${runtime.failCount} attempts \u2014 ${message}`;\n }\n }\n\n /**\n * Write one account's info states (data received, service online, state, last update).\n *\n * @param runtime the account's runtime\n */\n private writeAccountInfo(runtime: AccountRuntime): void {\n const { config } = runtime;\n this.writeAccountStatus(runtime);\n if (runtime.status.reachable) {\n this.deps.setState(`${config.id}.info.lastUpdate`, new Date(this.deps.now()).toISOString());\n }\n }\n\n /**\n * Write the two status states.\n *\n * `unreach` drives the connection icon the admin draws next to the account, so it\n * has to mean what a user reads into that icon: green while the account delivers.\n * A throttle keeps the last values and the service is fine, so it stays green and\n * only fills the error text; a dead sign-in, a broken service or no connection at\n * all turn it off. Both states are written on change only \u2014 an indicator rewritten\n * every cycle floods the history and hides the real transition.\n *\n * @param runtime the account's runtime\n */\n private writeAccountStatus(runtime: AccountRuntime): void {\n const { config } = runtime;\n const delivering = runtime.state === \"ok\" || runtime.state === \"rate-limited\";\n this.setIfChanged(runtime, `${config.id}.info.unreach`, !delivering);\n this.setIfChanged(runtime, `${config.id}.info.error`, runtime.error);\n }\n\n /**\n * Write a state only when its value actually changed since the last write.\n *\n * @param runtime the account's runtime (holds the last-written cache)\n * @param id the state id\n * @param value the value\n */\n private setIfChanged(runtime: AccountRuntime, id: string, value: boolean | number | string): void {\n if (runtime.written.get(id) === value) {\n return;\n }\n runtime.written.set(id, value);\n this.deps.setState(id, value);\n }\n\n /** Recompute and write the totals + info.connection. */\n private async writeTotals(): Promise<void> {\n const totals = computeTotals(this.runtimes.map(runtime => runtime.status));\n this.deps.setState(\"total.costs.today\", totals.costsToday);\n this.deps.setState(\"total.costs.month\", totals.costsMonth);\n this.deps.setState(\"total.costs.projectedMonth\", totals.costsProjectedMonth);\n this.deps.setState(\"total.maxLimitPercent\", totals.maxLimitPercent);\n this.deps.setState(\"total.warningsActive\", totals.warningsActive);\n this.deps.setState(\"total.limitReached\", totals.limitReached);\n this.deps.setState(\"total.accountsReachable\", totals.accountsReachable);\n this.deps.setState(\"total.accounts\", totals.accounts);\n this.deps.setState(\"info.connection\", totals.accountsReachable > 0);\n return Promise.resolve();\n }\n\n /**\n * The static per-account objects that exist regardless of what the source delivers.\n *\n * @param runtime the account's runtime\n */\n private async createAccountSkeleton(runtime: AccountRuntime): Promise<void> {\n const { config } = runtime;\n const defs: ObjectDef[] = [\n {\n id: config.id,\n type: \"device\",\n common: {\n name: `${config.name} (${config.provider})`,\n // The admin's object tree draws its connection icon from this link and\n // from nothing else \u2014 govee, beszel, homewizard and nut2 all do the same.\n statusStates: { offlineId: \"info.unreach\" },\n },\n },\n { id: `${config.id}.info`, type: \"channel\", common: { name: \"Info\" } },\n {\n // The two slots ioBroker itself provides for this \u2014 measured against\n // @iobroker/type-detector 6.0.0: `unreach` is the offline marker every\n // device type carries (`indicator.reachable` is deprecated there), and\n // `indicator.error` is the standard home for the reason.\n id: `${config.id}.info.unreach`,\n type: \"state\",\n common: {\n name: \"AI service not reachable\",\n type: \"boolean\",\n role: \"indicator.maintenance.unreach\",\n read: true,\n write: false,\n },\n },\n {\n // NOT `indicator.error`: the two official sources disagree \u2014 the type-detector\n // lists that role as a String, the repochecker's validity whitelist allows\n // boolean only (E1009). Validity wins, so the message rides on `text`.\n id: `${config.id}.info.error`,\n type: \"state\",\n common: { name: \"Last error\", type: \"string\", role: \"text\", read: true, write: false },\n },\n {\n id: `${config.id}.info.lastUpdate`,\n type: \"state\",\n common: { name: \"Last successful update\", type: \"string\", role: \"date\", read: true, write: false },\n },\n {\n id: `${config.id}.warning`,\n type: \"state\",\n common: { name: \"Above warn threshold\", type: \"boolean\", role: \"indicator\", read: true, write: false },\n },\n {\n id: `${config.id}.limitReached`,\n type: \"state\",\n common: {\n name: \"A plan-wide limit window is full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n runtime.createdObjects.add(def.id);\n }\n this.writeAccountStatus(runtime);\n }\n\n /** The totals skeleton (channel + states). */\n private async createTotalsSkeleton(): Promise<void> {\n const defs: ObjectDef[] = [\n { id: \"total.costs\", type: \"channel\", common: { name: \"Costs (USD accounts)\" } },\n {\n id: \"total.costs.today\",\n type: \"state\",\n common: {\n name: \"Costs today (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.month\",\n type: \"state\",\n common: {\n name: \"Costs this month (all accounts)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.costs.projectedMonth\",\n type: \"state\",\n common: {\n name: \"Costs projected month-end (computed)\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"USD\",\n },\n },\n {\n id: \"total.maxLimitPercent\",\n type: \"state\",\n common: {\n name: \"Highest plan-wide utilisation of any account\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n unit: \"%\",\n },\n },\n {\n id: \"total.warningsActive\",\n type: \"state\",\n common: {\n name: \"Accounts above their warn threshold\",\n type: \"number\",\n role: \"value\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.limitReached\",\n type: \"state\",\n common: {\n name: \"Any plan-wide limit window full\",\n type: \"boolean\",\n role: \"indicator\",\n read: true,\n write: false,\n },\n },\n {\n id: \"total.accountsReachable\",\n type: \"state\",\n common: { name: \"Reachable accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n {\n id: \"total.accounts\",\n type: \"state\",\n common: { name: \"Configured accounts\", type: \"number\", role: \"value\", read: true, write: false },\n },\n ];\n for (const def of defs) {\n await this.deps.upsertObject(def);\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/snapshot-tree.ts"],
|
|
4
|
-
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { sanitizeId } from \"./pure-helpers\";\n\n/** An object to create: id (relative to the instance), type and common. */\nexport interface ObjectDef {\n /** Object id relative to the instance root. */\n id: string;\n /** ioBroker object type. */\n type: \"device\" | \"channel\" | \"state\";\n /** The object's common. */\n common: {\n name: string | Record<string, string>;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n };\n}\n\n/** A state value to write (ack). */\nexport interface StateWrite {\n /** State id relative to the instance root. */\n id: string;\n /** The value. */\n value: boolean | number | string;\n}\n\n/**\n * A read-only state definition plus its value \u2014 the tree builder's working unit.\n *\n * @param id the state id\n * @param name the object name\n * @param type the value type\n * @param role the state role\n * @param value the current value\n * @param unit optional unit\n * @returns the def/value pair\n */\nfunction state(\n id: string,\n name: string,\n type: \"boolean\" | \"number\" | \"string\",\n role: string,\n value: boolean | number | string,\n unit?: string,\n): { def: ObjectDef; write: StateWrite } {\n const common: ObjectDef[\"common\"] = { name, type, role, read: true, write: false };\n if (unit !== undefined) {\n common.unit = unit;\n }\n return { def: { id, type: \"state\", common }, write: { id, value } };\n}\n\n/** The result of mapping one snapshot: objects to upsert (parents first) and values to write. */\nexport interface TreeResult {\n /** Objects in creation order (device, channels, states). */\n objects: ObjectDef[];\n /** State values to write with ack. */\n writes: StateWrite[];\n}\n\n/**\n * Map one account snapshot onto the harmonised object tree: `limits.*`, `credits.*`,\n * `costs.*`, `tokens.*` (+ `models.*`) \u2014 only what the snapshot carries is created,\n * so the tree mirrors exactly what the provider's source delivers.\n *\n * @param accountId the id-safe account id (device node)\n * @param accountName the display name for the device object\n * @param provider the provider kind (device object name suffix)\n * @param snapshot the fetched snapshot\n * @returns objects (parents first) and state writes\n */\nexport function mapSnapshot(\n accountId: string,\n accountName: string,\n provider: string,\n snapshot: UsageSnapshot,\n): TreeResult {\n const objects: ObjectDef[] = [];\n const writes: StateWrite[] = [];\n const add = (pair: { def: ObjectDef; write: StateWrite }): void => {\n objects.push(pair.def);\n writes.push(pair.write);\n };\n const channel = (id: string, name: string): void => {\n objects.push({ id, type: \"channel\", common: { name } });\n };\n\n objects.push({ id: accountId, type: \"device\", common: { name: `${accountName} (${provider})` } });\n\n if (snapshot.limits && snapshot.limits.length > 0) {\n channel(`${accountId}.limits`, \"Limit windows\");\n for (const limit of snapshot.limits) {\n const windowId = sanitizeId(limit.name);\n if (!windowId) {\n continue;\n }\n channel(`${accountId}.limits.${windowId}`, limit.label);\n add(\n state(`${accountId}.limits.${windowId}.percent`, `${limit.label} used`, \"number\", \"value\", limit.percent, \"%\"),\n );\n if (limit.resetAt !== undefined) {\n add(\n state(`${accountId}.limits.${windowId}.resetAt`, `${limit.label} resets at`, \"string\", \"date\", limit.resetAt),\n );\n }\n }\n }\n\n const credits = snapshot.credits;\n if (credits) {\n channel(`${accountId}.credits`, \"Credits\");\n const unit = credits.pieces ? \"\" : credits.currency;\n if (credits.used !== undefined) {\n add(state(`${accountId}.credits.used`, \"Credits used\", \"number\", \"value\", credits.used, unit));\n }\n if (credits.limit !== undefined) {\n add(state(`${accountId}.credits.limit`, \"Credits limit\", \"number\", \"value\", credits.limit, unit));\n }\n if (credits.remaining !== undefined) {\n add(state(`${accountId}.credits.remaining`, \"Credits remaining\", \"number\", \"value\", credits.remaining, unit));\n }\n if (credits.percent !== undefined) {\n add(state(`${accountId}.credits.percent`, \"Credits used (percent)\", \"number\", \"value\", credits.percent, \"%\"));\n }\n if (credits.granted !== undefined) {\n add(state(`${accountId}.credits.granted`, \"Granted balance\", \"number\", \"value\", credits.granted, unit));\n }\n if (credits.toppedUp !== undefined) {\n add(state(`${accountId}.credits.toppedUp`, \"Topped-up balance\", \"number\", \"value\", credits.toppedUp, unit));\n }\n }\n\n const costs = snapshot.costs;\n if (costs) {\n channel(`${accountId}.costs`, \"Costs\");\n if (costs.today !== undefined) {\n add(state(`${accountId}.costs.today`, \"Costs today\", \"number\", \"value\", costs.today, costs.currency));\n }\n if (costs.month !== undefined) {\n add(state(`${accountId}.costs.month`, \"Costs this month\", \"number\", \"value\", costs.month, costs.currency));\n }\n if (costs.total !== undefined) {\n add(state(`${accountId}.costs.total`, \"Costs total (lifetime)\", \"number\", \"value\", costs.total, costs.currency));\n }\n if (costs.projectedMonth !== undefined) {\n add(\n state(\n `${accountId}.costs.projectedMonth`,\n \"Costs projected month-end (computed)\",\n \"number\",\n \"value\",\n costs.projectedMonth,\n costs.currency,\n ),\n );\n }\n }\n\n const tokens = snapshot.tokens;\n if (tokens) {\n channel(`${accountId}.tokens`, \"Tokens\");\n if (tokens.inputToday !== undefined) {\n add(state(`${accountId}.tokens.inputToday`, \"Input tokens today\", \"number\", \"value\", tokens.inputToday));\n }\n if (tokens.outputToday !== undefined) {\n add(state(`${accountId}.tokens.outputToday`, \"Output tokens today\", \"number\", \"value\", tokens.outputToday));\n }\n if (tokens.perModel && tokens.perModel.length > 0) {\n channel(`${accountId}.models`, \"Per-model usage\");\n for (const model of tokens.perModel) {\n const modelId = sanitizeId(model.model);\n if (!modelId) {\n continue;\n }\n channel(`${accountId}.models.${modelId}`, model.model);\n if (model.tokens !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.tokensToday`,\n `${model.model} tokens today`,\n \"number\",\n \"value\",\n model.tokens,\n ),\n );\n }\n if (model.cost !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.costToday`,\n `${model.model} costs today`,\n \"number\",\n \"value\",\n model.cost,\n snapshot.costs?.currency ?? \"USD\",\n ),\n );\n }\n }\n }\n }\n\n if (snapshot.available !== undefined) {\n add(state(`${accountId}.available`, \"Balance sufficient for calls\", \"boolean\", \"indicator\", snapshot.available));\n }\n\n return { objects, writes };\n}\n\n/**\n * The window that decides how full an account is: the highest PLAN-WIDE limit, or\n * the granted budget when the account has no windows at all.\n *\n * Windows marked `scoped` are left out on purpose \u2014 they cover a single model and\n * may sit at 100 % permanently for a model the user never touches, which would\n * pin the account's warning on forever. Their datapoints still exist; they just\n * do not speak for the account.\n *\n * @param snapshot the snapshot\n * @returns percent plus the label that produced it, or undefined when nothing applies\n */\nexport function limitingWindow(snapshot: UsageSnapshot): { percent: number; label: string } | undefined {\n let best: { percent: number; label: string } | undefined;\n for (const limit of snapshot.limits ?? []) {\n if (limit.scoped) {\n continue;\n }\n if (!best || limit.percent > best.percent) {\n best = { percent: limit.percent, label: limit.label };\n }\n }\n const credits = snapshot.credits?.percent;\n if (credits !== undefined && (!best || credits > best.percent)) {\n best = { percent: credits, label: \"Credits\" };\n }\n return best;\n}\n\n/**\n * The highest plan-wide limit percent in a snapshot, or undefined when it has none.\n *\n * @param snapshot the snapshot\n * @returns the maximum percent\n */\nexport function maxLimitPercent(snapshot: UsageSnapshot): number | undefined {\n return limitingWindow(snapshot)?.percent;\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,0BAA2B;
|
|
4
|
+
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { sanitizeId } from \"./pure-helpers\";\n\n/** An object to create: id (relative to the instance), type and common. */\nexport interface ObjectDef {\n /** Object id relative to the instance root. */\n id: string;\n /** ioBroker object type. */\n type: \"device\" | \"channel\" | \"state\";\n /** The object's common. */\n common: {\n name: string | Record<string, string>;\n type?: \"boolean\" | \"number\" | \"string\";\n role?: string;\n read?: boolean;\n write?: boolean;\n unit?: string;\n min?: number;\n max?: number;\n /**\n * Links a device to the state that says whether it is up. THIS is what puts the\n * connection icon next to the device in the admin's object tree \u2014 the object\n * browser reads `common.statusStates` and nothing else (verified in\n * adapter-react-v5 `renderLeaf.tsx`). A relative id is expanded to\n * `<device>.<id>`. `offlineId` is inverted: true means offline.\n */\n statusStates?: { onlineId?: string; offlineId?: string; errorId?: string };\n };\n}\n\n/** A state value to write (ack). */\nexport interface StateWrite {\n /** State id relative to the instance root. */\n id: string;\n /** The value. */\n value: boolean | number | string;\n}\n\n/**\n * A read-only state definition plus its value \u2014 the tree builder's working unit.\n *\n * @param id the state id\n * @param name the object name\n * @param type the value type\n * @param role the state role\n * @param value the current value\n * @param unit optional unit\n * @returns the def/value pair\n */\nfunction state(\n id: string,\n name: string,\n type: \"boolean\" | \"number\" | \"string\",\n role: string,\n value: boolean | number | string,\n unit?: string,\n): { def: ObjectDef; write: StateWrite } {\n const common: ObjectDef[\"common\"] = { name, type, role, read: true, write: false };\n if (unit !== undefined) {\n common.unit = unit;\n }\n return { def: { id, type: \"state\", common }, write: { id, value } };\n}\n\n/** The result of mapping one snapshot: objects to upsert (parents first) and values to write. */\nexport interface TreeResult {\n /** Objects in creation order (device, channels, states). */\n objects: ObjectDef[];\n /** State values to write with ack. */\n writes: StateWrite[];\n}\n\n/**\n * Map one account snapshot onto the harmonised object tree: `limits.*`, `credits.*`,\n * `costs.*`, `tokens.*` (+ `models.*`) \u2014 only what the snapshot carries is created,\n * so the tree mirrors exactly what the provider's source delivers.\n *\n * @param accountId the id-safe account id (device node)\n * @param accountName the display name for the device object\n * @param provider the provider kind (device object name suffix)\n * @param snapshot the fetched snapshot\n * @returns objects (parents first) and state writes\n */\nexport function mapSnapshot(\n accountId: string,\n accountName: string,\n provider: string,\n snapshot: UsageSnapshot,\n): TreeResult {\n const objects: ObjectDef[] = [];\n const writes: StateWrite[] = [];\n const add = (pair: { def: ObjectDef; write: StateWrite }): void => {\n objects.push(pair.def);\n writes.push(pair.write);\n };\n const channel = (id: string, name: string): void => {\n objects.push({ id, type: \"channel\", common: { name } });\n };\n\n objects.push({ id: accountId, type: \"device\", common: { name: `${accountName} (${provider})` } });\n\n if (snapshot.limits && snapshot.limits.length > 0) {\n channel(`${accountId}.limits`, \"Limit windows\");\n for (const limit of snapshot.limits) {\n const windowId = sanitizeId(limit.name);\n if (!windowId) {\n continue;\n }\n channel(`${accountId}.limits.${windowId}`, limit.label);\n add(\n state(`${accountId}.limits.${windowId}.percent`, `${limit.label} used`, \"number\", \"value\", limit.percent, \"%\"),\n );\n if (limit.resetAt !== undefined) {\n add(\n state(`${accountId}.limits.${windowId}.resetAt`, `${limit.label} resets at`, \"string\", \"date\", limit.resetAt),\n );\n }\n }\n }\n\n const credits = snapshot.credits;\n if (credits) {\n channel(`${accountId}.credits`, \"Credits\");\n const unit = credits.pieces ? \"\" : credits.currency;\n if (credits.used !== undefined) {\n add(state(`${accountId}.credits.used`, \"Credits used\", \"number\", \"value\", credits.used, unit));\n }\n if (credits.limit !== undefined) {\n add(state(`${accountId}.credits.limit`, \"Credits limit\", \"number\", \"value\", credits.limit, unit));\n }\n if (credits.remaining !== undefined) {\n add(state(`${accountId}.credits.remaining`, \"Credits remaining\", \"number\", \"value\", credits.remaining, unit));\n }\n if (credits.percent !== undefined) {\n add(state(`${accountId}.credits.percent`, \"Credits used (percent)\", \"number\", \"value\", credits.percent, \"%\"));\n }\n if (credits.granted !== undefined) {\n add(state(`${accountId}.credits.granted`, \"Granted balance\", \"number\", \"value\", credits.granted, unit));\n }\n if (credits.toppedUp !== undefined) {\n add(state(`${accountId}.credits.toppedUp`, \"Topped-up balance\", \"number\", \"value\", credits.toppedUp, unit));\n }\n }\n\n const costs = snapshot.costs;\n if (costs) {\n channel(`${accountId}.costs`, \"Costs\");\n if (costs.today !== undefined) {\n add(state(`${accountId}.costs.today`, \"Costs today\", \"number\", \"value\", costs.today, costs.currency));\n }\n if (costs.month !== undefined) {\n add(state(`${accountId}.costs.month`, \"Costs this month\", \"number\", \"value\", costs.month, costs.currency));\n }\n if (costs.total !== undefined) {\n add(state(`${accountId}.costs.total`, \"Costs total (lifetime)\", \"number\", \"value\", costs.total, costs.currency));\n }\n if (costs.projectedMonth !== undefined) {\n add(\n state(\n `${accountId}.costs.projectedMonth`,\n \"Costs projected month-end (computed)\",\n \"number\",\n \"value\",\n costs.projectedMonth,\n costs.currency,\n ),\n );\n }\n }\n\n const tokens = snapshot.tokens;\n if (tokens) {\n channel(`${accountId}.tokens`, \"Tokens\");\n if (tokens.inputToday !== undefined) {\n add(state(`${accountId}.tokens.inputToday`, \"Input tokens today\", \"number\", \"value\", tokens.inputToday));\n }\n if (tokens.outputToday !== undefined) {\n add(state(`${accountId}.tokens.outputToday`, \"Output tokens today\", \"number\", \"value\", tokens.outputToday));\n }\n if (tokens.perModel && tokens.perModel.length > 0) {\n channel(`${accountId}.models`, \"Per-model usage\");\n for (const model of tokens.perModel) {\n const modelId = sanitizeId(model.model);\n if (!modelId) {\n continue;\n }\n channel(`${accountId}.models.${modelId}`, model.model);\n if (model.tokens !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.tokensToday`,\n `${model.model} tokens today`,\n \"number\",\n \"value\",\n model.tokens,\n ),\n );\n }\n if (model.cost !== undefined) {\n add(\n state(\n `${accountId}.models.${modelId}.costToday`,\n `${model.model} costs today`,\n \"number\",\n \"value\",\n model.cost,\n snapshot.costs?.currency ?? \"USD\",\n ),\n );\n }\n }\n }\n }\n\n if (snapshot.available !== undefined) {\n add(state(`${accountId}.available`, \"Balance sufficient for calls\", \"boolean\", \"indicator\", snapshot.available));\n }\n\n return { objects, writes };\n}\n\n/**\n * The window that decides how full an account is: the highest PLAN-WIDE limit, or\n * the granted budget when the account has no windows at all.\n *\n * Windows marked `scoped` are left out on purpose \u2014 they cover a single model and\n * may sit at 100 % permanently for a model the user never touches, which would\n * pin the account's warning on forever. Their datapoints still exist; they just\n * do not speak for the account.\n *\n * @param snapshot the snapshot\n * @returns percent plus the label that produced it, or undefined when nothing applies\n */\nexport function limitingWindow(snapshot: UsageSnapshot): { percent: number; label: string } | undefined {\n let best: { percent: number; label: string } | undefined;\n for (const limit of snapshot.limits ?? []) {\n if (limit.scoped) {\n continue;\n }\n if (!best || limit.percent > best.percent) {\n best = { percent: limit.percent, label: limit.label };\n }\n }\n const credits = snapshot.credits?.percent;\n if (credits !== undefined && (!best || credits > best.percent)) {\n best = { percent: credits, label: \"Credits\" };\n }\n return best;\n}\n\n/**\n * The highest plan-wide limit percent in a snapshot, or undefined when it has none.\n *\n * @param snapshot the snapshot\n * @returns the maximum percent\n */\nexport function maxLimitPercent(snapshot: UsageSnapshot): number | undefined {\n return limitingWindow(snapshot)?.percent;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/lib/totals.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/totals.ts"],
|
|
4
|
-
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { maxLimitPercent } from \"./snapshot-tree\";\n\n/** One account's contribution to the totals. */\nexport interface AccountStatus {\n /** The last successful snapshot, if any. */\n snapshot?: UsageSnapshot;\n /** Whether the account is currently reachable. */\n reachable: boolean;\n /** Whether the account is above its warn threshold. */\n warning: boolean;\n}\n\n/** The adapter-wide totals. */\nexport interface Totals {\n /** Summed real money spent today (same-currency accounts only). */\n costsToday: number;\n /** Summed real money spent this month. */\n costsMonth: number;\n /** Summed projected month-end spend. */\n costsProjectedMonth: number;\n /** The currency the sums are in. */\n currency: string;\n /** The highest limit utilisation of any account (percent). */\n maxLimitPercent: number;\n /** Number of accounts above their warn threshold. */\n warningsActive: number;\n /** True when any limit window is full (>= 100 %). */\n limitReached: boolean;\n /** Reachable accounts. */\n accountsReachable: number;\n /** Configured (enabled) accounts. */\n accounts: number;\n}\n\n/** The currency the totals are summed in. Non-matching and piece-counters stay out. */\nconst TOTAL_CURRENCY = \"USD\";\n\n/**\n * Compute the adapter-wide totals from the in-memory account statuses. Money sums\n * include only real-money costs in {@link TOTAL_CURRENCY}; piece-counters and\n * foreign currencies are excluded by design.\n *\n * @param statuses each account's status\n * @returns the totals\n */\nexport function computeTotals(statuses: readonly AccountStatus[]): Totals {\n let costsToday = 0;\n let costsMonth = 0;\n let costsProjectedMonth = 0;\n let maxPercent = 0;\n let warningsActive = 0;\n let limitReached = false;\n let reachable = 0;\n for (const status of statuses) {\n if (status.reachable) {\n reachable++;\n }\n if (status.warning) {\n warningsActive++;\n }\n const snapshot = status.snapshot;\n if (!snapshot) {\n continue;\n }\n const costs = snapshot.costs;\n if (costs && costs.currency === TOTAL_CURRENCY) {\n costsToday += costs.today ?? 0;\n costsMonth += costs.month ?? 0;\n costsProjectedMonth += costs.projectedMonth ?? costs.month ?? 0;\n }\n const percent = maxLimitPercent(snapshot);\n if (percent !== undefined) {\n maxPercent = Math.max(maxPercent, percent);\n if (percent >= 100) {\n limitReached = true;\n }\n }\n }\n return {\n costsToday: round2(costsToday),\n costsMonth: round2(costsMonth),\n costsProjectedMonth: round2(costsProjectedMonth),\n currency: TOTAL_CURRENCY,\n maxLimitPercent: round2(maxPercent),\n warningsActive,\n limitReached,\n accountsReachable: reachable,\n accounts: statuses.length,\n };\n}\n\n/**\n * Round to two decimals (money/percent display).\n *\n * @param value the value\n * @returns the rounded value\n */\nfunction round2(value: number): number {\n return Math.round(value * 100) / 100;\n}\n"],
|
|
4
|
+
"sourcesContent": ["import type { UsageSnapshot } from \"./provider\";\nimport { maxLimitPercent } from \"./snapshot-tree\";\n\n/** One account's contribution to the totals. */\nexport interface AccountStatus {\n /** The last successful snapshot, if any. */\n snapshot?: UsageSnapshot;\n /** Whether the account is currently reachable. */\n reachable: boolean;\n /** Whether the account is above its warn threshold. */\n warning: boolean;\n}\n\n/** The adapter-wide totals. */\nexport interface Totals {\n /** Summed real money spent today (same-currency accounts only). */\n costsToday: number;\n /** Summed real money spent this month. */\n costsMonth: number;\n /** Summed projected month-end spend. */\n costsProjectedMonth: number;\n /** The currency the sums are in. */\n currency: string;\n /** The highest PLAN-WIDE limit utilisation of any account (percent); model-scoped windows stay out. */\n maxLimitPercent: number;\n /** Number of accounts above their warn threshold. */\n warningsActive: number;\n /** True when any plan-wide limit window is full (>= 100 %). */\n limitReached: boolean;\n /** Reachable accounts. */\n accountsReachable: number;\n /** Configured (enabled) accounts. */\n accounts: number;\n}\n\n/** The currency the totals are summed in. Non-matching and piece-counters stay out. */\nconst TOTAL_CURRENCY = \"USD\";\n\n/**\n * Compute the adapter-wide totals from the in-memory account statuses. Money sums\n * include only real-money costs in {@link TOTAL_CURRENCY}; piece-counters and\n * foreign currencies are excluded by design.\n *\n * @param statuses each account's status\n * @returns the totals\n */\nexport function computeTotals(statuses: readonly AccountStatus[]): Totals {\n let costsToday = 0;\n let costsMonth = 0;\n let costsProjectedMonth = 0;\n let maxPercent = 0;\n let warningsActive = 0;\n let limitReached = false;\n let reachable = 0;\n for (const status of statuses) {\n if (status.reachable) {\n reachable++;\n }\n if (status.warning) {\n warningsActive++;\n }\n const snapshot = status.snapshot;\n if (!snapshot) {\n continue;\n }\n const costs = snapshot.costs;\n if (costs && costs.currency === TOTAL_CURRENCY) {\n costsToday += costs.today ?? 0;\n costsMonth += costs.month ?? 0;\n costsProjectedMonth += costs.projectedMonth ?? costs.month ?? 0;\n }\n const percent = maxLimitPercent(snapshot);\n if (percent !== undefined) {\n maxPercent = Math.max(maxPercent, percent);\n if (percent >= 100) {\n limitReached = true;\n }\n }\n }\n return {\n costsToday: round2(costsToday),\n costsMonth: round2(costsMonth),\n costsProjectedMonth: round2(costsProjectedMonth),\n currency: TOTAL_CURRENCY,\n maxLimitPercent: round2(maxPercent),\n warningsActive,\n limitReached,\n accountsReachable: reachable,\n accounts: statuses.length,\n };\n}\n\n/**\n * Round to two decimals (money/percent display).\n *\n * @param value the value\n * @returns the rounded value\n */\nfunction round2(value: number): number {\n return Math.round(value * 100) / 100;\n}\n"],
|
|
5
5
|
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,2BAAgC;AAmChC,MAAM,iBAAiB;AAUhB,SAAS,cAAc,UAA4C;AA9C1E;AA+CE,MAAI,aAAa;AACjB,MAAI,aAAa;AACjB,MAAI,sBAAsB;AAC1B,MAAI,aAAa;AACjB,MAAI,iBAAiB;AACrB,MAAI,eAAe;AACnB,MAAI,YAAY;AAChB,aAAW,UAAU,UAAU;AAC7B,QAAI,OAAO,WAAW;AACpB;AAAA,IACF;AACA,QAAI,OAAO,SAAS;AAClB;AAAA,IACF;AACA,UAAM,WAAW,OAAO;AACxB,QAAI,CAAC,UAAU;AACb;AAAA,IACF;AACA,UAAM,QAAQ,SAAS;AACvB,QAAI,SAAS,MAAM,aAAa,gBAAgB;AAC9C,qBAAc,WAAM,UAAN,YAAe;AAC7B,qBAAc,WAAM,UAAN,YAAe;AAC7B,8BAAuB,iBAAM,mBAAN,YAAwB,MAAM,UAA9B,YAAuC;AAAA,IAChE;AACA,UAAM,cAAU,sCAAgB,QAAQ;AACxC,QAAI,YAAY,QAAW;AACzB,mBAAa,KAAK,IAAI,YAAY,OAAO;AACzC,UAAI,WAAW,KAAK;AAClB,uBAAe;AAAA,MACjB;AAAA,IACF;AAAA,EACF;AACA,SAAO;AAAA,IACL,YAAY,OAAO,UAAU;AAAA,IAC7B,YAAY,OAAO,UAAU;AAAA,IAC7B,qBAAqB,OAAO,mBAAmB;AAAA,IAC/C,UAAU;AAAA,IACV,iBAAiB,OAAO,UAAU;AAAA,IAClC;AAAA,IACA;AAAA,IACA,mBAAmB;AAAA,IACnB,UAAU,SAAS;AAAA,EACrB;AACF;AAQA,SAAS,OAAO,OAAuB;AACrC,SAAO,KAAK,MAAM,QAAQ,GAAG,IAAI;AACnC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/build/main.js
CHANGED
|
@@ -198,7 +198,6 @@ class AiUsageAdapter extends utils.Adapter {
|
|
|
198
198
|
this.stopDevicePoller(provider);
|
|
199
199
|
const id = import_pure_helpers.SUBSCRIPTION_IDS[provider];
|
|
200
200
|
if (id) {
|
|
201
|
-
await this.setSignedInState(id, true);
|
|
202
201
|
await ((_a = this.engine) == null ? void 0 : _a.pollNow(id));
|
|
203
202
|
}
|
|
204
203
|
this.log.info(`${provider}: signed in`);
|
|
@@ -285,10 +284,6 @@ class AiUsageAdapter extends utils.Adapter {
|
|
|
285
284
|
this.attempts.delete(provider);
|
|
286
285
|
this.signInErrors.delete(provider);
|
|
287
286
|
this.stopDevicePoller(provider);
|
|
288
|
-
const id = import_pure_helpers.SUBSCRIPTION_IDS[provider];
|
|
289
|
-
if (id) {
|
|
290
|
-
await this.setSignedInState(id, false);
|
|
291
|
-
}
|
|
292
287
|
return { status: "signed-out" };
|
|
293
288
|
}
|
|
294
289
|
// ------------------------------------------------------------ token files
|
|
@@ -402,7 +397,10 @@ class AiUsageAdapter extends utils.Adapter {
|
|
|
402
397
|
) : void 0
|
|
403
398
|
});
|
|
404
399
|
await this.engine.start();
|
|
405
|
-
await this.
|
|
400
|
+
const removed = await this.removeRetiredStates(accounts);
|
|
401
|
+
if (removed > 0) {
|
|
402
|
+
this.log.info(`Object tree updated: removed ${removed} obsolete status datapoint(s)`);
|
|
403
|
+
}
|
|
406
404
|
this.log.info(`Monitoring ${providers.size} of ${accounts.length} AI account(s), polling every ${interval} s`);
|
|
407
405
|
} catch (e) {
|
|
408
406
|
this.log.error(`Startup failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -469,40 +467,43 @@ class AiUsageAdapter extends utils.Adapter {
|
|
|
469
467
|
}
|
|
470
468
|
}
|
|
471
469
|
/**
|
|
472
|
-
*
|
|
470
|
+
* Status states retired in 0.5.0 — `provider` (visible in the node name anyway),
|
|
471
|
+
* `reachable`/`serviceOnline`/`state` (three spellings of one fact) and `signedIn`
|
|
472
|
+
* (the settings page asks the adapter directly).
|
|
473
|
+
*
|
|
474
|
+
* ioBroker never garbage-collects an object whose id the adapter stopped writing:
|
|
475
|
+
* it would sit there frozen on its last value and keep lying. So the old ids are
|
|
476
|
+
* deleted from a fixed list on every start — no state reading, no heuristics.
|
|
477
|
+
*/
|
|
478
|
+
static RETIRED_INFO_STATES = [
|
|
479
|
+
"info.provider",
|
|
480
|
+
"info.reachable",
|
|
481
|
+
"info.serviceOnline",
|
|
482
|
+
"info.state",
|
|
483
|
+
"info.signedIn"
|
|
484
|
+
];
|
|
485
|
+
/**
|
|
486
|
+
* Remove the retired status states of every configured account.
|
|
473
487
|
*
|
|
474
488
|
* @param accounts the configured accounts
|
|
489
|
+
* @returns how many objects were actually deleted
|
|
475
490
|
*/
|
|
476
|
-
async
|
|
491
|
+
async removeRetiredStates(accounts) {
|
|
492
|
+
let removed = 0;
|
|
477
493
|
for (const account of accounts) {
|
|
478
|
-
|
|
479
|
-
|
|
494
|
+
for (const suffix of AiUsageAdapter.RETIRED_INFO_STATES) {
|
|
495
|
+
const id = `${account.id}.${suffix}`;
|
|
496
|
+
try {
|
|
497
|
+
if (await this.getObjectAsync(id)) {
|
|
498
|
+
await this.delObjectAsync(id);
|
|
499
|
+
removed++;
|
|
500
|
+
}
|
|
501
|
+
} catch (e) {
|
|
502
|
+
this.log.debug(`Could not remove ${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
503
|
+
}
|
|
480
504
|
}
|
|
481
|
-
const tokens = await this.tokenStore(account.provider).load();
|
|
482
|
-
await this.setSignedInState(account.id, !!tokens);
|
|
483
505
|
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Write the sign-in indicator INSIDE the account node — the pre-0.3.0 layout kept
|
|
487
|
-
* a second `auth.<name>` branch, which showed every subscription twice in the tree.
|
|
488
|
-
*
|
|
489
|
-
* @param accountId the account's object id
|
|
490
|
-
* @param signedIn whether a usable sign-in exists
|
|
491
|
-
*/
|
|
492
|
-
async setSignedInState(accountId, signedIn) {
|
|
493
|
-
await this.extendObject(`${accountId}.info.signedIn`, {
|
|
494
|
-
type: "state",
|
|
495
|
-
common: {
|
|
496
|
-
name: { en: "Signed in", de: "Angemeldet" },
|
|
497
|
-
type: "boolean",
|
|
498
|
-
role: "indicator",
|
|
499
|
-
read: true,
|
|
500
|
-
write: false,
|
|
501
|
-
def: false
|
|
502
|
-
},
|
|
503
|
-
native: {}
|
|
504
|
-
});
|
|
505
|
-
await this.setState(`${accountId}.info.signedIn`, { val: signedIn, ack: true });
|
|
506
|
+
return removed;
|
|
506
507
|
}
|
|
507
508
|
/**
|
|
508
509
|
* Delete object trees that no longer belong to a configured account, plus the
|