iobroker.govee-smart 2.16.0 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,12 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
94
94
  Placeholder for the next version (at the beginning of the line):
95
95
  ### **WORK IN PROGRESS**
96
96
  -->
97
+ ### 2.16.1 (2026-06-11)
98
+
99
+ - Running in compact mode no longer intercepts errors from other adapters in the same process — error reporting stays correctly attributed per adapter.
100
+ - Cloud commands issued during a long rate-limit window no longer pile up without limit; stale queued calls are dropped with a single warning.
101
+ - Device info entries (name, model, IP address) no longer rewrite their timestamps on every refresh — state history stays clean.
102
+
97
103
  ### 2.16.0 (2026-06-09)
98
104
 
99
105
  - Lights and appliances without a local connection can now be controlled through the Cloud and report their correct online status; devices that support local control keep using it first.
@@ -114,10 +120,6 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
114
120
  - The segment-detection wizard and the Cloud login test now show their messages in all admin languages instead of falling back to English or German.
115
121
  - For LAN-only lights, brightness, color and state are now recorded only when they actually change, keeping their history cleaner.
116
122
 
117
- ### 2.13.4 (2026-05-23)
118
-
119
- - Changelog rewritten in user-centric style across all versions.
120
-
121
123
  [Older changelogs can be found there](CHANGELOG_OLD.md)
122
124
 
123
125
  ## Support
