iobroker.govee-smart 2.38.2 → 2.38.3
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 +4 -10
- package/admin/custom/mf-manifest.json +1 -1
- package/build/lib/err-message.js +42 -0
- package/build/lib/err-message.js.map +7 -0
- package/build/lib/types.js +5 -18
- package/build/lib/types.js.map +2 -2
- package/io-package.json +14 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,10 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
|
|
|
103
103
|
### **WORK IN PROGRESS**
|
|
104
104
|
-->
|
|
105
105
|
|
|
106
|
+
### 2.38.3 (2026-09-17)
|
|
107
|
+
|
|
108
|
+
- Changed: Internal cleanup. No user-facing changes.
|
|
109
|
+
|
|
106
110
|
### 2.38.2 (2026-09-17)
|
|
107
111
|
|
|
108
112
|
- Changed: Internal refactoring. No user-facing changes.
|
|
@@ -121,16 +125,6 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
|
|
|
121
125
|
|
|
122
126
|
- Fixed: The instance settings open on the Configuration tab again also when the admin keeps its settings on the server — 2.37.0 handled the browser storage only, so every open still landed on Expert
|
|
123
127
|
|
|
124
|
-
### 2.37.0 (2026-09-15)
|
|
125
|
-
|
|
126
|
-
- Changed: The two folders "snapshots" and "diagnostics" at the top of the object tree are gone — a saved local snapshot is kept with its device, and no diagnostics report is stored any more
|
|
127
|
-
- Changed: A diagnostics report is generated and downloaded from the Expert tab only — the adapter keeps no copy in the instance, and the copies left by earlier versions are removed at the first start
|
|
128
|
-
- Changed: Local snapshots saved by earlier versions are carried over into their device objects at the first start, so nothing you saved is lost when the folder disappears
|
|
129
|
-
- Fixed: The instance settings open on the Configuration tab again — the admin remembered the last tab you used, and once you had visited the Expert tab every later visit started there
|
|
130
|
-
- Fixed: The device icons in the object tree are visible on the dark themes again — they were drawn in plain black, invisible on a dark background, and now take the row's text colour
|
|
131
|
-
- Fixed: A group's "membersUnreachable" datapoint is created together with the group — it used to appear only with the first reachability check, up to 20 seconds after the rest of the group
|
|
132
|
-
- Changed: The admin warns when another adapter on the same host takes the LAN port 4002 — the port shows as a fixed field in the adapter settings next to the network interface
|
|
133
|
-
|
|
134
128
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
135
129
|
|
|
136
130
|
## Support
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"ConfigCustomGoveeSegmentSet","name":"ConfigCustomGoveeSegmentSet","metaData":{"name":"ConfigCustomGoveeSegmentSet","type":"app","buildInfo":{"buildVersion":"1.0.0","buildName":"ConfigCustomGoveeSegmentSet"},"remoteEntry":{"name":"customComponents.js","path":"","type":"module"},"ssrRemoteEntry":{"name":"customComponents.ssr.js","path":"","type":"module"},"types":{"path":"","name":""
|
|
1
|
+
{"id":"ConfigCustomGoveeSegmentSet","name":"ConfigCustomGoveeSegmentSet","metaData":{"name":"ConfigCustomGoveeSegmentSet","type":"app","buildInfo":{"buildVersion":"1.0.0","buildName":"ConfigCustomGoveeSegmentSet"},"remoteEntry":{"name":"customComponents.js","path":"","type":"module"},"ssrRemoteEntry":{"name":"customComponents.ssr.js","path":"","type":"module"},"types":{"path":"","name":""},"globalName":"ConfigCustomGoveeSegmentSet","pluginVersion":"0.2.5","publicPath":"auto"},"shared":[{"id":"ConfigCustomGoveeSegmentSet:@iobroker/gui-components","name":"@iobroker/gui-components","version":"10.0.12","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__prebuild___mf_0_iobroker_mf_1_gui_mf_2_components__prebuild__-DXxYvebj.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:@iobroker/json-config","name":"@iobroker/json-config","version":"9.0.16","singleton":true,"requiredVersion":"*","assets":{"js":{"async":["assets/lib-D_TOKWDo.js"],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_json_mf_2_config__loadShare__.js-Bh78u5iG.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:@mui/icons-material","name":"@mui/icons-material","version":"9.2.0","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BqaV2gc_.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:@mui/material","name":"@mui/material","version":"9.2.0","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BqaV2gc_.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:react","name":"react","version":"19.2.8","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BqaV2gc_.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:react-dom","name":"react-dom","version":"19.2.8","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BqaV2gc_.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:react/jsx-runtime","name":"react/jsx-runtime","version":"19.2.8","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare___mf_0_iobroker_mf_1_gui_mf_2_components__loadShare__.js-BqaV2gc_.js"]},"css":{"async":[],"sync":[]}}},{"id":"ConfigCustomGoveeSegmentSet:react-dom/client","name":"react-dom/client","version":"19.2.8","singleton":true,"requiredVersion":"*","assets":{"js":{"async":[],"sync":["assets/_virtual_mf___mfe_internal__ConfigCustomGoveeSegmentSet__mf_owner__1__loadShare__react_mf_2_dom_mf_1_client__loadShare__.js-ld5_knuQ.js"]},"css":{"async":[],"sync":[]}}}],"remotes":[],"exposes":[{"id":"ConfigCustomGoveeSegmentSet:Components","name":"Components","assets":{"js":{"async":["assets/lib-D_TOKWDo.js"],"sync":["assets/Components-qML8u7V6.js"]},"css":{"async":[],"sync":[]}},"path":"./Components"}]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
var err_message_exports = {};
|
|
20
|
+
__export(err_message_exports, {
|
|
21
|
+
errMessage: () => errMessage
|
|
22
|
+
});
|
|
23
|
+
module.exports = __toCommonJS(err_message_exports);
|
|
24
|
+
function errMessage(e) {
|
|
25
|
+
var _a;
|
|
26
|
+
if (e instanceof Error) {
|
|
27
|
+
return e.message;
|
|
28
|
+
}
|
|
29
|
+
if (typeof e === "object" && e !== null) {
|
|
30
|
+
try {
|
|
31
|
+
return (_a = JSON.stringify(e)) != null ? _a : Object.prototype.toString.call(e);
|
|
32
|
+
} catch {
|
|
33
|
+
return Object.prototype.toString.call(e);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return String(e);
|
|
37
|
+
}
|
|
38
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
39
|
+
0 && (module.exports = {
|
|
40
|
+
errMessage
|
|
41
|
+
});
|
|
42
|
+
//# sourceMappingURL=err-message.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/err-message.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Render an unknown error to a string for logging. Returns `e.message` for\n * Error values (the stack stays out of warn/error lines \u2014 debug paths that\n * want the trace render it themselves) and `String(...)` for every primitive.\n *\n * A thrown PLAIN OBJECT gets its own branch: `String({ code: \"ECONNRESET\" })`\n * is `[object Object]`, so a rejected fetch/HTTP object used to reach the log\n * with nothing in it a reader could act on. `JSON.stringify` renders the\n * fields instead; it returns `undefined` for a value it cannot represent and\n * THROWS on a circular structure or a BigInt field \u2014 a logger that throws\n * inside a catch block turns a handled failure into an unhandled rejection,\n * so both outcomes fall back to the type tag.\n *\n * This file stays import-free on purpose: the admin component under\n * `src-admin/` imports it too (relative path into `src/lib/`, measured to work\n * with the Module-Federation build), so it lands in both bundles \u2014 a\n * dependency here would be dragged into both.\n *\n * @param e Caught value (usually `unknown` in catch blocks)\n */\nexport function errMessage(e: unknown): string {\n if (e instanceof Error) {\n return e.message;\n }\n if (typeof e === \"object\" && e !== null) {\n try {\n return JSON.stringify(e) ?? Object.prototype.toString.call(e);\n } catch {\n return Object.prototype.toString.call(e);\n }\n }\n return String(e);\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAoBO,SAAS,WAAW,GAAoB;AApB/C;AAqBE,MAAI,aAAa,OAAO;AACtB,WAAO,EAAE;AAAA,EACX;AACA,MAAI,OAAO,MAAM,YAAY,MAAM,MAAM;AACvC,QAAI;AACF,cAAO,UAAK,UAAU,CAAC,MAAhB,YAAqB,OAAO,UAAU,SAAS,KAAK,CAAC;AAAA,IAC9D,QAAQ;AACN,aAAO,OAAO,UAAU,SAAS,KAAK,CAAC;AAAA,IACzC;AAAA,EACF;AACA,SAAO,OAAO,CAAC;AACjB;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/build/lib/types.js
CHANGED
|
@@ -25,7 +25,7 @@ __export(types_exports, {
|
|
|
25
25
|
coerceFiniteNumber: () => coerceFiniteNumber,
|
|
26
26
|
deviceLabel: () => deviceLabel,
|
|
27
27
|
disambiguateLabels: () => disambiguateLabels,
|
|
28
|
-
errMessage: () => errMessage,
|
|
28
|
+
errMessage: () => import_err_message.errMessage,
|
|
29
29
|
formatGatewayLabel: () => formatGatewayLabel,
|
|
30
30
|
hexToRgb: () => hexToRgb,
|
|
31
31
|
logDedup: () => logDedup,
|
|
@@ -38,6 +38,7 @@ __export(types_exports, {
|
|
|
38
38
|
rgbToHex: () => rgbToHex
|
|
39
39
|
});
|
|
40
40
|
module.exports = __toCommonJS(types_exports);
|
|
41
|
+
var import_err_message = require("./err-message");
|
|
41
42
|
function normalizeDeviceId(id) {
|
|
42
43
|
if (typeof id !== "string") {
|
|
43
44
|
return "";
|
|
@@ -82,7 +83,7 @@ function classifyError(err) {
|
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
|
-
const msg = errMessage(err);
|
|
86
|
+
const msg = (0, import_err_message.errMessage)(err);
|
|
86
87
|
if (msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("ENETUNREACH") || msg.includes("ECONNRESET")) {
|
|
87
88
|
return "NETWORK";
|
|
88
89
|
}
|
|
@@ -103,22 +104,8 @@ function classifyError(err) {
|
|
|
103
104
|
}
|
|
104
105
|
return "UNKNOWN";
|
|
105
106
|
}
|
|
106
|
-
function errMessage(e) {
|
|
107
|
-
var _a;
|
|
108
|
-
if (e instanceof Error) {
|
|
109
|
-
return e.message;
|
|
110
|
-
}
|
|
111
|
-
if (typeof e === "object" && e !== null) {
|
|
112
|
-
try {
|
|
113
|
-
return (_a = JSON.stringify(e)) != null ? _a : Object.prototype.toString.call(e);
|
|
114
|
-
} catch {
|
|
115
|
-
return Object.prototype.toString.call(e);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return String(e);
|
|
119
|
-
}
|
|
120
107
|
function logRejected(log, context) {
|
|
121
|
-
return (e) => log.debug(`${context}: ${errMessage(e)}`);
|
|
108
|
+
return (e) => log.debug(`${context}: ${(0, import_err_message.errMessage)(e)}`);
|
|
122
109
|
}
|
|
123
110
|
function maskSecret(secret) {
|
|
124
111
|
if (typeof secret !== "string" || secret.length <= 4) {
|
|
@@ -138,7 +125,7 @@ function coerceFiniteNumber(raw) {
|
|
|
138
125
|
}
|
|
139
126
|
function logDedup(log, last, context, err) {
|
|
140
127
|
const category = classifyError(err);
|
|
141
|
-
const msg = errMessage(err);
|
|
128
|
+
const msg = (0, import_err_message.errMessage)(err);
|
|
142
129
|
if (category !== last) {
|
|
143
130
|
log.warn(`${context}: ${msg}`);
|
|
144
131
|
} else {
|
package/build/lib/types.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/types.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Result of a cloud-load attempt. The retry loop inspects `reason` to handle\n * rate limits and permanent failures correctly.\n */\nexport type CloudLoadResult =\n /** Success */\n | { ok: true }\n /** Network/timeout \u2014 just retry later */\n | { ok: false; reason: \"transient\" }\n /** Govee 429 \u2014 respect retry-after */\n | { ok: false; reason: \"rate-limited\"; retryAfterMs: number }\n /** Auth error (invalid API key) \u2014 NO retry, user must fix the config */\n | { ok: false; reason: \"auth-failed\"; message: string };\n\n// --- Cloud API v2 Types ---\n\n/** Device from Cloud API GET /router/api/v1/user/devices */\nexport interface CloudDevice {\n /** Product model (e.g. H6160) */\n sku: string;\n /** Unique device identifier */\n device: string;\n /** User-assigned device name */\n deviceName: string;\n /** Device category (e.g. \"devices.types.light\") */\n type: string;\n /** Device capabilities from Cloud API */\n capabilities: CloudCapability[];\n}\n\n/** A single capability from the Cloud API */\nexport interface CloudCapability {\n /** Capability type (e.g. \"devices.capabilities.on_off\") */\n type: string;\n /** Capability instance (e.g. \"powerSwitch\", \"brightness\") */\n instance: string;\n /** Parameter definition for this capability (optional \u2014 API can omit it) */\n parameters?: CapabilityParameters;\n}\n\n/** Parameter definition for a capability */\nexport interface CapabilityParameters {\n /** Value data type */\n dataType: \"ENUM\" | \"INTEGER\" | \"STRUCT\";\n /** Available options for ENUM type */\n options?: CapabilityOption[];\n /** Value range for INTEGER type */\n range?: { min: number; max: number; precision: number };\n /** Unit of measurement */\n unit?: string;\n /** Field definitions for STRUCT type */\n fields?: CapabilityField[];\n}\n\n/** ENUM option */\nexport interface CapabilityOption {\n /** Display name of the option. Absent on an unnamed level (see `options`). */\n name?: string;\n /** Option value. Optional and sometimes explicitly `null` (grouping entry). */\n value?: number | string | Record<string, unknown> | null;\n /** Nested per-mode sub-list. Its entries often have a value and NO name. */\n options?: CapabilityOption[];\n /** A mode whose level is a range instead of a list (H7143 Auto: 40-80). */\n range?: { min: number; max: number; precision?: number };\n /** Fixed value for a mode that has no levels at all (Govee sends 0). */\n defaultValue?: number;\n}\n\n/** An ENUM option that carries a usable display name. */\nexport type NamedCapabilityOption = CapabilityOption & { name: string };\n\n/** STRUCT field definition */\nexport interface CapabilityField {\n /** Field name identifier */\n fieldName: string;\n /** Value data type */\n dataType?: \"ENUM\" | \"INTEGER\" | \"STRUCT\" | \"Array\";\n /** Available options for ENUM fields */\n options?: CapabilityOption[];\n /** Value range for INTEGER fields */\n range?: { min: number; max: number; precision: number };\n /** Element range for Array fields (0-based, segment count = max + 1) */\n elementRange?: { min: number; max: number };\n /** Default the device declares for this field (`\"Celsius\"`, `0`, `null`). */\n defaultValue?: unknown;\n /**\n * Unit carried on the field itself (`\"unit.celsius\"`) \u2014 some payloads label\n * the temperature field instead of declaring a separate `unit` field.\n */\n unit?: string;\n /** Whether this field is required */\n required?: boolean;\n}\n\n/** Cloud API device list response */\nexport interface CloudDeviceListResponse {\n /** Response status code */\n code: number;\n /** Response message */\n message: string;\n /** List of devices */\n data: CloudDevice[];\n}\n\n/**\n * Answer of POST /router/api/v1/device/state. Note the envelope: `payload`\n * with `msg`, as on the scene endpoints \u2014 NOT `data`/`message`, which is the\n * envelope of the device list (measured on three captures, issue #47).\n */\nexport interface CloudDeviceStateResponse {\n /** Echo of the request id */\n requestId?: string;\n /** Response message \u2014 `msg` on this endpoint */\n msg?: string;\n /** Response status code */\n code: number;\n /** Device state data */\n payload?: {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /** Current capability states */\n capabilities: CloudStateCapability[];\n };\n}\n\n/** A capability value from state response */\nexport interface CloudStateCapability {\n /** Capability type */\n type: string;\n /** Capability instance */\n instance: string;\n /** Current state value */\n state: { value: unknown };\n}\n\n/** Cloud API scenes response \u2014 payload contains capabilities with options */\nexport interface CloudScenesResponse {\n /** Response status code */\n code: number;\n /** Response message */\n message: string;\n /** Payload with capabilities (scenes endpoint format) */\n payload?: {\n /** Scene capabilities with options */\n capabilities: CloudCapability[];\n };\n}\n\n/** A scene/snapshot option from the Cloud API */\nexport interface CloudScene {\n /** Display name */\n name: string;\n /** Activation value (passed directly to control endpoint) \u2014 object for scenes, integer for snapshots */\n value: Record<string, unknown> | number;\n}\n\n// --- AWS IoT MQTT Types ---\n\n/** Login response from app2.govee.com */\nexport interface GoveeLoginResponse {\n /** API status code (200 = success) */\n status?: number;\n /** API status message */\n message?: string;\n /** Client authentication data (missing on auth failure) */\n client?: {\n /** Bearer token for API calls */\n token: string;\n /** Account identifier (numeric) */\n accountId: number | string;\n /** MQTT topic for status updates */\n topic: string;\n /** Token TTL in seconds. Govee uses this name; some responses also send `tokenExpireCycle`. */\n token_expire_cycle?: number;\n tokenExpireCycle?: number;\n };\n}\n\n/**\n * Bundle of credentials persisted across adapter restarts so we don't have\n * to log in every time (which would spam the Govee 2FA email each restart).\n * Populated from `native` after a successful login, fed back into the next\n * MQTT connect.\n */\nexport interface PersistedMqttCredentials {\n /** Govee bearer token from /v1/login. */\n bearerToken: string;\n /** AWS IoT endpoint hostname (xxx-ats.iot.<region>.amazonaws.com). */\n iotEndpoint: string;\n /** Base64-encoded PKCS#12 cert bundle from /iot/key. */\n p12Cert: string;\n /** Password for the P12 cert (also from /iot/key). */\n p12Pass: string;\n /** Govee account-id, used to build the MQTT clientId. */\n accountId: string;\n /** MQTT topic the account subscribes to for status push. */\n accountTopic: string;\n /** ms-timestamp at which the bearer token expires (Date.now() + ttlMs). */\n tokenExpiresAt: number;\n}\n\n/** IoT key response from app2.govee.com */\nexport interface GoveeIotKeyResponse {\n /** IoT credential data */\n data?: {\n /** AWS IoT endpoint hostname */\n endpoint: string;\n /** Base64-encoded PKCS12 certificate */\n p12: string;\n /** Password for the PKCS12 certificate */\n p12Pass: string;\n };\n}\n\n/** MQTT status update received on account topic */\nexport interface MqttStatusUpdate {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /**\n * Packet kind Govee sent. `\"online\"` is a dedicated reachability packet \u2014\n * measured on four real user reports (issues #22/#25/#26): H66A1 sends\n * `{cmd:\"online\", state:{result:1}}`, H6199 `{cmd:\"online\",\n * state:{connected:\"true\"}}`. Until 2.30.0 the client dropped this field and\n * nothing downstream could tell a reachability packet from a state push.\n */\n cmd?: string;\n /**\n * Govee's transaction id, e.g. `x_1788603714892008`: its first 13 digits are\n * the millisecond timestamp of the request the device is answering. Kept\n * because it dates the packet ITSELF \u2014 a retained message the broker replays\n * on (re)subscribe carries the stamp of when it was originally sent, not of\n * its arrival, and `readDevicePushAt` uses exactly that to tell a fresh sign\n * of life from an old echo.\n */\n transaction?: string;\n /** Device state values */\n state?: {\n /** Power state (1 = on, 0 = off) */\n onOff?: number;\n /** Brightness percentage 0-100 */\n brightness?: number;\n /** RGB color values */\n color?: { r: number; g: number; b: number };\n /** Color temperature in Kelvin */\n colorTemInKelvin?: number;\n /**\n * Reachability as text (\"true\"/\"false\"). Carried in every packet kind for\n * the devices that send it (status, pt, online), so it is always usable.\n *\n * Which of these two fields a device uses correlates with the protocol\n * generation Govee also puts in the envelope (`pactType` 1 \u2192 this one,\n * 2 \u2192 `result`, measured across all 75 captured packets). The adapter does\n * NOT key off it: the account list carries devices with no `pactType` at\n * all, so `readReportedReachability` decides on the FIELD SHAPE that is\n * actually present. The envelope field was parsed and handed on for a\n * version without a single reader \u2014 dropped, with the observation kept\n * here where it explains something.\n */\n connected?: string;\n /**\n * Reachability as number (1/0), but ONLY inside a `cmd:\"online\"` packet.\n * In `cmd:\"status\"` / `cmd:\"ptReal\"` the same field is an operation result\n * code and says nothing about reachability.\n */\n result?: number;\n };\n /** Operation data */\n op?: {\n /** Command strings */\n command?: string[];\n };\n}\n\n// --- LAN API Types ---\n\n/** LAN discovery response */\nexport interface LanDevice {\n /** Device IP address */\n ip: string;\n /** Device identifier */\n device: string;\n /** Product model */\n sku: string;\n}\n\n/** LAN status response */\nexport interface LanStatus {\n /** Power state (1 = on, 0 = off) */\n onOff: number;\n /** Brightness percentage 0-100 */\n brightness: number;\n /** RGB color values */\n color: { r: number; g: number; b: number };\n /** Color temperature in Kelvin */\n colorTemInKelvin: number;\n}\n\n/** LAN command message wrapper */\nexport interface LanMessage {\n /** Message payload */\n msg: {\n /** Command name */\n cmd: string;\n /** Command data */\n data: Record<string, unknown>;\n };\n}\n\n// --- Internal Device Model ---\n\n/** Unified device representation used by device-manager */\nexport interface GoveeDevice {\n /** Product model (e.g. H6160) */\n sku: string;\n /** Unique device ID (8-byte hex) */\n deviceId: string;\n /** Display name (from Cloud or SKU fallback) */\n name: string;\n /** Device type from Cloud (e.g. \"devices.types.light\") */\n type: string;\n /** LAN IP address if discovered */\n lanIp?: string;\n /** Capabilities from Cloud API */\n capabilities: CloudCapability[];\n /** Available light scenes (from Cloud scenes endpoint) */\n scenes: CloudScene[];\n /** Available DIY scenes (from Cloud scenes endpoint) */\n diyScenes: CloudScene[];\n /** Available snapshots (from Cloud scenes endpoint) */\n snapshots: CloudScene[];\n /** Scene library entries with scene codes for ptReal (from undocumented API) */\n sceneLibrary: Array<{\n name: string;\n /** BLE scene code (> 0 = usable via ptReal) */\n sceneCode: number;\n /** Base64-encoded BLE scene parameter data */\n scenceParam?: string;\n /** Speed control info (from scene library API) */\n speedInfo?: {\n /** Whether this scene supports speed adjustment */\n supSpeed: boolean;\n /** Default speed level index */\n speedIndex: number;\n /** JSON config with per-level moveIn/color/bright overrides */\n config: string;\n };\n }>;\n /** Music effect library entries for ptReal local music mode (authenticated API) */\n musicLibrary: Array<{\n name: string;\n /** BLE music effect code */\n musicCode: number;\n /** Base64-encoded BLE parameter data */\n scenceParam?: string;\n /** Music sub-mode index */\n mode?: number;\n }>;\n /** DIY light effect library entries for ptReal local DIY activation (authenticated API) */\n diyLibrary: Array<{\n name: string;\n /** BLE DIY effect code */\n diyCode: number;\n /** Base64-encoded BLE parameter data */\n scenceParam?: string;\n }>;\n /** Supported feature flags per SKU (from authenticated API) */\n skuFeatures: Record<string, unknown> | null;\n /** Group member devices (only for BaseGroup) */\n groupMembers?: { sku: string; deviceId: string }[];\n /** Last known state */\n state: DeviceState;\n /**\n * Number of LED segments on this device. Resolved by\n * {@link resolveSegmentCount} from Cache \u2192 MQTT-discovered \u2192 Cloud min.\n * Persisted via SKU cache so learned values survive restarts.\n */\n segmentCount?: number;\n /** BLE packets per cloud snapshot for ptReal activation [snapshotIdx][cmdIdx][packetBase64] */\n snapshotBleCmds?: string[][][];\n /** Current speed level for scene playback (0-based, applied on next scene activation) */\n sceneSpeed?: number;\n /**\n * Set to true after a Cloud scene-fetch attempt completed (success or confirmed empty).\n * Used to distinguish \"not yet tried\" from \"legitimately empty\" \u2014 prevents endless refetch.\n */\n scenesChecked?: boolean;\n /**\n * Manual-mode flag for cut strips (physical segments with gaps). When true,\n * `manualSegments` lists the indices that actually light up; all others\n * (within `0..segmentCount-1`) are skipped. Orthogonal to `segmentCount`:\n * the total is still the strip's real length, manualMode just masks gaps.\n */\n manualMode?: boolean;\n /**\n * Explicit physical segment indices (parsed from `segments.manual_list` state).\n * Only used when `manualMode=true`. Indices must be within `0..segmentCount-1`.\n */\n manualSegments?: number[];\n /**\n * Timestamp (ms) when device was last seen via LAN discovery or MQTT status push.\n * Used for cache pruning \u2014 stale entries without recent network sighting get removed.\n */\n lastSeenOnNetwork?: number;\n /**\n * When the reachability refresh last ISSUED a cloud state read for this\n * device \u2014 the attempt, not its outcome.\n *\n * Needed because a call that answers nothing usable leaves the evidence\n * untouched, so the age test that triggered it would trigger again on the\n * very next tick, forever. Runtime-only: it is not persisted, and a restart\n * simply allows one immediate attempt.\n */\n lastReachabilityRefreshAt?: number;\n /**\n * Consecutive account-reconcile passes this device was missing from its\n * authoritative account list (Cloud `/user/devices` for lights/appliances,\n * App-API list for sensors, group list for BaseGroups) while not\n * LAN-reachable. Persisted via the SKU cache so the debounce survives\n * restarts; reset to 0 the instant the device reappears in any source or is\n * seen on LAN. Reaching the evict threshold removes the device (irreversible).\n */\n accountMissCount?: number;\n /**\n * Human-readable identifier of the Govee gateway this device reaches the\n * cloud through, when it has no own WiFi (BLE\u2192gateway sensors like the H5109\n * pool thermometer behind an H5042). Format `<gateway SKU> (<gateway BLE\n * name>)`, e.g. `H5042 (ihoment_H5042_3795)`. Set from the App-API\n * `settings.gatewayInfo`; **set-only, never cleared** once seen (sticky), so\n * a flaky poll that omits it can't churn the object tree. When present the\n * device shows `info.gateway` instead of an always-empty `info.ip`.\n */\n gateway?: string;\n /**\n * The gateway's own device id, straight from `gatewayInfo.device`. Until\n * 2.30.0 only `sku` + `bleName` were used (for the display label) and this was\n * dropped \u2014 but it is the only thing that links a battery sensor to the\n * gateway it hangs off, and krobi's rule is that the GATEWAY is the\n * reachability device for anything behind it (2026-09-03).\n */\n gatewayDeviceId?: string;\n /**\n * Timestamp (ms) when device last replied to a LAN-direct probe (multicast\n * discovery or unicast devStatus). Only set by the LAN-Discovery / LAN-Status\n * paths \u2014 NOT by MQTT-push (broker buffering risk) and NOT by Cloud caps.\n * Used as the sole truth-source for `info.online` of Lights via the\n * StateManager.syncInfoOnline resolver (90s freshness window).\n */\n lastLanReplyAt?: number;\n /**\n * When this device last answered on the LOCAL interface \u2014 PERSISTED, unlike\n * `lastLanReplyAt` (which is a live freshness stamp and deliberately dropped\n * on save, see device-manager/cache.ts).\n *\n * Why a second stamp is needed: `lanIp` is re-discovered on every restart, so\n * right after a start every light looks like a cloud-only device for one scan\n * cycle. Deciding \"is this LAN-driven?\" on `lanIp` would hand those lights to\n * Govee's stale cloud cache in that window \u2014 that was exactly the 2.29.0\n * false-green. This stamp survives the restart and answers the question\n * honestly.\n *\n * It expires (LAN_CAPABLE_MEMORY_MS) so a device whose local API the user\n * switches OFF eventually moves to the cloud path instead of staying grey\n * forever.\n */\n lastLanSeenAt?: number;\n /** Which channels are available */\n channels: {\n /** LAN UDP reachable */\n lan: boolean;\n /** MQTT connected */\n mqtt: boolean;\n /** Cloud API available */\n cloud: boolean;\n };\n}\n\n/** Current device state */\nexport interface DeviceState {\n /** Whether device is reachable */\n online: boolean;\n /**\n * What Govee itself last SAID about reachability \u2014 an explicit `online`\n * capability and nothing else (`applyOnlineCap`: cloud state read, App-API\n * poll, OpenAPI-MQTT event). `undefined` means Govee has never made that\n * statement for this device.\n *\n * The distinction matters because \"Govee says offline\" and \"nobody ever said\n * anything\" must not resolve to the same marker. Lives in `state` (not on the\n * device) so it is runtime-only and never reaches the cache.\n *\n * Strictly separate from {@link cloudLivenessAt}: a statement and the mere\n * arrival of a packet used to share this one slot, so whichever came LAST\n * won. An event arriving seconds after Govee reported `online:false`\n * overwrote that `false` with `true` for the next 30 minutes \u2014 exactly the\n * precedence the architecture doc has always claimed to enforce (\"an explicit\n * report beats the mere arrival of a packet\") and did not.\n */\n cloudReportedOnline?: boolean;\n /**\n * When that report was heard. Evidence has to be able to grow old: without a\n * timestamp a device that said \"online\" once would read as reachable forever,\n * sitting unplugged in a drawer. Renewed by the account push, by the 2-minute\n * account list, and by the reachability refresh\n * ({@link DeviceManager.refreshExpiringReachability}) \u2014 which is the only\n * renewer that needs no account credentials.\n */\n cloudReportedOnlineAt?: number;\n /**\n * When Govee last delivered anything at all FOR this device \u2014 a reading, an\n * event, a capability list \u2014 without saying whether it is reachable.\n *\n * That arrival is real evidence: the payload exists because the device spoke.\n * It is weaker than a report, though, and only ever positive \u2014 there is no\n * \"silence means offline\" here, silence is simply the stamp growing old. Kept\n * apart from {@link cloudReportedOnline} so an explicit `offline` can no\n * longer be undone by the next packet that happens to arrive.\n *\n * Runtime-only, like the two above.\n */\n cloudLivenessAt?: number;\n /**\n * When the device ITSELF last pushed a status packet over the account broker\n * \u2014 its own voice, as opposed to what Govee's lists say about it. Dated by\n * the packet's transaction stamp where it carries one (so a retained replay\n * dates itself old), else by arrival. For the evidence window\n * (`CLOUD_ONLINE_EVIDENCE_TTL_MS`) this holds against a POLLED \"offline\":\n * Govee's device-list flag is measured to stick (gateway sensors, #18/#31)\n * and to lag (2026-05-13 capture), while a packet from the device cannot lie\n * about the device having spoken. An explicit `cmd:\"online\"` packet is an\n * event, not a poll, and still wins. Runtime-only, never cached.\n */\n devicePushAt?: number;\n /**\n * Reachability of this device's gateway, resolved by the DeviceManager (which\n * is the only place that can see other devices). `false` caps this device's\n * reachability: a sensor behind a dead gateway is not reachable no matter how\n * fresh its last reading looked. `undefined` means no gateway, or a gateway\n * that is not in the account list \u2014 then nothing is capped.\n */\n gatewayOnline?: boolean;\n /** Power on/off */\n power?: boolean;\n /** Brightness 0-100 */\n brightness?: number;\n /** Color as \"#RRGGBB\" hex string */\n colorRgb?: string;\n /** Color temperature in Kelvin */\n colorTemperature?: number;\n /** Additional dynamic state values */\n [key: string]: unknown;\n}\n\n/**\n * What a state patch CHANGED, for consumers that must act on a transition\n * rather than on the reported value. Carried next to the patch, never inside\n * it: the patch is merged into `device.state` and shows up in diagnostics.\n * Sources that only report changes (account push, cloud merge) pass nothing.\n */\nexport interface DeviceStateChanges {\n /** `power` differs from what the device reported before (a switch-on or -off). */\n powerFlipped: boolean;\n}\n\n/**\n * Normalize device ID \u2014 remove colons, lowercase.\n * Returns empty string if input is not a string (defensive against malformed API data).\n *\n * @param id Raw device identifier\n */\nexport function normalizeDeviceId(id: string): string {\n if (typeof id !== \"string\") {\n return \"\";\n }\n return id.replace(/:/g, \"\").toLowerCase();\n}\n\n/**\n * Consistent device label for log lines: always name plus model, e.g.\n * `Wifi Thermometer (H5179)`. Falls back to the bare SKU when no display\n * name is known (or the name IS the SKU) \u2014 never produces `H5179 (H5179)`.\n *\n * @param device Anything carrying a display name and a SKU (GoveeDevice, cached entry, \u2026)\n * @param device.name Optional display name (user-assigned or Cloud-provided)\n * @param device.sku Product model\n */\nexport function deviceLabel(device: { name?: string; sku: string }): string {\n const name = typeof device.name === \"string\" ? device.name.trim() : \"\";\n return name && name !== device.sku ? `${name} (${device.sku})` : device.sku;\n}\n\n/**\n * Human-readable label for the Govee gateway a device reaches the cloud\n * through, derived from the App-API `settings.gatewayInfo`. Returns\n * `<SKU> (<BLE name>)`, or just the SKU when no BLE name is present, or\n * `undefined` when there is no usable gateway SKU \u2014 the caller then leaves the\n * device on the normal `info.ip` path and never creates an `info.gateway` with\n * a garbage value. Auth secrets (`secretCode`, `topic`) are deliberately ignored.\n *\n * @param gatewayInfo The `gatewayInfo` object from the App-API device settings\n */\nexport function formatGatewayLabel(gatewayInfo: { sku?: unknown; bleName?: unknown } | undefined): string | undefined {\n if (!gatewayInfo || typeof gatewayInfo !== \"object\") {\n return undefined;\n }\n const sku = typeof gatewayInfo.sku === \"string\" ? gatewayInfo.sku.trim() : \"\";\n if (!sku) {\n return undefined;\n }\n const bleName = typeof gatewayInfo.bleName === \"string\" ? gatewayInfo.bleName.trim() : \"\";\n return bleName ? `${sku} (${bleName})` : sku;\n}\n\n/** Error categories for dedup logging */\nexport type ErrorCategory =\n | \"NETWORK\"\n | \"TIMEOUT\"\n | \"AUTH\"\n | \"RATE_LIMIT\"\n /** Govee returned 454 with no code in the request body \u2014 user must request a verification code via Settings. */\n | \"VERIFICATION_PENDING\"\n /** Govee returned 454 with code already sent, or 455 \u2014 code is wrong or expired, user must request a fresh one. */\n | \"VERIFICATION_FAILED\"\n | \"UNKNOWN\";\n\n/**\n * Classify an error into a category for dedup logging.\n * Only the category is used as key \u2014 not context or full message.\n *\n * @param err Error to classify\n */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Error) {\n const code = (err as NodeJS.ErrnoException).code;\n if (\n code === \"ECONNREFUSED\" ||\n code === \"EHOSTUNREACH\" ||\n code === \"ENOTFOUND\" ||\n code === \"ENETUNREACH\" ||\n code === \"ECONNRESET\" ||\n code === \"EAI_AGAIN\"\n ) {\n return \"NETWORK\";\n }\n if (code === \"ETIMEDOUT\" || err.message.includes(\"timed out\")) {\n return \"TIMEOUT\";\n }\n // mqtt.js CONNACK reason codes: 4 = bad user name or password, 5 = not\n // authorized \u2014 the broker rejected the credentials (API key / account).\n const reasonCode: unknown = (err as { code?: unknown }).code;\n if (reasonCode === 4 || reasonCode === 5) {\n return \"AUTH\";\n }\n // An HTTP error carries its status as a field \u2014 that is the authoritative\n // signal, not a number that happens to occur in the message text.\n const status = (err as { statusCode?: unknown }).statusCode;\n if (typeof status === \"number\") {\n if (status === 429) {\n return \"RATE_LIMIT\";\n }\n if (status === 401 || status === 403) {\n return \"AUTH\";\n }\n }\n }\n // Through the same renderer the log uses: a rejected plain object carries its\n // fields here instead of `[object Object]`, so a thrown `{ code: \"ECONNRESET\" }`\n // reaches the marker test below rather than falling through to UNKNOWN.\n const msg = errMessage(err);\n if (\n msg.includes(\"ECONNREFUSED\") ||\n msg.includes(\"ENOTFOUND\") ||\n msg.includes(\"ENETUNREACH\") ||\n msg.includes(\"ECONNRESET\")\n ) {\n return \"NETWORK\";\n }\n if (msg.includes(\"Timeout\")) {\n return \"TIMEOUT\";\n }\n // Text markers are matched as WORDS, never as bare substrings: an \"Invalid\n // JSON\" error quotes the first 100 characters of a foreign body, and a Govee\n // maintenance page containing \"author\" or \"401\" in that snippet used to be\n // classified AUTH \u2014 which stops the Cloud retry loop for good and tells the\n // user to check a perfectly valid API key.\n if (/\\b(rate limit(ed)?|too many requests)\\b/i.test(msg)) {\n return \"RATE_LIMIT\";\n }\n // 2FA-pending classification must come before AUTH \u2014 Govee returns 454 with\n // a leading \"454\" or \"Verification\" marker that would otherwise fall into AUTH\n // and trip the auth-failure backoff. Two distinct categories so the adapter\n // can pause reconnect on PENDING (waiting for user-entered code) but reset\n // on FAILED (code was sent but rejected, user retries via Settings button).\n if (msg.includes(\"Verification required\") || (msg.includes(\"status 454\") && !msg.includes(\"invalid\"))) {\n return \"VERIFICATION_PENDING\";\n }\n if (msg.includes(\"Verification code invalid\") || msg.includes(\"status 455\")) {\n return \"VERIFICATION_FAILED\";\n }\n if (\n msg.includes(\"Login failed\") ||\n /\\b(unauthori[sz]ed|not authori[sz]ed|forbidden|authentication failed|bad username or password)\\b/i.test(msg)\n ) {\n return \"AUTH\";\n }\n return \"UNKNOWN\";\n}\n\n/**\n * Render an unknown error to a string for logging. Returns `e.message` for\n * Error values (the stack stays out of warn/error lines \u2014 debug paths that\n * want the trace render it themselves) and `String(...)` for every primitive.\n *\n * A thrown PLAIN OBJECT gets its own branch: `String({ code: \"ECONNRESET\" })`\n * is `[object Object]`, so a rejected fetch/HTTP object used to reach the log\n * with nothing in it a reader could act on. `JSON.stringify` renders the\n * fields instead; it returns `undefined` for a value it cannot represent and\n * THROWS on a circular structure or a BigInt field \u2014 a logger that throws\n * inside a catch block turns a handled failure into an unhandled rejection,\n * so both outcomes fall back to the type tag.\n *\n * @param e Caught value (usually `unknown` in catch blocks)\n */\nexport function errMessage(e: unknown): string {\n if (e instanceof Error) {\n return e.message;\n }\n if (typeof e === \"object\" && e !== null) {\n try {\n return JSON.stringify(e) ?? Object.prototype.toString.call(e);\n } catch {\n return Object.prototype.toString.call(e);\n }\n }\n return String(e);\n}\n\n/**\n * `.catch` handler for a best-effort write on an event path (a state write, a\n * dropdown reset, a marker refresh). The write is allowed to fail \u2014 the state\n * may have been deleted out-of-band, the database may be going down \u2014 but the\n * failure stays visible on the debug log instead of vanishing in an empty\n * catch, so a \"the adapter ignored my value\" report can be traced.\n *\n * @param log Adapter logger\n * @param context What was being written, for the debug line\n */\nexport function logRejected(log: ioBroker.Logger, context: string): (e: unknown) => void {\n return e => log.debug(`${context}: ${errMessage(e)}`);\n}\n\n/**\n * Mask a secret for safe logging. Reveals only a short leading fragment so a\n * log line stays recognizable without exposing the value. Credential-bearing\n * strings (API key topic, tokens) must pass through this before they ever\n * reach a log line \u2014 a raw secret in a log the user later pastes publicly is a\n * real exposure (H1).\n *\n * @param secret The sensitive string to mask\n */\nexport function maskSecret(secret: string): string {\n if (typeof secret !== \"string\" || secret.length <= 4) {\n return \"***\";\n }\n return `${secret.slice(0, 4)}***`;\n}\n\n/**\n * Coerce an unknown value to a finite number. Returns null for NaN, Infinity,\n * non-numeric strings, objects, etc. Use at API boundaries where external\n * payloads might send numbers as strings, or send malformed values.\n *\n * Strings that contain a finite number are accepted (Govee occasionally sends\n * `brightness: \"50\"` instead of `brightness: 50`).\n *\n * @param raw Unknown input\n */\nexport function coerceFiniteNumber(raw: unknown): number | null {\n if (typeof raw === \"number\") {\n return Number.isFinite(raw) ? raw : null;\n }\n if (typeof raw === \"string\" && raw.trim().length > 0) {\n const n = Number(raw);\n return Number.isFinite(n) ? n : null;\n }\n return null;\n}\n\n/**\n * Dedup-aware error logger.\n *\n * Compares the new error category against the caller's last category. On\n * change \u2192 warn (so the user sees fresh failures). On repeat \u2192 debug (so the\n * log doesn't spam). Returns the new category so the caller can update its\n * `lastErrorCategory` member.\n *\n * Caller pattern:\n * ```ts\n * this.lastErrorCategory = logDedup(this.log, this.lastErrorCategory, \"Cloud\", err);\n * ```\n *\n * @param log Adapter logger\n * @param last Previous category (null on first call)\n * @param context Short prefix (e.g. \"Cloud\", \"MQTT\", \"App-API\")\n * @param err Caught error\n * @returns New category (assign to caller's tracker)\n */\nexport function logDedup(\n log: ioBroker.Logger,\n last: ErrorCategory | null,\n context: string,\n err: unknown,\n): ErrorCategory {\n const category = classifyError(err);\n const msg = errMessage(err);\n if (category !== last) {\n log.warn(`${context}: ${msg}`);\n } else {\n log.debug(`${context}: ${msg} (repeated)`);\n }\n return category;\n}\n\n/**\n * Clamp a value to the 0-255 byte range. NaN/non-numeric inputs become 0.\n * Shared with govee-lan-client (LAN command bounds-check).\n *\n * @param v Input value\n */\nexport function clampByte(v: unknown): number {\n const n = typeof v === \"number\" && Number.isFinite(v) ? v : 0;\n return Math.max(0, Math.min(255, Math.round(n)));\n}\n\n/**\n * Convert RGB values to hex color string \"#RRGGBB\".\n * Out-of-range or non-numeric inputs are clamped to produce valid hex.\n *\n * @param r Red channel 0-255\n * @param g Green channel 0-255\n * @param b Blue channel 0-255\n */\nexport function rgbToHex(r: number, g: number, b: number): string {\n const rr = clampByte(r).toString(16).padStart(2, \"0\");\n const gg = clampByte(g).toString(16).padStart(2, \"0\");\n const bb = clampByte(b).toString(16).padStart(2, \"0\");\n return `#${rr}${gg}${bb}`;\n}\n\n/**\n * Parse hex color string to RGB values. Returns black for non-string,\n * wrong-length or malformed input (defensive \u2014 upstream may pass unexpected\n * types or shortened forms like \"FF\" that would otherwise yield blue=255\n * via the bitshift below).\n *\n * @param hex Color string (e.g. \"#FF6600\" or \"FF6600\")\n */\nexport function hexToRgb(hex: string): { r: number; g: number; b: number } {\n if (typeof hex !== \"string\") {\n return { r: 0, g: 0, b: 0 };\n }\n const cleaned = hex.replace(\"#\", \"\");\n // Reject anything that isn't exactly 6 hex digits \u2014 accepting \"FF\" or\n // \"FFAABBCC\" would silently yield non-obvious RGB values via parseInt\n // truncation/sign-extension.\n if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {\n return { r: 0, g: 0, b: 0 };\n }\n const num = parseInt(cleaned, 16) || 0;\n return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff };\n}\n\n/**\n * Convert packed RGB integer to hex color string \"#RRGGBB\"\n *\n * @param rgb Packed integer (r << 16 | g << 8 | b)\n */\nexport function rgbIntToHex(rgb: number): string {\n return `#${(rgb & 0xffffff).toString(16).padStart(6, \"0\")}`;\n}\n\n/**\n * Result of parsing a manual-segments string like \"0-9\", \"0-2,4-9\", \"0,3,5\".\n *\n * indices Deduplicated, sorted list of segment indices\n *\n * error Human-readable error (null on success)\n */\nexport interface SegmentListParseResult {\n /** Deduplicated, sorted list of segment indices */\n indices: number[];\n /** Human-readable error (null on success) */\n error: string | null;\n}\n\n/**\n * Parse a user-provided segment-list string.\n * Accepts comma-separated singles (\"0,1,2\"), ranges (\"0-9\"), mixed\n * (\"0-8,10-14\"); whitespace-tolerant. Deduplicates automatically and\n * returns the result sorted ascending.\n *\n * @param input User-input string\n * @param maxIndex Per-device upper bound (e.g. device.segmentCount - 1). Indices > maxIndex are rejected.\n * @returns SegmentListParseResult with indices + optional error\n */\nexport function parseSegmentList(input: string, maxIndex: number): SegmentListParseResult {\n // Backstop matches the Govee bitmask protocol limit (`SEGMENT_HARD_MAX = 55`\n // in device-manager). Higher values would be silently dropped at the\n // ptReal layer anyway; rejecting them up front gives a clearer user error.\n const HARD_MAX = 55;\n if (typeof input !== \"string\") {\n return { indices: [], error: \"input must be a string\" };\n }\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { indices: [], error: \"list is empty\" };\n }\n const effectiveMax = Math.min(Number.isFinite(maxIndex) && maxIndex >= 0 ? Math.floor(maxIndex) : HARD_MAX, HARD_MAX);\n const set = new Set<number>();\n const parts = trimmed.split(\",\");\n for (const raw of parts) {\n const part = raw.trim();\n if (part === \"\") {\n continue;\n }\n const rangeMatch = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(part);\n if (rangeMatch) {\n const start = parseInt(rangeMatch[1], 10);\n const end = parseInt(rangeMatch[2], 10);\n if (start > end) {\n return {\n indices: [],\n error: `invalid range \"${part}\" (start > end)`,\n };\n }\n for (let i = start; i <= end; i++) {\n if (i < 0 || i > effectiveMax) {\n return {\n indices: [],\n error: `segment ${i} is outside 0-${effectiveMax} for this device`,\n };\n }\n set.add(i);\n }\n continue;\n }\n if (!/^\\d+$/.test(part)) {\n return {\n indices: [],\n error: `invalid entry \"${part}\" (only digits and ranges allowed)`,\n };\n }\n const idx = parseInt(part, 10);\n if (idx < 0 || idx > effectiveMax) {\n return {\n indices: [],\n error: `segment ${idx} is outside 0-${effectiveMax} for this device`,\n };\n }\n set.add(idx);\n }\n if (set.size === 0) {\n return { indices: [], error: \"no valid indices in list\" };\n }\n return {\n indices: Array.from(set).sort((a, b) => a - b),\n error: null,\n };\n}\n\n/**\n * Disambiguate a list of names by appending \" (2)\", \" (3)\" to repeats,\n * preserving the order. The first occurrence keeps the original name.\n *\n * Used both when building common.states maps and when reverse-resolving\n * a label back to an index \u2014 the SAME function on both sides guarantees\n * the user-visible label and the lookup target stay in sync, even when\n * the source list (cloud scenes etc.) contains duplicates.\n *\n * @param names Raw name list, possibly containing duplicates\n */\nexport function disambiguateLabels(names: string[]): string[] {\n const counts = new Map<string, number>();\n const used = new Set<string>();\n return names.map(name => {\n let n = counts.get(name) ?? 0;\n let label = n === 0 ? name : `${name} (${n + 1})`;\n // Guard against colliding with a name the input already carried in\n // \"(N)\"-suffixed form (e.g. [\"Aurora\",\"Aurora\",\"Aurora (2)\"]) \u2014 keep bumping\n // the counter until the label is actually unique so the reverse-lookup stays\n // deterministic (I4).\n while (used.has(label)) {\n n += 1;\n label = `${name} (${n + 1})`;\n }\n counts.set(name, n + 1);\n used.add(label);\n return label;\n });\n}\n\n/**\n * Build a `common.states` map from a list of named items, with index 0\n * reserved for a sentinel entry (default \"---\" = no selection).\n *\n * Duplicate names are disambiguated via `disambiguateLabels`, so each\n * value in the resulting map is unique and the reverse-lookup is\n * deterministic.\n *\n * @param items Source list \u2014 each item must have a `name` field\n * @param zeroLabel Label for index 0 (default \"---\" = no selection)\n */\nexport function buildUniqueLabelMap<T extends { name: string }>(items: T[], zeroLabel = \"---\"): Record<string, string> {\n const labels = disambiguateLabels(items.map(item => item.name));\n const result: Record<string, string> = { 0: zeroLabel };\n labels.forEach((label, i) => {\n result[String(i + 1)] = label;\n });\n return result;\n}\n\n/**\n * Result of resolving a state value against a `common.states` map.\n * `key` is the matching map key (string form, as stored in the map),\n * `canonical` is the matching label (the canonical, disambiguated form\n * \u2014 what the dropdown displays).\n */\nexport interface ResolvedStatesValue {\n /** The matching key from the states map, in string form */\n key: string;\n /** Canonical label as stored in the states map */\n canonical: string;\n}\n\n/**\n * Reverse-resolve a state value against a `common.states` map, accepting\n * three input forms:\n * - number `1` \u2192 direct key lookup\n * - string matching a key \u2192 direct key match (case-sensitive \u2014 keys\n * are identifiers like \"1\" or \"spectrum\")\n * - string matching a label \u2192 case-insensitive trim match against\n * the map values\n *\n * Returns null when no match is found. The caller decides whether to\n * warn, ack=false, or fall back to a default \u2014 this helper is pure.\n *\n * @param input User-supplied state value (number, string, or other)\n * @param statesMap The state's `common.states` map (key \u2192 label)\n */\nexport function resolveStatesValue(input: unknown, statesMap: Record<string, string>): ResolvedStatesValue | null {\n if (typeof input === \"number\" && Number.isFinite(input)) {\n const key = String(input);\n // Own-property guard so a value/key can never resolve against an inherited\n // Object.prototype member (SEC-GC2). String(number) can't be \"__proto__\" etc.,\n // but keep it symmetric with the string branch below.\n if (Object.prototype.hasOwnProperty.call(statesMap, key)) {\n return { key, canonical: statesMap[key] };\n }\n return null;\n }\n if (typeof input === \"string\") {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return null;\n }\n // Direct key match \u2014 handles numeric-string keys (\"1\") and\n // identifier-string keys (\"spectrum\") in one pass. Own-property guard so a\n // dropdown input of \"__proto__\" / \"toString\" / \"constructor\" can't match an\n // inherited prototype member and falsely resolve ok=true (SEC-GC2).\n if (Object.prototype.hasOwnProperty.call(statesMap, trimmed)) {\n return { key: trimmed, canonical: statesMap[trimmed] };\n }\n // Label match \u2014 case-insensitive, trim. Lets users write the\n // human-readable name (e.g. \"Aurora\") regardless of casing.\n const needle = trimmed.toLowerCase();\n for (const [key, label] of Object.entries(statesMap)) {\n if (typeof label === \"string\" && label.trim().toLowerCase() === needle) {\n return { key, canonical: label };\n }\n }\n }\n return null;\n}\n\n/**\n * Event message from the OpenAPI-MQTT broker (mqtt.openapi.govee.com:8883).\n * Govee pushes one of these per device-capability state change \u2014 primarily\n * appliance events like lackWater, iceFull, bodyAppeared.\n */\nexport interface OpenApiMqttEvent {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /** Event capabilities (typically a single event entry) */\n capabilities: CloudStateCapability[];\n}\n\n/**\n * Timer/callback interface for helper classes. Declared with function-property\n * syntax (`setTimeout: (...) => ...`) rather than method syntax: stricter\n * parameter variance for a dependency-injection surface, and it keeps the\n * repo-checker's plain-timer regex off the interface method names. The real\n * scheduling still goes through the injected `this.setTimeout` etc., which the\n * regex correctly ignores (preceded by a dot).\n */\nexport interface TimerAdapter {\n /** Create a repeating interval timer */\n setInterval: (callback: () => void, ms: number) => ioBroker.Interval | undefined;\n /** Clear a repeating interval timer */\n clearInterval: (timer: ioBroker.Interval) => void;\n /** Create a one-shot timeout timer */\n setTimeout: (callback: () => void, ms: number) => ioBroker.Timeout | undefined;\n /** Clear a one-shot timeout timer */\n clearTimeout: (timer: ioBroker.Timeout) => void;\n /** Async delay that gets cancelled on adapter unload */\n delay: (ms: number) => Promise<void>;\n}\n\n/** The four basic control kinds, spelled as their camelCase cloud instance. */\nexport type ControlKind = \"power\" | \"brightness\" | \"colorRgb\" | \"colorTemperature\";\n\n/**\n * Canonical predicate: does a raw Govee capability match a basic control kind?\n * Single source of truth for the group-intersection check (capability-mapper\n * `memberHasControlState`, keyed on snake_case state-ids) and command routing\n * (`CommandRouter.findCapabilityForCommand`, keyed on camelCase command tokens).\n * A duplicated copy of this shape-match drifted at the B2 rename and broke\n * cloud-only group colour control \u2014 lives here (types.ts has no adapter-core/i18n\n * imports) so both callers share one copy. Exact `shortType` match (not\n * `endsWith`) \u2192 `segment_color_setting` never counts as a colour cap.\n *\n * @param cap Raw capability (type/instance may be non-string / missing)\n * @param kind Control kind to test for\n */\nexport function capMatchesControl(\n cap: { type?: unknown; instance?: unknown } | null | undefined,\n kind: ControlKind,\n): boolean {\n if (!cap || typeof cap.type !== \"string\" || typeof cap.instance !== \"string\") {\n return false;\n }\n const shortType = cap.type.replace(\"devices.capabilities.\", \"\");\n const inst = cap.instance;\n switch (kind) {\n case \"power\":\n return shortType === \"on_off\";\n case \"brightness\":\n return shortType === \"range\" && inst.toLowerCase().includes(\"brightness\");\n case \"colorRgb\":\n return shortType === \"color_setting\" && inst === \"colorRgb\";\n case \"colorTemperature\":\n return shortType === \"color_setting\" && inst.includes(\"colorTem\");\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;
|
|
4
|
+
"sourcesContent": ["// The error-text helper lives in its own import-free module so the admin\n// component (`src-admin/`) can share it; re-exported here for the 20+ callers.\nimport { errMessage } from \"./err-message\";\n\nexport { errMessage };\n\n/**\n * Result of a cloud-load attempt. The retry loop inspects `reason` to handle\n * rate limits and permanent failures correctly.\n */\nexport type CloudLoadResult =\n /** Success */\n | { ok: true }\n /** Network/timeout \u2014 just retry later */\n | { ok: false; reason: \"transient\" }\n /** Govee 429 \u2014 respect retry-after */\n | { ok: false; reason: \"rate-limited\"; retryAfterMs: number }\n /** Auth error (invalid API key) \u2014 NO retry, user must fix the config */\n | { ok: false; reason: \"auth-failed\"; message: string };\n\n// --- Cloud API v2 Types ---\n\n/** Device from Cloud API GET /router/api/v1/user/devices */\nexport interface CloudDevice {\n /** Product model (e.g. H6160) */\n sku: string;\n /** Unique device identifier */\n device: string;\n /** User-assigned device name */\n deviceName: string;\n /** Device category (e.g. \"devices.types.light\") */\n type: string;\n /** Device capabilities from Cloud API */\n capabilities: CloudCapability[];\n}\n\n/** A single capability from the Cloud API */\nexport interface CloudCapability {\n /** Capability type (e.g. \"devices.capabilities.on_off\") */\n type: string;\n /** Capability instance (e.g. \"powerSwitch\", \"brightness\") */\n instance: string;\n /** Parameter definition for this capability (optional \u2014 API can omit it) */\n parameters?: CapabilityParameters;\n}\n\n/** Parameter definition for a capability */\nexport interface CapabilityParameters {\n /** Value data type */\n dataType: \"ENUM\" | \"INTEGER\" | \"STRUCT\";\n /** Available options for ENUM type */\n options?: CapabilityOption[];\n /** Value range for INTEGER type */\n range?: { min: number; max: number; precision: number };\n /** Unit of measurement */\n unit?: string;\n /** Field definitions for STRUCT type */\n fields?: CapabilityField[];\n}\n\n/** ENUM option */\nexport interface CapabilityOption {\n /** Display name of the option. Absent on an unnamed level (see `options`). */\n name?: string;\n /** Option value. Optional and sometimes explicitly `null` (grouping entry). */\n value?: number | string | Record<string, unknown> | null;\n /** Nested per-mode sub-list. Its entries often have a value and NO name. */\n options?: CapabilityOption[];\n /** A mode whose level is a range instead of a list (H7143 Auto: 40-80). */\n range?: { min: number; max: number; precision?: number };\n /** Fixed value for a mode that has no levels at all (Govee sends 0). */\n defaultValue?: number;\n}\n\n/** An ENUM option that carries a usable display name. */\nexport type NamedCapabilityOption = CapabilityOption & { name: string };\n\n/** STRUCT field definition */\nexport interface CapabilityField {\n /** Field name identifier */\n fieldName: string;\n /** Value data type */\n dataType?: \"ENUM\" | \"INTEGER\" | \"STRUCT\" | \"Array\";\n /** Available options for ENUM fields */\n options?: CapabilityOption[];\n /** Value range for INTEGER fields */\n range?: { min: number; max: number; precision: number };\n /** Element range for Array fields (0-based, segment count = max + 1) */\n elementRange?: { min: number; max: number };\n /** Default the device declares for this field (`\"Celsius\"`, `0`, `null`). */\n defaultValue?: unknown;\n /**\n * Unit carried on the field itself (`\"unit.celsius\"`) \u2014 some payloads label\n * the temperature field instead of declaring a separate `unit` field.\n */\n unit?: string;\n /** Whether this field is required */\n required?: boolean;\n}\n\n/** Cloud API device list response */\nexport interface CloudDeviceListResponse {\n /** Response status code */\n code: number;\n /** Response message */\n message: string;\n /** List of devices */\n data: CloudDevice[];\n}\n\n/**\n * Answer of POST /router/api/v1/device/state. Note the envelope: `payload`\n * with `msg`, as on the scene endpoints \u2014 NOT `data`/`message`, which is the\n * envelope of the device list (measured on three captures, issue #47).\n */\nexport interface CloudDeviceStateResponse {\n /** Echo of the request id */\n requestId?: string;\n /** Response message \u2014 `msg` on this endpoint */\n msg?: string;\n /** Response status code */\n code: number;\n /** Device state data */\n payload?: {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /** Current capability states */\n capabilities: CloudStateCapability[];\n };\n}\n\n/** A capability value from state response */\nexport interface CloudStateCapability {\n /** Capability type */\n type: string;\n /** Capability instance */\n instance: string;\n /** Current state value */\n state: { value: unknown };\n}\n\n/** Cloud API scenes response \u2014 payload contains capabilities with options */\nexport interface CloudScenesResponse {\n /** Response status code */\n code: number;\n /** Response message */\n message: string;\n /** Payload with capabilities (scenes endpoint format) */\n payload?: {\n /** Scene capabilities with options */\n capabilities: CloudCapability[];\n };\n}\n\n/** A scene/snapshot option from the Cloud API */\nexport interface CloudScene {\n /** Display name */\n name: string;\n /** Activation value (passed directly to control endpoint) \u2014 object for scenes, integer for snapshots */\n value: Record<string, unknown> | number;\n}\n\n// --- AWS IoT MQTT Types ---\n\n/** Login response from app2.govee.com */\nexport interface GoveeLoginResponse {\n /** API status code (200 = success) */\n status?: number;\n /** API status message */\n message?: string;\n /** Client authentication data (missing on auth failure) */\n client?: {\n /** Bearer token for API calls */\n token: string;\n /** Account identifier (numeric) */\n accountId: number | string;\n /** MQTT topic for status updates */\n topic: string;\n /** Token TTL in seconds. Govee uses this name; some responses also send `tokenExpireCycle`. */\n token_expire_cycle?: number;\n tokenExpireCycle?: number;\n };\n}\n\n/**\n * Bundle of credentials persisted across adapter restarts so we don't have\n * to log in every time (which would spam the Govee 2FA email each restart).\n * Populated from `native` after a successful login, fed back into the next\n * MQTT connect.\n */\nexport interface PersistedMqttCredentials {\n /** Govee bearer token from /v1/login. */\n bearerToken: string;\n /** AWS IoT endpoint hostname (xxx-ats.iot.<region>.amazonaws.com). */\n iotEndpoint: string;\n /** Base64-encoded PKCS#12 cert bundle from /iot/key. */\n p12Cert: string;\n /** Password for the P12 cert (also from /iot/key). */\n p12Pass: string;\n /** Govee account-id, used to build the MQTT clientId. */\n accountId: string;\n /** MQTT topic the account subscribes to for status push. */\n accountTopic: string;\n /** ms-timestamp at which the bearer token expires (Date.now() + ttlMs). */\n tokenExpiresAt: number;\n}\n\n/** IoT key response from app2.govee.com */\nexport interface GoveeIotKeyResponse {\n /** IoT credential data */\n data?: {\n /** AWS IoT endpoint hostname */\n endpoint: string;\n /** Base64-encoded PKCS12 certificate */\n p12: string;\n /** Password for the PKCS12 certificate */\n p12Pass: string;\n };\n}\n\n/** MQTT status update received on account topic */\nexport interface MqttStatusUpdate {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /**\n * Packet kind Govee sent. `\"online\"` is a dedicated reachability packet \u2014\n * measured on four real user reports (issues #22/#25/#26): H66A1 sends\n * `{cmd:\"online\", state:{result:1}}`, H6199 `{cmd:\"online\",\n * state:{connected:\"true\"}}`. Until 2.30.0 the client dropped this field and\n * nothing downstream could tell a reachability packet from a state push.\n */\n cmd?: string;\n /**\n * Govee's transaction id, e.g. `x_1788603714892008`: its first 13 digits are\n * the millisecond timestamp of the request the device is answering. Kept\n * because it dates the packet ITSELF \u2014 a retained message the broker replays\n * on (re)subscribe carries the stamp of when it was originally sent, not of\n * its arrival, and `readDevicePushAt` uses exactly that to tell a fresh sign\n * of life from an old echo.\n */\n transaction?: string;\n /** Device state values */\n state?: {\n /** Power state (1 = on, 0 = off) */\n onOff?: number;\n /** Brightness percentage 0-100 */\n brightness?: number;\n /** RGB color values */\n color?: { r: number; g: number; b: number };\n /** Color temperature in Kelvin */\n colorTemInKelvin?: number;\n /**\n * Reachability as text (\"true\"/\"false\"). Carried in every packet kind for\n * the devices that send it (status, pt, online), so it is always usable.\n *\n * Which of these two fields a device uses correlates with the protocol\n * generation Govee also puts in the envelope (`pactType` 1 \u2192 this one,\n * 2 \u2192 `result`, measured across all 75 captured packets). The adapter does\n * NOT key off it: the account list carries devices with no `pactType` at\n * all, so `readReportedReachability` decides on the FIELD SHAPE that is\n * actually present. The envelope field was parsed and handed on for a\n * version without a single reader \u2014 dropped, with the observation kept\n * here where it explains something.\n */\n connected?: string;\n /**\n * Reachability as number (1/0), but ONLY inside a `cmd:\"online\"` packet.\n * In `cmd:\"status\"` / `cmd:\"ptReal\"` the same field is an operation result\n * code and says nothing about reachability.\n */\n result?: number;\n };\n /** Operation data */\n op?: {\n /** Command strings */\n command?: string[];\n };\n}\n\n// --- LAN API Types ---\n\n/** LAN discovery response */\nexport interface LanDevice {\n /** Device IP address */\n ip: string;\n /** Device identifier */\n device: string;\n /** Product model */\n sku: string;\n}\n\n/** LAN status response */\nexport interface LanStatus {\n /** Power state (1 = on, 0 = off) */\n onOff: number;\n /** Brightness percentage 0-100 */\n brightness: number;\n /** RGB color values */\n color: { r: number; g: number; b: number };\n /** Color temperature in Kelvin */\n colorTemInKelvin: number;\n}\n\n/** LAN command message wrapper */\nexport interface LanMessage {\n /** Message payload */\n msg: {\n /** Command name */\n cmd: string;\n /** Command data */\n data: Record<string, unknown>;\n };\n}\n\n// --- Internal Device Model ---\n\n/** Unified device representation used by device-manager */\nexport interface GoveeDevice {\n /** Product model (e.g. H6160) */\n sku: string;\n /** Unique device ID (8-byte hex) */\n deviceId: string;\n /** Display name (from Cloud or SKU fallback) */\n name: string;\n /** Device type from Cloud (e.g. \"devices.types.light\") */\n type: string;\n /** LAN IP address if discovered */\n lanIp?: string;\n /** Capabilities from Cloud API */\n capabilities: CloudCapability[];\n /** Available light scenes (from Cloud scenes endpoint) */\n scenes: CloudScene[];\n /** Available DIY scenes (from Cloud scenes endpoint) */\n diyScenes: CloudScene[];\n /** Available snapshots (from Cloud scenes endpoint) */\n snapshots: CloudScene[];\n /** Scene library entries with scene codes for ptReal (from undocumented API) */\n sceneLibrary: Array<{\n name: string;\n /** BLE scene code (> 0 = usable via ptReal) */\n sceneCode: number;\n /** Base64-encoded BLE scene parameter data */\n scenceParam?: string;\n /** Speed control info (from scene library API) */\n speedInfo?: {\n /** Whether this scene supports speed adjustment */\n supSpeed: boolean;\n /** Default speed level index */\n speedIndex: number;\n /** JSON config with per-level moveIn/color/bright overrides */\n config: string;\n };\n }>;\n /** Music effect library entries for ptReal local music mode (authenticated API) */\n musicLibrary: Array<{\n name: string;\n /** BLE music effect code */\n musicCode: number;\n /** Base64-encoded BLE parameter data */\n scenceParam?: string;\n /** Music sub-mode index */\n mode?: number;\n }>;\n /** DIY light effect library entries for ptReal local DIY activation (authenticated API) */\n diyLibrary: Array<{\n name: string;\n /** BLE DIY effect code */\n diyCode: number;\n /** Base64-encoded BLE parameter data */\n scenceParam?: string;\n }>;\n /** Supported feature flags per SKU (from authenticated API) */\n skuFeatures: Record<string, unknown> | null;\n /** Group member devices (only for BaseGroup) */\n groupMembers?: { sku: string; deviceId: string }[];\n /** Last known state */\n state: DeviceState;\n /**\n * Number of LED segments on this device. Resolved by\n * {@link resolveSegmentCount} from Cache \u2192 MQTT-discovered \u2192 Cloud min.\n * Persisted via SKU cache so learned values survive restarts.\n */\n segmentCount?: number;\n /** BLE packets per cloud snapshot for ptReal activation [snapshotIdx][cmdIdx][packetBase64] */\n snapshotBleCmds?: string[][][];\n /** Current speed level for scene playback (0-based, applied on next scene activation) */\n sceneSpeed?: number;\n /**\n * Set to true after a Cloud scene-fetch attempt completed (success or confirmed empty).\n * Used to distinguish \"not yet tried\" from \"legitimately empty\" \u2014 prevents endless refetch.\n */\n scenesChecked?: boolean;\n /**\n * Manual-mode flag for cut strips (physical segments with gaps). When true,\n * `manualSegments` lists the indices that actually light up; all others\n * (within `0..segmentCount-1`) are skipped. Orthogonal to `segmentCount`:\n * the total is still the strip's real length, manualMode just masks gaps.\n */\n manualMode?: boolean;\n /**\n * Explicit physical segment indices (parsed from `segments.manual_list` state).\n * Only used when `manualMode=true`. Indices must be within `0..segmentCount-1`.\n */\n manualSegments?: number[];\n /**\n * Timestamp (ms) when device was last seen via LAN discovery or MQTT status push.\n * Used for cache pruning \u2014 stale entries without recent network sighting get removed.\n */\n lastSeenOnNetwork?: number;\n /**\n * When the reachability refresh last ISSUED a cloud state read for this\n * device \u2014 the attempt, not its outcome.\n *\n * Needed because a call that answers nothing usable leaves the evidence\n * untouched, so the age test that triggered it would trigger again on the\n * very next tick, forever. Runtime-only: it is not persisted, and a restart\n * simply allows one immediate attempt.\n */\n lastReachabilityRefreshAt?: number;\n /**\n * Consecutive account-reconcile passes this device was missing from its\n * authoritative account list (Cloud `/user/devices` for lights/appliances,\n * App-API list for sensors, group list for BaseGroups) while not\n * LAN-reachable. Persisted via the SKU cache so the debounce survives\n * restarts; reset to 0 the instant the device reappears in any source or is\n * seen on LAN. Reaching the evict threshold removes the device (irreversible).\n */\n accountMissCount?: number;\n /**\n * Human-readable identifier of the Govee gateway this device reaches the\n * cloud through, when it has no own WiFi (BLE\u2192gateway sensors like the H5109\n * pool thermometer behind an H5042). Format `<gateway SKU> (<gateway BLE\n * name>)`, e.g. `H5042 (ihoment_H5042_3795)`. Set from the App-API\n * `settings.gatewayInfo`; **set-only, never cleared** once seen (sticky), so\n * a flaky poll that omits it can't churn the object tree. When present the\n * device shows `info.gateway` instead of an always-empty `info.ip`.\n */\n gateway?: string;\n /**\n * The gateway's own device id, straight from `gatewayInfo.device`. Until\n * 2.30.0 only `sku` + `bleName` were used (for the display label) and this was\n * dropped \u2014 but it is the only thing that links a battery sensor to the\n * gateway it hangs off, and krobi's rule is that the GATEWAY is the\n * reachability device for anything behind it (2026-09-03).\n */\n gatewayDeviceId?: string;\n /**\n * Timestamp (ms) when device last replied to a LAN-direct probe (multicast\n * discovery or unicast devStatus). Only set by the LAN-Discovery / LAN-Status\n * paths \u2014 NOT by MQTT-push (broker buffering risk) and NOT by Cloud caps.\n * Used as the sole truth-source for `info.online` of Lights via the\n * StateManager.syncInfoOnline resolver (90s freshness window).\n */\n lastLanReplyAt?: number;\n /**\n * When this device last answered on the LOCAL interface \u2014 PERSISTED, unlike\n * `lastLanReplyAt` (which is a live freshness stamp and deliberately dropped\n * on save, see device-manager/cache.ts).\n *\n * Why a second stamp is needed: `lanIp` is re-discovered on every restart, so\n * right after a start every light looks like a cloud-only device for one scan\n * cycle. Deciding \"is this LAN-driven?\" on `lanIp` would hand those lights to\n * Govee's stale cloud cache in that window \u2014 that was exactly the 2.29.0\n * false-green. This stamp survives the restart and answers the question\n * honestly.\n *\n * It expires (LAN_CAPABLE_MEMORY_MS) so a device whose local API the user\n * switches OFF eventually moves to the cloud path instead of staying grey\n * forever.\n */\n lastLanSeenAt?: number;\n /** Which channels are available */\n channels: {\n /** LAN UDP reachable */\n lan: boolean;\n /** MQTT connected */\n mqtt: boolean;\n /** Cloud API available */\n cloud: boolean;\n };\n}\n\n/** Current device state */\nexport interface DeviceState {\n /** Whether device is reachable */\n online: boolean;\n /**\n * What Govee itself last SAID about reachability \u2014 an explicit `online`\n * capability and nothing else (`applyOnlineCap`: cloud state read, App-API\n * poll, OpenAPI-MQTT event). `undefined` means Govee has never made that\n * statement for this device.\n *\n * The distinction matters because \"Govee says offline\" and \"nobody ever said\n * anything\" must not resolve to the same marker. Lives in `state` (not on the\n * device) so it is runtime-only and never reaches the cache.\n *\n * Strictly separate from {@link cloudLivenessAt}: a statement and the mere\n * arrival of a packet used to share this one slot, so whichever came LAST\n * won. An event arriving seconds after Govee reported `online:false`\n * overwrote that `false` with `true` for the next 30 minutes \u2014 exactly the\n * precedence the architecture doc has always claimed to enforce (\"an explicit\n * report beats the mere arrival of a packet\") and did not.\n */\n cloudReportedOnline?: boolean;\n /**\n * When that report was heard. Evidence has to be able to grow old: without a\n * timestamp a device that said \"online\" once would read as reachable forever,\n * sitting unplugged in a drawer. Renewed by the account push, by the 2-minute\n * account list, and by the reachability refresh\n * ({@link DeviceManager.refreshExpiringReachability}) \u2014 which is the only\n * renewer that needs no account credentials.\n */\n cloudReportedOnlineAt?: number;\n /**\n * When Govee last delivered anything at all FOR this device \u2014 a reading, an\n * event, a capability list \u2014 without saying whether it is reachable.\n *\n * That arrival is real evidence: the payload exists because the device spoke.\n * It is weaker than a report, though, and only ever positive \u2014 there is no\n * \"silence means offline\" here, silence is simply the stamp growing old. Kept\n * apart from {@link cloudReportedOnline} so an explicit `offline` can no\n * longer be undone by the next packet that happens to arrive.\n *\n * Runtime-only, like the two above.\n */\n cloudLivenessAt?: number;\n /**\n * When the device ITSELF last pushed a status packet over the account broker\n * \u2014 its own voice, as opposed to what Govee's lists say about it. Dated by\n * the packet's transaction stamp where it carries one (so a retained replay\n * dates itself old), else by arrival. For the evidence window\n * (`CLOUD_ONLINE_EVIDENCE_TTL_MS`) this holds against a POLLED \"offline\":\n * Govee's device-list flag is measured to stick (gateway sensors, #18/#31)\n * and to lag (2026-05-13 capture), while a packet from the device cannot lie\n * about the device having spoken. An explicit `cmd:\"online\"` packet is an\n * event, not a poll, and still wins. Runtime-only, never cached.\n */\n devicePushAt?: number;\n /**\n * Reachability of this device's gateway, resolved by the DeviceManager (which\n * is the only place that can see other devices). `false` caps this device's\n * reachability: a sensor behind a dead gateway is not reachable no matter how\n * fresh its last reading looked. `undefined` means no gateway, or a gateway\n * that is not in the account list \u2014 then nothing is capped.\n */\n gatewayOnline?: boolean;\n /** Power on/off */\n power?: boolean;\n /** Brightness 0-100 */\n brightness?: number;\n /** Color as \"#RRGGBB\" hex string */\n colorRgb?: string;\n /** Color temperature in Kelvin */\n colorTemperature?: number;\n /** Additional dynamic state values */\n [key: string]: unknown;\n}\n\n/**\n * What a state patch CHANGED, for consumers that must act on a transition\n * rather than on the reported value. Carried next to the patch, never inside\n * it: the patch is merged into `device.state` and shows up in diagnostics.\n * Sources that only report changes (account push, cloud merge) pass nothing.\n */\nexport interface DeviceStateChanges {\n /** `power` differs from what the device reported before (a switch-on or -off). */\n powerFlipped: boolean;\n}\n\n/**\n * Normalize device ID \u2014 remove colons, lowercase.\n * Returns empty string if input is not a string (defensive against malformed API data).\n *\n * @param id Raw device identifier\n */\nexport function normalizeDeviceId(id: string): string {\n if (typeof id !== \"string\") {\n return \"\";\n }\n return id.replace(/:/g, \"\").toLowerCase();\n}\n\n/**\n * Consistent device label for log lines: always name plus model, e.g.\n * `Wifi Thermometer (H5179)`. Falls back to the bare SKU when no display\n * name is known (or the name IS the SKU) \u2014 never produces `H5179 (H5179)`.\n *\n * @param device Anything carrying a display name and a SKU (GoveeDevice, cached entry, \u2026)\n * @param device.name Optional display name (user-assigned or Cloud-provided)\n * @param device.sku Product model\n */\nexport function deviceLabel(device: { name?: string; sku: string }): string {\n const name = typeof device.name === \"string\" ? device.name.trim() : \"\";\n return name && name !== device.sku ? `${name} (${device.sku})` : device.sku;\n}\n\n/**\n * Human-readable label for the Govee gateway a device reaches the cloud\n * through, derived from the App-API `settings.gatewayInfo`. Returns\n * `<SKU> (<BLE name>)`, or just the SKU when no BLE name is present, or\n * `undefined` when there is no usable gateway SKU \u2014 the caller then leaves the\n * device on the normal `info.ip` path and never creates an `info.gateway` with\n * a garbage value. Auth secrets (`secretCode`, `topic`) are deliberately ignored.\n *\n * @param gatewayInfo The `gatewayInfo` object from the App-API device settings\n */\nexport function formatGatewayLabel(gatewayInfo: { sku?: unknown; bleName?: unknown } | undefined): string | undefined {\n if (!gatewayInfo || typeof gatewayInfo !== \"object\") {\n return undefined;\n }\n const sku = typeof gatewayInfo.sku === \"string\" ? gatewayInfo.sku.trim() : \"\";\n if (!sku) {\n return undefined;\n }\n const bleName = typeof gatewayInfo.bleName === \"string\" ? gatewayInfo.bleName.trim() : \"\";\n return bleName ? `${sku} (${bleName})` : sku;\n}\n\n/** Error categories for dedup logging */\nexport type ErrorCategory =\n | \"NETWORK\"\n | \"TIMEOUT\"\n | \"AUTH\"\n | \"RATE_LIMIT\"\n /** Govee returned 454 with no code in the request body \u2014 user must request a verification code via Settings. */\n | \"VERIFICATION_PENDING\"\n /** Govee returned 454 with code already sent, or 455 \u2014 code is wrong or expired, user must request a fresh one. */\n | \"VERIFICATION_FAILED\"\n | \"UNKNOWN\";\n\n/**\n * Classify an error into a category for dedup logging.\n * Only the category is used as key \u2014 not context or full message.\n *\n * @param err Error to classify\n */\nexport function classifyError(err: unknown): ErrorCategory {\n if (err instanceof Error) {\n const code = (err as NodeJS.ErrnoException).code;\n if (\n code === \"ECONNREFUSED\" ||\n code === \"EHOSTUNREACH\" ||\n code === \"ENOTFOUND\" ||\n code === \"ENETUNREACH\" ||\n code === \"ECONNRESET\" ||\n code === \"EAI_AGAIN\"\n ) {\n return \"NETWORK\";\n }\n if (code === \"ETIMEDOUT\" || err.message.includes(\"timed out\")) {\n return \"TIMEOUT\";\n }\n // mqtt.js CONNACK reason codes: 4 = bad user name or password, 5 = not\n // authorized \u2014 the broker rejected the credentials (API key / account).\n const reasonCode: unknown = (err as { code?: unknown }).code;\n if (reasonCode === 4 || reasonCode === 5) {\n return \"AUTH\";\n }\n // An HTTP error carries its status as a field \u2014 that is the authoritative\n // signal, not a number that happens to occur in the message text.\n const status = (err as { statusCode?: unknown }).statusCode;\n if (typeof status === \"number\") {\n if (status === 429) {\n return \"RATE_LIMIT\";\n }\n if (status === 401 || status === 403) {\n return \"AUTH\";\n }\n }\n }\n // Through the same renderer the log uses: a rejected plain object carries its\n // fields here instead of `[object Object]`, so a thrown `{ code: \"ECONNRESET\" }`\n // reaches the marker test below rather than falling through to UNKNOWN.\n const msg = errMessage(err);\n if (\n msg.includes(\"ECONNREFUSED\") ||\n msg.includes(\"ENOTFOUND\") ||\n msg.includes(\"ENETUNREACH\") ||\n msg.includes(\"ECONNRESET\")\n ) {\n return \"NETWORK\";\n }\n if (msg.includes(\"Timeout\")) {\n return \"TIMEOUT\";\n }\n // Text markers are matched as WORDS, never as bare substrings: an \"Invalid\n // JSON\" error quotes the first 100 characters of a foreign body, and a Govee\n // maintenance page containing \"author\" or \"401\" in that snippet used to be\n // classified AUTH \u2014 which stops the Cloud retry loop for good and tells the\n // user to check a perfectly valid API key.\n if (/\\b(rate limit(ed)?|too many requests)\\b/i.test(msg)) {\n return \"RATE_LIMIT\";\n }\n // 2FA-pending classification must come before AUTH \u2014 Govee returns 454 with\n // a leading \"454\" or \"Verification\" marker that would otherwise fall into AUTH\n // and trip the auth-failure backoff. Two distinct categories so the adapter\n // can pause reconnect on PENDING (waiting for user-entered code) but reset\n // on FAILED (code was sent but rejected, user retries via Settings button).\n if (msg.includes(\"Verification required\") || (msg.includes(\"status 454\") && !msg.includes(\"invalid\"))) {\n return \"VERIFICATION_PENDING\";\n }\n if (msg.includes(\"Verification code invalid\") || msg.includes(\"status 455\")) {\n return \"VERIFICATION_FAILED\";\n }\n if (\n msg.includes(\"Login failed\") ||\n /\\b(unauthori[sz]ed|not authori[sz]ed|forbidden|authentication failed|bad username or password)\\b/i.test(msg)\n ) {\n return \"AUTH\";\n }\n return \"UNKNOWN\";\n}\n\n/**\n * `.catch` handler for a best-effort write on an event path (a state write, a\n * dropdown reset, a marker refresh). The write is allowed to fail \u2014 the state\n * may have been deleted out-of-band, the database may be going down \u2014 but the\n * failure stays visible on the debug log instead of vanishing in an empty\n * catch, so a \"the adapter ignored my value\" report can be traced.\n *\n * @param log Adapter logger\n * @param context What was being written, for the debug line\n */\nexport function logRejected(log: ioBroker.Logger, context: string): (e: unknown) => void {\n return e => log.debug(`${context}: ${errMessage(e)}`);\n}\n\n/**\n * Mask a secret for safe logging. Reveals only a short leading fragment so a\n * log line stays recognizable without exposing the value. Credential-bearing\n * strings (API key topic, tokens) must pass through this before they ever\n * reach a log line \u2014 a raw secret in a log the user later pastes publicly is a\n * real exposure (H1).\n *\n * @param secret The sensitive string to mask\n */\nexport function maskSecret(secret: string): string {\n if (typeof secret !== \"string\" || secret.length <= 4) {\n return \"***\";\n }\n return `${secret.slice(0, 4)}***`;\n}\n\n/**\n * Coerce an unknown value to a finite number. Returns null for NaN, Infinity,\n * non-numeric strings, objects, etc. Use at API boundaries where external\n * payloads might send numbers as strings, or send malformed values.\n *\n * Strings that contain a finite number are accepted (Govee occasionally sends\n * `brightness: \"50\"` instead of `brightness: 50`).\n *\n * @param raw Unknown input\n */\nexport function coerceFiniteNumber(raw: unknown): number | null {\n if (typeof raw === \"number\") {\n return Number.isFinite(raw) ? raw : null;\n }\n if (typeof raw === \"string\" && raw.trim().length > 0) {\n const n = Number(raw);\n return Number.isFinite(n) ? n : null;\n }\n return null;\n}\n\n/**\n * Dedup-aware error logger.\n *\n * Compares the new error category against the caller's last category. On\n * change \u2192 warn (so the user sees fresh failures). On repeat \u2192 debug (so the\n * log doesn't spam). Returns the new category so the caller can update its\n * `lastErrorCategory` member.\n *\n * Caller pattern:\n * ```ts\n * this.lastErrorCategory = logDedup(this.log, this.lastErrorCategory, \"Cloud\", err);\n * ```\n *\n * @param log Adapter logger\n * @param last Previous category (null on first call)\n * @param context Short prefix (e.g. \"Cloud\", \"MQTT\", \"App-API\")\n * @param err Caught error\n * @returns New category (assign to caller's tracker)\n */\nexport function logDedup(\n log: ioBroker.Logger,\n last: ErrorCategory | null,\n context: string,\n err: unknown,\n): ErrorCategory {\n const category = classifyError(err);\n const msg = errMessage(err);\n if (category !== last) {\n log.warn(`${context}: ${msg}`);\n } else {\n log.debug(`${context}: ${msg} (repeated)`);\n }\n return category;\n}\n\n/**\n * Clamp a value to the 0-255 byte range. NaN/non-numeric inputs become 0.\n * Shared with govee-lan-client (LAN command bounds-check).\n *\n * @param v Input value\n */\nexport function clampByte(v: unknown): number {\n const n = typeof v === \"number\" && Number.isFinite(v) ? v : 0;\n return Math.max(0, Math.min(255, Math.round(n)));\n}\n\n/**\n * Convert RGB values to hex color string \"#RRGGBB\".\n * Out-of-range or non-numeric inputs are clamped to produce valid hex.\n *\n * @param r Red channel 0-255\n * @param g Green channel 0-255\n * @param b Blue channel 0-255\n */\nexport function rgbToHex(r: number, g: number, b: number): string {\n const rr = clampByte(r).toString(16).padStart(2, \"0\");\n const gg = clampByte(g).toString(16).padStart(2, \"0\");\n const bb = clampByte(b).toString(16).padStart(2, \"0\");\n return `#${rr}${gg}${bb}`;\n}\n\n/**\n * Parse hex color string to RGB values. Returns black for non-string,\n * wrong-length or malformed input (defensive \u2014 upstream may pass unexpected\n * types or shortened forms like \"FF\" that would otherwise yield blue=255\n * via the bitshift below).\n *\n * @param hex Color string (e.g. \"#FF6600\" or \"FF6600\")\n */\nexport function hexToRgb(hex: string): { r: number; g: number; b: number } {\n if (typeof hex !== \"string\") {\n return { r: 0, g: 0, b: 0 };\n }\n const cleaned = hex.replace(\"#\", \"\");\n // Reject anything that isn't exactly 6 hex digits \u2014 accepting \"FF\" or\n // \"FFAABBCC\" would silently yield non-obvious RGB values via parseInt\n // truncation/sign-extension.\n if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) {\n return { r: 0, g: 0, b: 0 };\n }\n const num = parseInt(cleaned, 16) || 0;\n return { r: (num >> 16) & 0xff, g: (num >> 8) & 0xff, b: num & 0xff };\n}\n\n/**\n * Convert packed RGB integer to hex color string \"#RRGGBB\"\n *\n * @param rgb Packed integer (r << 16 | g << 8 | b)\n */\nexport function rgbIntToHex(rgb: number): string {\n return `#${(rgb & 0xffffff).toString(16).padStart(6, \"0\")}`;\n}\n\n/**\n * Result of parsing a manual-segments string like \"0-9\", \"0-2,4-9\", \"0,3,5\".\n *\n * indices Deduplicated, sorted list of segment indices\n *\n * error Human-readable error (null on success)\n */\nexport interface SegmentListParseResult {\n /** Deduplicated, sorted list of segment indices */\n indices: number[];\n /** Human-readable error (null on success) */\n error: string | null;\n}\n\n/**\n * Parse a user-provided segment-list string.\n * Accepts comma-separated singles (\"0,1,2\"), ranges (\"0-9\"), mixed\n * (\"0-8,10-14\"); whitespace-tolerant. Deduplicates automatically and\n * returns the result sorted ascending.\n *\n * @param input User-input string\n * @param maxIndex Per-device upper bound (e.g. device.segmentCount - 1). Indices > maxIndex are rejected.\n * @returns SegmentListParseResult with indices + optional error\n */\nexport function parseSegmentList(input: string, maxIndex: number): SegmentListParseResult {\n // Backstop matches the Govee bitmask protocol limit (`SEGMENT_HARD_MAX = 55`\n // in device-manager). Higher values would be silently dropped at the\n // ptReal layer anyway; rejecting them up front gives a clearer user error.\n const HARD_MAX = 55;\n if (typeof input !== \"string\") {\n return { indices: [], error: \"input must be a string\" };\n }\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return { indices: [], error: \"list is empty\" };\n }\n const effectiveMax = Math.min(Number.isFinite(maxIndex) && maxIndex >= 0 ? Math.floor(maxIndex) : HARD_MAX, HARD_MAX);\n const set = new Set<number>();\n const parts = trimmed.split(\",\");\n for (const raw of parts) {\n const part = raw.trim();\n if (part === \"\") {\n continue;\n }\n const rangeMatch = /^(\\d+)\\s*-\\s*(\\d+)$/.exec(part);\n if (rangeMatch) {\n const start = parseInt(rangeMatch[1], 10);\n const end = parseInt(rangeMatch[2], 10);\n if (start > end) {\n return {\n indices: [],\n error: `invalid range \"${part}\" (start > end)`,\n };\n }\n for (let i = start; i <= end; i++) {\n if (i < 0 || i > effectiveMax) {\n return {\n indices: [],\n error: `segment ${i} is outside 0-${effectiveMax} for this device`,\n };\n }\n set.add(i);\n }\n continue;\n }\n if (!/^\\d+$/.test(part)) {\n return {\n indices: [],\n error: `invalid entry \"${part}\" (only digits and ranges allowed)`,\n };\n }\n const idx = parseInt(part, 10);\n if (idx < 0 || idx > effectiveMax) {\n return {\n indices: [],\n error: `segment ${idx} is outside 0-${effectiveMax} for this device`,\n };\n }\n set.add(idx);\n }\n if (set.size === 0) {\n return { indices: [], error: \"no valid indices in list\" };\n }\n return {\n indices: Array.from(set).sort((a, b) => a - b),\n error: null,\n };\n}\n\n/**\n * Disambiguate a list of names by appending \" (2)\", \" (3)\" to repeats,\n * preserving the order. The first occurrence keeps the original name.\n *\n * Used both when building common.states maps and when reverse-resolving\n * a label back to an index \u2014 the SAME function on both sides guarantees\n * the user-visible label and the lookup target stay in sync, even when\n * the source list (cloud scenes etc.) contains duplicates.\n *\n * @param names Raw name list, possibly containing duplicates\n */\nexport function disambiguateLabels(names: string[]): string[] {\n const counts = new Map<string, number>();\n const used = new Set<string>();\n return names.map(name => {\n let n = counts.get(name) ?? 0;\n let label = n === 0 ? name : `${name} (${n + 1})`;\n // Guard against colliding with a name the input already carried in\n // \"(N)\"-suffixed form (e.g. [\"Aurora\",\"Aurora\",\"Aurora (2)\"]) \u2014 keep bumping\n // the counter until the label is actually unique so the reverse-lookup stays\n // deterministic (I4).\n while (used.has(label)) {\n n += 1;\n label = `${name} (${n + 1})`;\n }\n counts.set(name, n + 1);\n used.add(label);\n return label;\n });\n}\n\n/**\n * Build a `common.states` map from a list of named items, with index 0\n * reserved for a sentinel entry (default \"---\" = no selection).\n *\n * Duplicate names are disambiguated via `disambiguateLabels`, so each\n * value in the resulting map is unique and the reverse-lookup is\n * deterministic.\n *\n * @param items Source list \u2014 each item must have a `name` field\n * @param zeroLabel Label for index 0 (default \"---\" = no selection)\n */\nexport function buildUniqueLabelMap<T extends { name: string }>(items: T[], zeroLabel = \"---\"): Record<string, string> {\n const labels = disambiguateLabels(items.map(item => item.name));\n const result: Record<string, string> = { 0: zeroLabel };\n labels.forEach((label, i) => {\n result[String(i + 1)] = label;\n });\n return result;\n}\n\n/**\n * Result of resolving a state value against a `common.states` map.\n * `key` is the matching map key (string form, as stored in the map),\n * `canonical` is the matching label (the canonical, disambiguated form\n * \u2014 what the dropdown displays).\n */\nexport interface ResolvedStatesValue {\n /** The matching key from the states map, in string form */\n key: string;\n /** Canonical label as stored in the states map */\n canonical: string;\n}\n\n/**\n * Reverse-resolve a state value against a `common.states` map, accepting\n * three input forms:\n * - number `1` \u2192 direct key lookup\n * - string matching a key \u2192 direct key match (case-sensitive \u2014 keys\n * are identifiers like \"1\" or \"spectrum\")\n * - string matching a label \u2192 case-insensitive trim match against\n * the map values\n *\n * Returns null when no match is found. The caller decides whether to\n * warn, ack=false, or fall back to a default \u2014 this helper is pure.\n *\n * @param input User-supplied state value (number, string, or other)\n * @param statesMap The state's `common.states` map (key \u2192 label)\n */\nexport function resolveStatesValue(input: unknown, statesMap: Record<string, string>): ResolvedStatesValue | null {\n if (typeof input === \"number\" && Number.isFinite(input)) {\n const key = String(input);\n // Own-property guard so a value/key can never resolve against an inherited\n // Object.prototype member (SEC-GC2). String(number) can't be \"__proto__\" etc.,\n // but keep it symmetric with the string branch below.\n if (Object.prototype.hasOwnProperty.call(statesMap, key)) {\n return { key, canonical: statesMap[key] };\n }\n return null;\n }\n if (typeof input === \"string\") {\n const trimmed = input.trim();\n if (trimmed === \"\") {\n return null;\n }\n // Direct key match \u2014 handles numeric-string keys (\"1\") and\n // identifier-string keys (\"spectrum\") in one pass. Own-property guard so a\n // dropdown input of \"__proto__\" / \"toString\" / \"constructor\" can't match an\n // inherited prototype member and falsely resolve ok=true (SEC-GC2).\n if (Object.prototype.hasOwnProperty.call(statesMap, trimmed)) {\n return { key: trimmed, canonical: statesMap[trimmed] };\n }\n // Label match \u2014 case-insensitive, trim. Lets users write the\n // human-readable name (e.g. \"Aurora\") regardless of casing.\n const needle = trimmed.toLowerCase();\n for (const [key, label] of Object.entries(statesMap)) {\n if (typeof label === \"string\" && label.trim().toLowerCase() === needle) {\n return { key, canonical: label };\n }\n }\n }\n return null;\n}\n\n/**\n * Event message from the OpenAPI-MQTT broker (mqtt.openapi.govee.com:8883).\n * Govee pushes one of these per device-capability state change \u2014 primarily\n * appliance events like lackWater, iceFull, bodyAppeared.\n */\nexport interface OpenApiMqttEvent {\n /** Product model */\n sku: string;\n /** Device identifier */\n device: string;\n /** Event capabilities (typically a single event entry) */\n capabilities: CloudStateCapability[];\n}\n\n/**\n * Timer/callback interface for helper classes. Declared with function-property\n * syntax (`setTimeout: (...) => ...`) rather than method syntax: stricter\n * parameter variance for a dependency-injection surface, and it keeps the\n * repo-checker's plain-timer regex off the interface method names. The real\n * scheduling still goes through the injected `this.setTimeout` etc., which the\n * regex correctly ignores (preceded by a dot).\n */\nexport interface TimerAdapter {\n /** Create a repeating interval timer */\n setInterval: (callback: () => void, ms: number) => ioBroker.Interval | undefined;\n /** Clear a repeating interval timer */\n clearInterval: (timer: ioBroker.Interval) => void;\n /** Create a one-shot timeout timer */\n setTimeout: (callback: () => void, ms: number) => ioBroker.Timeout | undefined;\n /** Clear a one-shot timeout timer */\n clearTimeout: (timer: ioBroker.Timeout) => void;\n /** Async delay that gets cancelled on adapter unload */\n delay: (ms: number) => Promise<void>;\n}\n\n/** The four basic control kinds, spelled as their camelCase cloud instance. */\nexport type ControlKind = \"power\" | \"brightness\" | \"colorRgb\" | \"colorTemperature\";\n\n/**\n * Canonical predicate: does a raw Govee capability match a basic control kind?\n * Single source of truth for the group-intersection check (capability-mapper\n * `memberHasControlState`, keyed on snake_case state-ids) and command routing\n * (`CommandRouter.findCapabilityForCommand`, keyed on camelCase command tokens).\n * A duplicated copy of this shape-match drifted at the B2 rename and broke\n * cloud-only group colour control \u2014 lives here (types.ts has no adapter-core/i18n\n * imports) so both callers share one copy. Exact `shortType` match (not\n * `endsWith`) \u2192 `segment_color_setting` never counts as a colour cap.\n *\n * @param cap Raw capability (type/instance may be non-string / missing)\n * @param kind Control kind to test for\n */\nexport function capMatchesControl(\n cap: { type?: unknown; instance?: unknown } | null | undefined,\n kind: ControlKind,\n): boolean {\n if (!cap || typeof cap.type !== \"string\" || typeof cap.instance !== \"string\") {\n return false;\n }\n const shortType = cap.type.replace(\"devices.capabilities.\", \"\");\n const inst = cap.instance;\n switch (kind) {\n case \"power\":\n return shortType === \"on_off\";\n case \"brightness\":\n return shortType === \"range\" && inst.toLowerCase().includes(\"brightness\");\n case \"colorRgb\":\n return shortType === \"color_setting\" && inst === \"colorRgb\";\n case \"colorTemperature\":\n return shortType === \"color_setting\" && inst.includes(\"colorTem\");\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "govee-smart",
|
|
4
|
-
"version": "2.38.
|
|
4
|
+
"version": "2.38.3",
|
|
5
5
|
"news": {
|
|
6
|
+
"2.38.3": {
|
|
7
|
+
"en": "Internal cleanup. No user-facing changes.",
|
|
8
|
+
"de": "Interne Bereinigung. Keine sichtbaren Änderungen.",
|
|
9
|
+
"ru": "Внутренняя оптимизация. Без видимых изменений.",
|
|
10
|
+
"pt": "Limpeza interna. Sem alterações visíveis.",
|
|
11
|
+
"nl": "Interne opruiming. Geen zichtbare wijzigingen.",
|
|
12
|
+
"fr": "Nettoyage interne. Aucun changement visible.",
|
|
13
|
+
"it": "Pulizia interna. Nessuna modifica visibile.",
|
|
14
|
+
"es": "Limpieza interna. Sin cambios visibles.",
|
|
15
|
+
"pl": "Wewnętrzne porządki. Brak widocznych zmian.",
|
|
16
|
+
"uk": "Внутрішнє прибирання. Без видимих змін.",
|
|
17
|
+
"zh-cn": "内部清理。无用户可见更改。"
|
|
18
|
+
},
|
|
6
19
|
"2.38.2": {
|
|
7
20
|
"en": "Internal refactoring. No user-facing changes.",
|
|
8
21
|
"de": "Interne Überarbeitung. Keine für Nutzer sichtbaren Änderungen.",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "Polecenie, którego nie udało się wysłać, nie jest już potwierdzane, kreator segmentów przywraca jasność, a instalacja z kontem Govee, ale usuniętym kluczem API znów odczytuje wartości czujników.",
|
|
81
94
|
"uk": "Команда, яку не вдалося надіслати, більше не підтверджується, майстер сегментів повертає яскравість, а установка з обліковим записом Govee, але видаленим API-ключем знову читає значення датчиків.",
|
|
82
95
|
"zh-cn": "无法发送的指令不再被确认,分段向导会恢复亮度,保留 Govee 账户但已删除 API 密钥的安装也能重新读取传感器数值。"
|
|
83
|
-
},
|
|
84
|
-
"2.35.2": {
|
|
85
|
-
"en": "A light that is unplugged no longer shows as switched on after a start — Govee's answer for a device it cannot reach carries the values of the last contact, and those are no longer written.",
|
|
86
|
-
"de": "Eine ausgesteckte Lampe zeigt sich nach einem Start nicht mehr als eingeschaltet — Govees Antwort für ein unerreichbares Gerät trägt die Werte des letzten Kontakts, die nicht mehr geschrieben werden.",
|
|
87
|
-
"ru": "Отключённая от сети лампа после старта больше не показывается включённой — ответ Govee для недоступного устройства несёт значения последнего контакта, и они больше не записываются.",
|
|
88
|
-
"pt": "Uma luz desligada da tomada já não aparece como ligada após um arranque — a resposta da Govee para um aparelho inacessível traz os valores do último contacto, e esses já não são escritos.",
|
|
89
|
-
"nl": "Een lamp zonder stroom toont zich na een start niet meer als ingeschakeld — Govees antwoord voor een onbereikbaar apparaat draagt de waarden van het laatste contact, die niet meer worden geschreven.",
|
|
90
|
-
"fr": "Une lampe débranchée ne s'affiche plus comme allumée après un démarrage — la réponse de Govee pour un appareil injoignable porte les valeurs du dernier contact, qui ne sont plus écrites.",
|
|
91
|
-
"it": "Una luce scollegata non appare più accesa dopo un avvio — la risposta di Govee per un dispositivo irraggiungibile porta i valori dell'ultimo contatto, che non vengono più scritti.",
|
|
92
|
-
"es": "Una luz desenchufada ya no aparece encendida tras un inicio — la respuesta de Govee para un dispositivo inalcanzable trae los valores del último contacto, y esos ya no se escriben.",
|
|
93
|
-
"pl": "Odłączona od prądu lampa nie pokazuje się już po starcie jako włączona — odpowiedź Govee dla nieosiągalnego urządzenia niesie wartości ostatniego kontaktu, a te nie są już zapisywane.",
|
|
94
|
-
"uk": "Вимкнена з розетки лампа після старту більше не показується увімкненою — відповідь Govee для недосяжного пристрою несе значення останнього контакту, і вони більше не записуються.",
|
|
95
|
-
"zh-cn": "拔掉电源的灯在启动后不再显示为已开启——Govee 对无法访问的设备返回的是上次联系时的数值,这些数值不再被写入。"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"plugins": {
|