@@ -18,19 +18,9 @@ var __copyProps = (to, from, except, desc) => {
18
18
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
19
  var actionable_problems_exports = {};
20
20
  __export(actionable_problems_exports, {
21
- ACTIONABLE_CATEGORIES: () => ACTIONABLE_CATEGORIES,
22
- ActionableProblems: () => ActionableProblems,
23
- isActionable: () => isActionable
21
+ ActionableProblems: () => ActionableProblems
24
22
  });
25
23
  module.exports = __toCommonJS(actionable_problems_exports);
26
- const ACTIONABLE_CATEGORIES = /* @__PURE__ */ new Set([
27
- "VERIFICATION_PENDING",
28
- "VERIFICATION_FAILED",
29
- "AUTH"
30
- ]);
31
- function isActionable(category) {
32
- return ACTIONABLE_CATEGORIES.has(category);
33
- }
34
24
  class ActionableProblems {
35
25
  /**
36
26
  * @param host side-effect surface (logger + notification raiser)
@@ -71,23 +61,9 @@ class ActionableProblems {
71
61
  this.active.delete(key);
72
62
  this.host.logInfo(resolutionMessage != null ? resolutionMessage : `Resolved: ${problem.title}`);
73
63
  }
74
- /**
75
- * True if the given problem is currently active.
76
- *
77
- * @param key the problem key to check
78
- */
79
- isActive(key) {
80
- return this.active.has(key);
81
- }
82
- /** Keys of all currently-active problems (diagnostics / tests). */
83
- activeKeys() {
84
- return [...this.active.keys()];
85
- }
86
64
  }
87
65
  // Annotate the CommonJS export names for ESM import in node:
88
66
  0 && (module.exports = {
89
- ACTIONABLE_CATEGORIES,
90
- ActionableProblems,
91
- isActionable
67
+ ActionableProblems
92
68
  });
93
69
  //# sourceMappingURL=actionable-problems.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/actionable-problems.ts"],
4
- "sourcesContent": ["import type { ErrorCategory } from \"./types\";\n\n/**\n * Error categories that require the USER to act \u2014 they will NOT self-heal:\n * the adapter can retry forever and stay blocked until the user does something\n * (request a verification code, fix credentials/API key). These are the only\n * categories that earn a persistent, surfaced \"this needs your attention\".\n *\n * Everything else (NETWORK, TIMEOUT, RATE_LIMIT, UNKNOWN) is transient \u2014 it\n * keeps the warn-once-then-debug policy in `log-channel-fail.ts` and never\n * reaches this registry.\n */\nexport const ACTIONABLE_CATEGORIES: ReadonlySet<ErrorCategory> = new Set<ErrorCategory>([\n \"VERIFICATION_PENDING\",\n \"VERIFICATION_FAILED\",\n \"AUTH\",\n]);\n\n/**\n * True when a classified error needs the user to act (vs. self-healing).\n *\n * @param category the classified error category\n */\nexport function isActionable(category: ErrorCategory): boolean {\n return ACTIONABLE_CATEGORIES.has(category);\n}\n\n/** A single user-actionable problem: what is wrong + what the user must do. */\nexport interface ActionableProblem {\n /**\n * Stable key \u2014 one active problem per key. Re-reporting the same key while it\n * is active is a no-op (spam-free). Example: `\"mqtt-verification\"`.\n */\n key: string;\n /** One sentence: what is wrong (user-facing, no jargon). */\n title: string;\n /** One sentence: what the user must do to fix it. */\n action: string;\n}\n\n/**\n * The side-effect surface the registry talks to. Abstracted so the registry is\n * pure logic and unit-testable without a live adapter. The real host wraps the\n * adapter logger + `registerNotification`; tests inject a capturing fake.\n */\nexport interface ActionableProblemsHost {\n /** Clear, user-facing warn line (first occurrence of a problem). */\n logWarn(message: string): void;\n /** Resolution / positive-feedback line (problem cleared). */\n logInfo(message: string): void;\n /**\n * Raise a persistent ioBroker notification carrying the message. Idempotent\n * from the caller's view \u2014 the platform caps duplicates via the category\n * `limit`, so callers never have to dedup across restarts.\n */\n notify(message: string): void;\n}\n\n/**\n * Central registry for user-actionable problems (Govee verification needed,\n * rejected credentials, \u2026). One mechanism every error site can feed.\n *\n * Behaviour (the \"intelligent, no-spam\" contract):\n * - **report** a NEW problem \u2192 surface it ONCE: a clear \"what \u2192 what to do\"\n * warn line + a persistent notification (stays in the Admin / forwards via\n * notification-manager until the user acknowledges it).\n * - **report** an already-active problem \u2192 no-op. No log/notification spam\n * while it stays unresolved within a session.\n * - **resolve** an active problem \u2192 a single positive `info` line. The\n * notification is left for the user to acknowledge (ioBroker has no adapter\n * API to clear one \u2014 using the platform as designed, no host-command hacks).\n *\n * Transient problems never reach here \u2014 they self-heal and keep the existing\n * warn-once-then-debug policy.\n */\nexport class ActionableProblems {\n private readonly active = new Map<string, ActionableProblem>();\n\n /**\n * @param host side-effect surface (logger + notification raiser)\n */\n constructor(private readonly host: ActionableProblemsHost) {}\n\n /**\n * Report an actionable problem. Surfaces it (warn + notification) when it is\n * NEW or when its message changed since last time (e.g. the verification\n * problem turning from \"code needed\" into \"code rejected\"). An identical\n * re-report of an already-active problem is a no-op \u2014 no spam.\n *\n * @param problem the problem to surface\n */\n report(problem: ActionableProblem): void {\n const line = `${problem.title} \u2192 ${problem.action}`;\n const existing = this.active.get(problem.key);\n if (existing && `${existing.title} \u2192 ${existing.action}` === line) {\n return; // identical and still active \u2014 already surfaced, stay quiet\n }\n this.active.set(problem.key, problem);\n this.host.logWarn(line);\n this.host.notify(line);\n }\n\n /**\n * Mark a problem resolved. Logs a single resolution line if it was active.\n *\n * @param key the problem key to clear\n * @param resolutionMessage optional positive message; falls back to a default\n */\n resolve(key: string, resolutionMessage?: string): void {\n const problem = this.active.get(key);\n if (!problem) {\n return;\n }\n this.active.delete(key);\n this.host.logInfo(resolutionMessage ?? `Resolved: ${problem.title}`);\n }\n\n /**\n * True if the given problem is currently active.\n *\n * @param key the problem key to check\n */\n isActive(key: string): boolean {\n return this.active.has(key);\n }\n\n /** Keys of all currently-active problems (diagnostics / tests). */\n activeKeys(): string[] {\n return [...this.active.keys()];\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAYO,MAAM,wBAAoD,oBAAI,IAAmB;AAAA,EACtF;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAOM,SAAS,aAAa,UAAkC;AAC7D,SAAO,sBAAsB,IAAI,QAAQ;AAC3C;AAkDO,MAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA,EAM9B,YAA6B,MAA8B;AAA9B;AAAA,EAA+B;AAAA,EAL3C,SAAS,oBAAI,IAA+B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAe7D,OAAO,SAAkC;AACvC,UAAM,OAAO,GAAG,QAAQ,KAAK,WAAM,QAAQ,MAAM;AACjD,UAAM,WAAW,KAAK,OAAO,IAAI,QAAQ,GAAG;AAC5C,QAAI,YAAY,GAAG,SAAS,KAAK,WAAM,SAAS,MAAM,OAAO,MAAM;AACjE;AAAA,IACF;AACA,SAAK,OAAO,IAAI,QAAQ,KAAK,OAAO;AACpC,SAAK,KAAK,QAAQ,IAAI;AACtB,SAAK,KAAK,OAAO,IAAI;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,QAAQ,KAAa,mBAAkC;AACrD,UAAM,UAAU,KAAK,OAAO,IAAI,GAAG;AACnC,QAAI,CAAC,SAAS;AACZ;AAAA,IACF;AACA,SAAK,OAAO,OAAO,GAAG;AACtB,SAAK,KAAK,QAAQ,gDAAqB,aAAa,QAAQ,KAAK,EAAE;AAAA,EACrE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,SAAS,KAAsB;AAC7B,WAAO,KAAK,OAAO,IAAI,GAAG;AAAA,EAC5B;AAAA;AAAA,EAGA,aAAuB;AACrB,WAAO,CAAC,GAAG,KAAK,OAAO,KAAK,CAAC;AAAA,EAC/B;AACF;",
4
+ "sourcesContent": ["/** A single user-actionable problem: what is wrong + what the user must do. */\nexport interface ActionableProblem {\n /**\n * Stable key \u2014 one active problem per key. Re-reporting the same key while it\n * is active is a no-op (spam-free). Example: `\"mqtt-verification\"`.\n */\n key: string;\n /** One sentence: what is wrong (user-facing, no jargon). */\n title: string;\n /** One sentence: what the user must do to fix it. */\n action: string;\n}\n\n/**\n * The side-effect surface the registry talks to. Abstracted so the registry is\n * pure logic and unit-testable without a live adapter. The real host wraps the\n * adapter logger + `registerNotification`; tests inject a capturing fake.\n */\nexport interface ActionableProblemsHost {\n /** Clear, user-facing warn line (first occurrence of a problem). */\n logWarn(message: string): void;\n /** Resolution / positive-feedback line (problem cleared). */\n logInfo(message: string): void;\n /**\n * Raise a persistent ioBroker notification carrying the message. Idempotent\n * from the caller's view \u2014 the platform caps duplicates via the category\n * `limit`, so callers never have to dedup across restarts.\n */\n notify(message: string): void;\n}\n\n/**\n * Central registry for user-actionable problems (Govee verification needed,\n * rejected credentials, \u2026). One mechanism every error site can feed.\n *\n * Which problems belong here: error classes the USER must fix because they\n * never self-heal \u2014 verification pending/failed and rejected credentials\n * (the AUTH-shaped failures). Transient classes (NETWORK, TIMEOUT,\n * RATE_LIMIT, UNKNOWN) keep the warn-once-then-debug policy in\n * `log-channel-fail.ts` and never reach this registry \u2014 enforced by where\n * `report()` is wired (only at verification/auth failure sites), not by a\n * runtime gate.\n *\n * Behaviour (the \"intelligent, no-spam\" contract):\n * - **report** a NEW problem \u2192 surface it ONCE: a clear \"what \u2192 what to do\"\n * warn line + a persistent notification (stays in the Admin / forwards via\n * notification-manager until the user acknowledges it).\n * - **report** an already-active problem \u2192 no-op. No log/notification spam\n * while it stays unresolved within a session.\n * - **resolve** an active problem \u2192 a single positive `info` line. The\n * notification is left for the user to acknowledge (ioBroker has no adapter\n * API to clear one \u2014 using the platform as designed, no host-command hacks).\n *\n * Transient problems never reach here \u2014 they self-heal and keep the existing\n * warn-once-then-debug policy.\n */\nexport class ActionableProblems {\n private readonly active = new Map<string, ActionableProblem>();\n\n /**\n * @param host side-effect surface (logger + notification raiser)\n */\n constructor(private readonly host: ActionableProblemsHost) {}\n\n /**\n * Report an actionable problem. Surfaces it (warn + notification) when it is\n * NEW or when its message changed since last time (e.g. the verification\n * problem turning from \"code needed\" into \"code rejected\"). An identical\n * re-report of an already-active problem is a no-op \u2014 no spam.\n *\n * @param problem the problem to surface\n */\n report(problem: ActionableProblem): void {\n const line = `${problem.title} \u2192 ${problem.action}`;\n const existing = this.active.get(problem.key);\n if (existing && `${existing.title} \u2192 ${existing.action}` === line) {\n return; // identical and still active \u2014 already surfaced, stay quiet\n }\n this.active.set(problem.key, problem);\n this.host.logWarn(line);\n this.host.notify(line);\n }\n\n /**\n * Mark a problem resolved. Logs a single resolution line if it was active.\n *\n * @param key the problem key to clear\n * @param resolutionMessage optional positive message; falls back to a default\n */\n resolve(key: string, resolutionMessage?: string): void {\n const problem = this.active.get(key);\n if (!problem) {\n return;\n }\n this.active.delete(key);\n this.host.logInfo(resolutionMessage ?? `Resolved: ${problem.title}`);\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAwDO,MAAM,mBAAmB;AAAA;AAAA;AAAA;AAAA,EAM9B,YAA6B,MAA8B;AAA9B;AAAA,EAA+B;AAAA,EAL3C,SAAS,oBAAI,IAA+B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAe7D,OAAO,SAAkC;AACvC,UAAM,OAAO,GAAG,QAAQ,KAAK,WAAM,QAAQ,MAAM;AACjD,UAAM,WAAW,KAAK,OAAO,IAAI,QAAQ,GAAG;AAC5C,QAAI,YAAY,GAAG,SAAS,KAAK,WAAM,SAAS,MAAM,OAAO,MAAM;AACjE;AAAA,IACF;AACA,SAAK,OAAO,IAAI,QAAQ,KAAK,OAAO;AACpC,SAAK,KAAK,QAAQ,IAAI;AACtB,SAAK,KAAK,OAAO,IAAI;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,QAAQ,KAAa,mBAAkC;AACrD,UAAM,UAAU,KAAK,OAAO,IAAI,GAAG;AACnC,QAAI,CAAC,SAAS;AACZ;AAAA,IACF;AACA,SAAK,OAAO,OAAO,GAAG;AACtB,SAAK,KAAK,QAAQ,gDAAqB,aAAa,QAAQ,KAAK,EAAE;AAAA,EACrE;AACF;",
6
6
  "names": []
7
7
  }
@@ -44,14 +44,6 @@ class CloudRetryLoop {
44
44
  this.retryTimer = void 0;
45
45
  }
46
46
  }
47
- /** True once Cloud is (or has been) up. */
48
- isConnected() {
49
- return this.connected;
50
- }
51
- /** True after an auth-failure — no further automatic retries will happen. */
52
- isStopped() {
53
- return this.stopped;
54
- }
55
47
  /** Cancel any pending retry. Called from onUnload. */
56
48
  dispose() {
57
49
  if (this.retryTimer !== void 0) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/cloud-retry.ts"],
4
- "sourcesContent": ["import { errMessage, type CloudLoadResult } from \"./types\";\nimport { TRANSIENT_RETRY_MS } from \"./timing-constants\";\n\n/**\n * Dependencies the retry loop needs. Extracting this interface decouples the\n * state machine from the adapter class so the reconnect behaviour can be\n * verified without standing up a full adapter fixture.\n */\nexport interface CloudRetryHost {\n /** Host logger (maps to `adapter.log`). */\n log: {\n debug(m: string): void;\n info(m: string): void;\n warn(m: string): void;\n };\n /** Schedule a managed timeout; must be cancellable via clearTimeout. */\n setTimeout: (cb: () => void, ms: number) => unknown;\n /** Cancel a previously scheduled timeout. */\n clearTimeout: (handle: unknown) => void;\n /** Perform one Cloud-load attempt \u2014 should include any own timeout wrapping. */\n loadFromCloud(): Promise<CloudLoadResult>;\n /** Hook called once after a successful retry (host refreshes states). */\n onCloudRestored(): Promise<void>;\n}\n\n/**\n * Background retry-loop for Govee Cloud connectivity.\n *\n * Handles the three failure modes of a Cloud load:\n * - `auth-failed` \u2192 **stop permanently** (user must fix API-Key)\n * - `rate-limited` \u2192 wait the server-supplied `retryAfterMs`, then retry once\n * - `transient` \u2192 wait a fixed 5 min, then retry once\n *\n * A retry that still fails is handed back through {@link handleResult} so the\n * loop re-arms according to the new reason \u2014 a 429 that still 429s keeps\n * honouring Retry-After without escalating to transient.\n *\n * Idempotent: {@link handleResult} never queues a second timer while one is\n * already armed, and `connected=true` short-circuits further attempts.\n */\nexport class CloudRetryLoop {\n private retryTimer: unknown = undefined;\n private connected = false;\n private stopped = false;\n\n /** @param host Host interface wired up to the adapter. */\n constructor(private readonly host: CloudRetryHost) {}\n\n /**\n * Update the loop's view of whether Cloud is currently connected. Adapter\n * calls this after cache hits, initial success, and manual recoveries.\n *\n * @param ok New connection state\n */\n public setConnected(ok: boolean): void {\n this.connected = ok;\n if (ok && this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n }\n\n /** True once Cloud is (or has been) up. */\n public isConnected(): boolean {\n return this.connected;\n }\n\n /** True after an auth-failure \u2014 no further automatic retries will happen. */\n public isStopped(): boolean {\n return this.stopped;\n }\n\n /** Cancel any pending retry. Called from onUnload. */\n public dispose(): void {\n if (this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n }\n\n /**\n * React to a {@link CloudLoadResult}. On `ok` nothing happens \u2014 the caller\n * is expected to flip {@link setConnected} separately. On failure the loop\n * either stops (auth), schedules a specific delay (rate-limit), or falls\n * back to the transient-retry delay.\n *\n * @param result Most recent Cloud-load outcome\n */\n public handleResult(result: CloudLoadResult): void {\n if (result.ok) {\n return;\n }\n switch (result.reason) {\n case \"auth-failed\":\n this.stopped = true;\n if (this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n this.host.log.warn(\n `Govee Cloud: authentication failed \u2014 check API-Key in adapter settings. Not retrying automatically.`,\n );\n return;\n case \"rate-limited\":\n this.host.log.warn(\n `Govee Cloud: rate-limited \u2014 pausing for ${Math.round(result.retryAfterMs / 1000)}s before retry`,\n );\n this.schedule(result.retryAfterMs);\n return;\n case \"transient\":\n default:\n this.schedule(TRANSIENT_RETRY_MS);\n return;\n }\n }\n\n /**\n * (Re-)arm the retry timer unless one is already queued or we're stopped.\n *\n * @param delayMs How long to wait before the next retry\n */\n private schedule(delayMs: number): void {\n if (this.stopped || this.connected) {\n return;\n }\n if (this.retryTimer !== undefined) {\n return;\n }\n this.retryTimer = this.host.setTimeout(() => {\n this.retryTimer = undefined;\n this.runAttempt().catch(e => this.host.log.debug(`Cloud retry failed: ${errMessage(e)}`));\n }, delayMs);\n }\n\n /** Internal retry step \u2014 one load call, route the result. */\n private async runAttempt(): Promise<void> {\n if (this.connected || this.stopped) {\n return;\n }\n const result = await this.host.loadFromCloud();\n if (result.ok) {\n this.connected = true;\n this.host.log.info(\"Govee Cloud connection restored\");\n await this.host.onCloudRestored();\n } else {\n this.handleResult(result);\n }\n }\n}\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAiD;AACjD,8BAAmC;AAuC5B,MAAM,eAAe;AAAA;AAAA,EAM1B,YAA6B,MAAsB;AAAtB;AAAA,EAAuB;AAAA,EAL5C,aAAsB;AAAA,EACtB,YAAY;AAAA,EACZ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWX,aAAa,IAAmB;AACrC,SAAK,YAAY;AACjB,QAAI,MAAM,KAAK,eAAe,QAAW;AACvC,WAAK,KAAK,aAAa,KAAK,UAAU;AACtC,WAAK,aAAa;AAAA,IACpB;AAAA,EACF;AAAA;AAAA,EAGO,cAAuB;AAC5B,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGO,YAAqB;AAC1B,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGO,UAAgB;AACrB,QAAI,KAAK,eAAe,QAAW;AACjC,WAAK,KAAK,aAAa,KAAK,UAAU;AACtC,WAAK,aAAa;AAAA,IACpB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUO,aAAa,QAA+B;AACjD,QAAI,OAAO,IAAI;AACb;AAAA,IACF;AACA,YAAQ,OAAO,QAAQ;AAAA,MACrB,KAAK;AACH,aAAK,UAAU;AACf,YAAI,KAAK,eAAe,QAAW;AACjC,eAAK,KAAK,aAAa,KAAK,UAAU;AACtC,eAAK,aAAa;AAAA,QACpB;AACA,aAAK,KAAK,IAAI;AAAA,UACZ;AAAA,QACF;AACA;AAAA,MACF,KAAK;AACH,aAAK,KAAK,IAAI;AAAA,UACZ,gDAA2C,KAAK,MAAM,OAAO,eAAe,GAAI,CAAC;AAAA,QACnF;AACA,aAAK,SAAS,OAAO,YAAY;AACjC;AAAA,MACF,KAAK;AAAA,MACL;AACE,aAAK,SAAS,0CAAkB;AAChC;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ,SAAS,SAAuB;AACtC,QAAI,KAAK,WAAW,KAAK,WAAW;AAClC;AAAA,IACF;AACA,QAAI,KAAK,eAAe,QAAW;AACjC;AAAA,IACF;AACA,SAAK,aAAa,KAAK,KAAK,WAAW,MAAM;AAC3C,WAAK,aAAa;AAClB,WAAK,WAAW,EAAE,MAAM,OAAK,KAAK,KAAK,IAAI,MAAM,2BAAuB,yBAAW,CAAC,CAAC,EAAE,CAAC;AAAA,IAC1F,GAAG,OAAO;AAAA,EACZ;AAAA;AAAA,EAGA,MAAc,aAA4B;AACxC,QAAI,KAAK,aAAa,KAAK,SAAS;AAClC;AAAA,IACF;AACA,UAAM,SAAS,MAAM,KAAK,KAAK,cAAc;AAC7C,QAAI,OAAO,IAAI;AACb,WAAK,YAAY;AACjB,WAAK,KAAK,IAAI,KAAK,iCAAiC;AACpD,YAAM,KAAK,KAAK,gBAAgB;AAAA,IAClC,OAAO;AACL,WAAK,aAAa,MAAM;AAAA,IAC1B;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { errMessage, type CloudLoadResult } from \"./types\";\nimport { TRANSIENT_RETRY_MS } from \"./timing-constants\";\n\n/**\n * Dependencies the retry loop needs. Extracting this interface decouples the\n * state machine from the adapter class so the reconnect behaviour can be\n * verified without standing up a full adapter fixture.\n */\nexport interface CloudRetryHost {\n /** Host logger (maps to `adapter.log`). */\n log: {\n debug(m: string): void;\n info(m: string): void;\n warn(m: string): void;\n };\n /** Schedule a managed timeout; must be cancellable via clearTimeout. */\n setTimeout: (cb: () => void, ms: number) => unknown;\n /** Cancel a previously scheduled timeout. */\n clearTimeout: (handle: unknown) => void;\n /** Perform one Cloud-load attempt \u2014 should include any own timeout wrapping. */\n loadFromCloud(): Promise<CloudLoadResult>;\n /** Hook called once after a successful retry (host refreshes states). */\n onCloudRestored(): Promise<void>;\n}\n\n/**\n * Background retry-loop for Govee Cloud connectivity.\n *\n * Handles the three failure modes of a Cloud load:\n * - `auth-failed` \u2192 **stop permanently** (user must fix API-Key)\n * - `rate-limited` \u2192 wait the server-supplied `retryAfterMs`, then retry once\n * - `transient` \u2192 wait a fixed 5 min, then retry once\n *\n * A retry that still fails is handed back through {@link handleResult} so the\n * loop re-arms according to the new reason \u2014 a 429 that still 429s keeps\n * honouring Retry-After without escalating to transient.\n *\n * Idempotent: {@link handleResult} never queues a second timer while one is\n * already armed, and `connected=true` short-circuits further attempts.\n */\nexport class CloudRetryLoop {\n private retryTimer: unknown = undefined;\n private connected = false;\n private stopped = false;\n\n /** @param host Host interface wired up to the adapter. */\n constructor(private readonly host: CloudRetryHost) {}\n\n /**\n * Update the loop's view of whether Cloud is currently connected. Adapter\n * calls this after cache hits, initial success, and manual recoveries.\n *\n * @param ok New connection state\n */\n public setConnected(ok: boolean): void {\n this.connected = ok;\n if (ok && this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n }\n\n /** Cancel any pending retry. Called from onUnload. */\n public dispose(): void {\n if (this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n }\n\n /**\n * React to a {@link CloudLoadResult}. On `ok` nothing happens \u2014 the caller\n * is expected to flip {@link setConnected} separately. On failure the loop\n * either stops (auth), schedules a specific delay (rate-limit), or falls\n * back to the transient-retry delay.\n *\n * @param result Most recent Cloud-load outcome\n */\n public handleResult(result: CloudLoadResult): void {\n if (result.ok) {\n return;\n }\n switch (result.reason) {\n case \"auth-failed\":\n this.stopped = true;\n if (this.retryTimer !== undefined) {\n this.host.clearTimeout(this.retryTimer);\n this.retryTimer = undefined;\n }\n this.host.log.warn(\n `Govee Cloud: authentication failed \u2014 check API-Key in adapter settings. Not retrying automatically.`,\n );\n return;\n case \"rate-limited\":\n this.host.log.warn(\n `Govee Cloud: rate-limited \u2014 pausing for ${Math.round(result.retryAfterMs / 1000)}s before retry`,\n );\n this.schedule(result.retryAfterMs);\n return;\n case \"transient\":\n default:\n this.schedule(TRANSIENT_RETRY_MS);\n return;\n }\n }\n\n /**\n * (Re-)arm the retry timer unless one is already queued or we're stopped.\n *\n * @param delayMs How long to wait before the next retry\n */\n private schedule(delayMs: number): void {\n if (this.stopped || this.connected) {\n return;\n }\n if (this.retryTimer !== undefined) {\n return;\n }\n this.retryTimer = this.host.setTimeout(() => {\n this.retryTimer = undefined;\n this.runAttempt().catch(e => this.host.log.debug(`Cloud retry failed: ${errMessage(e)}`));\n }, delayMs);\n }\n\n /** Internal retry step \u2014 one load call, route the result. */\n private async runAttempt(): Promise<void> {\n if (this.connected || this.stopped) {\n return;\n }\n const result = await this.host.loadFromCloud();\n if (result.ok) {\n this.connected = true;\n this.host.log.info(\"Govee Cloud connection restored\");\n await this.host.onCloudRestored();\n } else {\n this.handleResult(result);\n }\n }\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,mBAAiD;AACjD,8BAAmC;AAuC5B,MAAM,eAAe;AAAA;AAAA,EAM1B,YAA6B,MAAsB;AAAtB;AAAA,EAAuB;AAAA,EAL5C,aAAsB;AAAA,EACtB,YAAY;AAAA,EACZ,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAWX,aAAa,IAAmB;AACrC,SAAK,YAAY;AACjB,QAAI,MAAM,KAAK,eAAe,QAAW;AACvC,WAAK,KAAK,aAAa,KAAK,UAAU;AACtC,WAAK,aAAa;AAAA,IACpB;AAAA,EACF;AAAA;AAAA,EAGO,UAAgB;AACrB,QAAI,KAAK,eAAe,QAAW;AACjC,WAAK,KAAK,aAAa,KAAK,UAAU;AACtC,WAAK,aAAa;AAAA,IACpB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUO,aAAa,QAA+B;AACjD,QAAI,OAAO,IAAI;AACb;AAAA,IACF;AACA,YAAQ,OAAO,QAAQ;AAAA,MACrB,KAAK;AACH,aAAK,UAAU;AACf,YAAI,KAAK,eAAe,QAAW;AACjC,eAAK,KAAK,aAAa,KAAK,UAAU;AACtC,eAAK,aAAa;AAAA,QACpB;AACA,aAAK,KAAK,IAAI;AAAA,UACZ;AAAA,QACF;AACA;AAAA,MACF,KAAK;AACH,aAAK,KAAK,IAAI;AAAA,UACZ,gDAA2C,KAAK,MAAM,OAAO,eAAe,GAAI,CAAC;AAAA,QACnF;AACA,aAAK,SAAS,OAAO,YAAY;AACjC;AAAA,MACF,KAAK;AAAA,MACL;AACE,aAAK,SAAS,0CAAkB;AAChC;AAAA,IACJ;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOQ,SAAS,SAAuB;AACtC,QAAI,KAAK,WAAW,KAAK,WAAW;AAClC;AAAA,IACF;AACA,QAAI,KAAK,eAAe,QAAW;AACjC;AAAA,IACF;AACA,SAAK,aAAa,KAAK,KAAK,WAAW,MAAM;AAC3C,WAAK,aAAa;AAClB,WAAK,WAAW,EAAE,MAAM,OAAK,KAAK,KAAK,IAAI,MAAM,2BAAuB,yBAAW,CAAC,CAAC,EAAE,CAAC;AAAA,IAC1F,GAAG,OAAO;AAAA,EACZ;AAAA;AAAA,EAGA,MAAc,aAA4B;AACxC,QAAI,KAAK,aAAa,KAAK,SAAS;AAClC;AAAA,IACF;AACA,UAAM,SAAS,MAAM,KAAK,KAAK,cAAc;AAC7C,QAAI,OAAO,IAAI;AACb,WAAK,YAAY;AACjB,WAAK,KAAK,IAAI,KAAK,iCAAiC;AACpD,YAAM,KAAK,KAAK,gBAAgB;AAAA,IAClC,OAAO;AACL,WAAK,aAAa,MAAM;AAAA,IAC1B;AAAA,EACF;AACF;",
6
6
  "names": []
7
7
  }
@@ -24,7 +24,6 @@ __export(lookups_exports, {
24
24
  SEGMENT_HARD_MAX: () => SEGMENT_HARD_MAX,
25
25
  deviceKey: () => deviceKey,
26
26
  findDeviceBySkuAndId: () => findDeviceBySkuAndId,
27
- getEffectiveSegmentIndices: () => getEffectiveSegmentIndices,
28
27
  parseMqttSegmentData: () => parseMqttSegmentData,
29
28
  resolveSegmentCount: () => resolveSegmentCount
30
29
  });
@@ -82,17 +81,6 @@ function parseMqttSegmentData(commands) {
82
81
  }
83
82
  return segments;
84
83
  }
85
- function getEffectiveSegmentIndices(device) {
86
- var _a;
87
- if (device.manualMode && Array.isArray(device.manualSegments) && device.manualSegments.length > 0) {
88
- return device.manualSegments.slice();
89
- }
90
- const count = (_a = device.segmentCount) != null ? _a : 0;
91
- if (count <= 0) {
92
- return [];
93
- }
94
- return Array.from({ length: count }, (_, i) => i);
95
- }
96
84
  function resolveSegmentCount(device) {
97
85
  if (typeof device.segmentCount === "number" && device.segmentCount > 0) {
98
86
  return device.segmentCount;
@@ -150,7 +138,6 @@ function findDeviceBySkuAndId(devices, sku, deviceId) {
150
138
  SEGMENT_HARD_MAX,
151
139
  deviceKey,
152
140
  findDeviceBySkuAndId,
153
- getEffectiveSegmentIndices,
154
141
  parseMqttSegmentData,
155
142
  resolveSegmentCount
156
143
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/lib/device-manager/lookups.ts"],
4
- "sourcesContent": ["import { normalizeDeviceId, type GoveeDevice } from \"../types\";\nimport { mapKey } from \"../device-key\";\n\n/** Parsed per-segment data from MQTT BLE packets */\nexport interface MqttSegmentData {\n /** Segment index (0-based) */\n index: number;\n /** Per-segment brightness 0-100 */\n brightness: number;\n /** Red channel 0-255 */\n r: number;\n /** Green channel 0-255 */\n g: number;\n /** Blue channel 0-255 */\n b: number;\n}\n\n/**\n * Parse AA A5 BLE notification packets from MQTT op.command.\n * 5 packets \u00D7 4 segment slots = max 20 segments per push. The device sends\n * exactly as many packets as it has physical segments \u2014 so parsing out all\n * slots (and filtering empty-slot padding) gives us a reliable count of\n * what actually exists on the strip.\n *\n * Format per slot: [Brightness 0-100] [R] [G] [B].\n *\n * An \"empty\" slot (brightness = 0 AND r = g = b = 0) is treated as padding\n * in a partially-filled final packet, not as a real unlit segment \u2014 this\n * matters for devices that don't pad their last packet to 4 slots.\n *\n * @param commands Base64-encoded BLE packets from MQTT op.command\n */\nexport function parseMqttSegmentData(commands: string[]): MqttSegmentData[] {\n if (!Array.isArray(commands)) {\n return [];\n }\n\n const segments: MqttSegmentData[] = [];\n let highestPacket = 0;\n\n for (const cmd of commands) {\n if (typeof cmd !== \"string\") {\n continue;\n }\n const bytes = Buffer.from(cmd, \"base64\");\n if (bytes.length < 20 || bytes[0] !== 0xaa || bytes[1] !== 0xa5) {\n continue;\n }\n\n // M2 \u2014 XOR checksum validation. Govee BLE packets carry an XOR over bytes\n // 0-18 in the last byte (index 19). Spoofed/malformed packets would\n // otherwise slip through and persist a wrong segmentCount.\n let xor = 0;\n for (let i = 0; i < 19; i++) {\n xor ^= bytes[i];\n }\n if (xor !== bytes[19]) {\n continue;\n }\n\n const packetNum = bytes[2];\n if (packetNum < 1 || packetNum > 5) {\n continue;\n }\n if (packetNum > highestPacket) {\n highestPacket = packetNum;\n }\n\n const baseIndex = (packetNum - 1) * 4;\n for (let slot = 0; slot < 4; slot++) {\n const segIdx = baseIndex + slot;\n const offset = 3 + slot * 4;\n segments.push({\n index: segIdx,\n brightness: bytes[offset],\n r: bytes[offset + 1],\n g: bytes[offset + 2],\n b: bytes[offset + 3],\n });\n }\n }\n\n while (segments.length > 0) {\n const tail = segments[segments.length - 1];\n if (tail.brightness === 0 && tail.r === 0 && tail.g === 0 && tail.b === 0) {\n segments.pop();\n } else {\n break;\n }\n }\n\n return segments;\n}\n\n/**\n * Effective physical segment indices for a device.\n * Uses `device.manualSegments` when `device.manualMode=true` (cut strip override),\n * falls back to `0..segmentCount-1` otherwise. Empty if device has no segments.\n *\n * @param device Target device\n */\nexport function getEffectiveSegmentIndices(device: GoveeDevice): number[] {\n if (device.manualMode && Array.isArray(device.manualSegments) && device.manualSegments.length > 0) {\n return device.manualSegments.slice();\n }\n const count = device.segmentCount ?? 0;\n if (count <= 0) {\n return [];\n }\n return Array.from({ length: count }, (_, i) => i);\n}\n\n/**\n * Resolve the authoritative segment count for a device.\n *\n * Priority:\n * 1. `device.segmentCount` if already set (from cache, MQTT discovery, or wizard)\n * 2. Minimum of positive `segment_color_setting` capability counts\n * 3. 0 if no capability advertises segments\n *\n * Why `min` over the capability caps: Govee reports `segmentedBrightness` and\n * `segmentedColorRgb` separately, and on at least one SKU (H70D1) those two\n * disagree \u2014 brightness says 10, colorRgb says 15, real device has 10.\n * Picking the smaller value is the safer starting point; MQTT discovery can\n * then grow it if the real device pushes more slots.\n *\n * @param device Target device\n */\nexport function resolveSegmentCount(device: GoveeDevice): number {\n if (typeof device.segmentCount === \"number\" && device.segmentCount > 0) {\n return device.segmentCount;\n }\n const caps = Array.isArray(device.capabilities) ? device.capabilities : [];\n let min = Number.POSITIVE_INFINITY;\n for (const c of caps) {\n if (!c || typeof c.type !== \"string\" || !c.type.includes(\"segment_color_setting\")) {\n continue;\n }\n const params = (c as { parameters?: { fields?: unknown[] } }).parameters;\n const fields = Array.isArray(params?.fields) ? params.fields : [];\n for (const f of fields) {\n if (!f || typeof f !== \"object\") {\n continue;\n }\n const fn = (f as { fieldName?: unknown }).fieldName;\n const er = (f as { elementRange?: { max?: unknown } }).elementRange;\n const rawMax = er && typeof er.max === \"number\" ? er.max : -1;\n if (fn === \"segment\" && rawMax >= 0) {\n const n = rawMax + 1;\n if (n > 0 && n < min) {\n min = n;\n }\n }\n }\n }\n return Number.isFinite(min) ? min : 0;\n}\n\n/** Protocol limit: Govee's segment bitmask is 7 bytes \u00D7 8 bits = 56 slots (0..55). */\nexport const SEGMENT_HARD_MAX = 55;\n\n/** Number of addressable segment slots (SEGMENT_HARD_MAX + 1 = 56). */\nexport const SEGMENT_COUNT_MAX = SEGMENT_HARD_MAX + 1;\n\n/** ptReal color-segment bitmask size (Govee protocol-fixed): one bit per segment, 56 segments \u2192 7 bytes. */\nexport const SEGMENT_COLOR_BITMASK_BYTES = 7;\n\n/** ptReal brightness-segment bitmask size (Govee protocol-fixed): twice the color width \u2192 14 bytes. */\nexport const SEGMENT_BRIGHTNESS_BITMASK_BYTES = 14;\n\n/**\n * Generate the stable runtime map key for a device \u2014 thin wrapper over\n * {@link mapKey} (device-key.ts), kept for the existing call sites.\n *\n */\nexport function deviceKey(sku: string, deviceId: string): string {\n return mapKey(sku, deviceId);\n}\n\n/**\n * Locate a device in the registry by SKU + raw deviceId, with normalized\n * fallback. Direct key-hit first; if that misses, scan for a normalized\n * match (device IDs come from multiple sources with different\n * colon/case conventions).\n *\n */\nexport function findDeviceBySkuAndId(\n devices: Map<string, GoveeDevice>,\n sku: string,\n deviceId: string,\n): GoveeDevice | undefined {\n const direct = devices.get(deviceKey(sku, deviceId));\n if (direct) {\n return direct;\n }\n const normalizedId = normalizeDeviceId(deviceId);\n for (const dev of devices.values()) {\n if (dev.sku === sku && normalizeDeviceId(dev.deviceId) === normalizedId) {\n return dev;\n }\n }\n return undefined;\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { normalizeDeviceId, type GoveeDevice } from \"../types\";\nimport { mapKey } from \"../device-key\";\n\n/** Parsed per-segment data from MQTT BLE packets */\nexport interface MqttSegmentData {\n /** Segment index (0-based) */\n index: number;\n /** Per-segment brightness 0-100 */\n brightness: number;\n /** Red channel 0-255 */\n r: number;\n /** Green channel 0-255 */\n g: number;\n /** Blue channel 0-255 */\n b: number;\n}\n\n/**\n * Parse AA A5 BLE notification packets from MQTT op.command.\n * 5 packets \u00D7 4 segment slots = max 20 segments per push. The device sends\n * exactly as many packets as it has physical segments \u2014 so parsing out all\n * slots (and filtering empty-slot padding) gives us a reliable count of\n * what actually exists on the strip.\n *\n * Format per slot: [Brightness 0-100] [R] [G] [B].\n *\n * An \"empty\" slot (brightness = 0 AND r = g = b = 0) is treated as padding\n * in a partially-filled final packet, not as a real unlit segment \u2014 this\n * matters for devices that don't pad their last packet to 4 slots.\n *\n * @param commands Base64-encoded BLE packets from MQTT op.command\n */\nexport function parseMqttSegmentData(commands: string[]): MqttSegmentData[] {\n if (!Array.isArray(commands)) {\n return [];\n }\n\n const segments: MqttSegmentData[] = [];\n let highestPacket = 0;\n\n for (const cmd of commands) {\n if (typeof cmd !== \"string\") {\n continue;\n }\n const bytes = Buffer.from(cmd, \"base64\");\n if (bytes.length < 20 || bytes[0] !== 0xaa || bytes[1] !== 0xa5) {\n continue;\n }\n\n // M2 \u2014 XOR checksum validation. Govee BLE packets carry an XOR over bytes\n // 0-18 in the last byte (index 19). Spoofed/malformed packets would\n // otherwise slip through and persist a wrong segmentCount.\n let xor = 0;\n for (let i = 0; i < 19; i++) {\n xor ^= bytes[i];\n }\n if (xor !== bytes[19]) {\n continue;\n }\n\n const packetNum = bytes[2];\n if (packetNum < 1 || packetNum > 5) {\n continue;\n }\n if (packetNum > highestPacket) {\n highestPacket = packetNum;\n }\n\n const baseIndex = (packetNum - 1) * 4;\n for (let slot = 0; slot < 4; slot++) {\n const segIdx = baseIndex + slot;\n const offset = 3 + slot * 4;\n segments.push({\n index: segIdx,\n brightness: bytes[offset],\n r: bytes[offset + 1],\n g: bytes[offset + 2],\n b: bytes[offset + 3],\n });\n }\n }\n\n while (segments.length > 0) {\n const tail = segments[segments.length - 1];\n if (tail.brightness === 0 && tail.r === 0 && tail.g === 0 && tail.b === 0) {\n segments.pop();\n } else {\n break;\n }\n }\n\n return segments;\n}\n\n/**\n * Resolve the authoritative segment count for a device.\n *\n * Priority:\n * 1. `device.segmentCount` if already set (from cache, MQTT discovery, or wizard)\n * 2. Minimum of positive `segment_color_setting` capability counts\n * 3. 0 if no capability advertises segments\n *\n * Why `min` over the capability caps: Govee reports `segmentedBrightness` and\n * `segmentedColorRgb` separately, and on at least one SKU (H70D1) those two\n * disagree \u2014 brightness says 10, colorRgb says 15, real device has 10.\n * Picking the smaller value is the safer starting point; MQTT discovery can\n * then grow it if the real device pushes more slots.\n *\n * @param device Target device\n */\nexport function resolveSegmentCount(device: GoveeDevice): number {\n if (typeof device.segmentCount === \"number\" && device.segmentCount > 0) {\n return device.segmentCount;\n }\n const caps = Array.isArray(device.capabilities) ? device.capabilities : [];\n let min = Number.POSITIVE_INFINITY;\n for (const c of caps) {\n if (!c || typeof c.type !== \"string\" || !c.type.includes(\"segment_color_setting\")) {\n continue;\n }\n const params = (c as { parameters?: { fields?: unknown[] } }).parameters;\n const fields = Array.isArray(params?.fields) ? params.fields : [];\n for (const f of fields) {\n if (!f || typeof f !== \"object\") {\n continue;\n }\n const fn = (f as { fieldName?: unknown }).fieldName;\n const er = (f as { elementRange?: { max?: unknown } }).elementRange;\n const rawMax = er && typeof er.max === \"number\" ? er.max : -1;\n if (fn === \"segment\" && rawMax >= 0) {\n const n = rawMax + 1;\n if (n > 0 && n < min) {\n min = n;\n }\n }\n }\n }\n return Number.isFinite(min) ? min : 0;\n}\n\n/** Protocol limit: Govee's segment bitmask is 7 bytes \u00D7 8 bits = 56 slots (0..55). */\nexport const SEGMENT_HARD_MAX = 55;\n\n/** Number of addressable segment slots (SEGMENT_HARD_MAX + 1 = 56). */\nexport const SEGMENT_COUNT_MAX = SEGMENT_HARD_MAX + 1;\n\n/** ptReal color-segment bitmask size (Govee protocol-fixed): one bit per segment, 56 segments \u2192 7 bytes. */\nexport const SEGMENT_COLOR_BITMASK_BYTES = 7;\n\n/** ptReal brightness-segment bitmask size (Govee protocol-fixed): twice the color width \u2192 14 bytes. */\nexport const SEGMENT_BRIGHTNESS_BITMASK_BYTES = 14;\n\n/**\n * Generate the stable runtime map key for a device \u2014 thin wrapper over\n * {@link mapKey} (device-key.ts), kept for the existing call sites.\n *\n */\nexport function deviceKey(sku: string, deviceId: string): string {\n return mapKey(sku, deviceId);\n}\n\n/**\n * Locate a device in the registry by SKU + raw deviceId, with normalized\n * fallback. Direct key-hit first; if that misses, scan for a normalized\n * match (device IDs come from multiple sources with different\n * colon/case conventions).\n *\n */\nexport function findDeviceBySkuAndId(\n devices: Map<string, GoveeDevice>,\n sku: string,\n deviceId: string,\n): GoveeDevice | undefined {\n const direct = devices.get(deviceKey(sku, deviceId));\n if (direct) {\n return direct;\n }\n const normalizedId = normalizeDeviceId(deviceId);\n for (const dev of devices.values()) {\n if (dev.sku === sku && normalizeDeviceId(dev.deviceId) === normalizedId) {\n return dev;\n }\n }\n return undefined;\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -32,7 +32,6 @@ __export(device_manager_exports, {
32
32
  SEGMENT_HARD_MAX: () => import_lookups2.SEGMENT_HARD_MAX,
33
33
  buildCapabilitiesFromAppEntry: () => import_mapping2.buildCapabilitiesFromAppEntry,
34
34
  cloudDeviceToGoveeDevice: () => import_mapping2.cloudDeviceToGoveeDevice,
35
- getEffectiveSegmentIndices: () => import_lookups2.getEffectiveSegmentIndices,
36
35
  parseMqttSegmentData: () => import_lookups2.parseMqttSegmentData,
37
36
  resolveSegmentCount: () => import_lookups2.resolveSegmentCount
38
37
  });
@@ -1270,7 +1269,6 @@ class DeviceManager {
1270
1269
  SEGMENT_HARD_MAX,
1271
1270
  buildCapabilitiesFromAppEntry,
1272
1271
  cloudDeviceToGoveeDevice,
1273
- getEffectiveSegmentIndices,
1274
1272
  parseMqttSegmentData,
1275
1273
  resolveSegmentCount
1276
1274
  });