iobroker.govee-smart 2.40.0 → 2.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/admin/i18n/de.json +0 -2
- package/admin/i18n/en.json +0 -2
- package/admin/i18n/es.json +0 -2
- package/admin/i18n/fr.json +0 -2
- package/admin/i18n/it.json +0 -2
- package/admin/i18n/nl.json +0 -2
- package/admin/i18n/pl.json +0 -2
- package/admin/i18n/pt.json +0 -2
- package/admin/i18n/ru.json +0 -2
- package/admin/i18n/uk.json +0 -2
- package/admin/i18n/zh-cn.json +0 -2
- package/admin/jsonConfig.json +0 -10
- package/build/lib/cloud-outage.js +85 -0
- package/build/lib/cloud-outage.js.map +7 -0
- package/build/lib/govee-cloud-client.js +33 -9
- package/build/lib/govee-cloud-client.js.map +2 -2
- package/build/lib/govee-lan-client.js +34 -26
- package/build/lib/govee-lan-client.js.map +2 -2
- package/build/lib/govee-openapi-mqtt-client.js +1 -1
- package/build/lib/govee-openapi-mqtt-client.js.map +2 -2
- package/build/lib/handlers/cloud-retry-handler.js +27 -8
- package/build/lib/handlers/cloud-retry-handler.js.map +2 -2
- package/build/lib/handlers/connection-state.js +5 -4
- package/build/lib/handlers/connection-state.js.map +2 -2
- package/build/lib/native-key-migration.js +44 -8
- package/build/lib/native-key-migration.js.map +2 -2
- package/build/lib/state-manager.js +1 -5
- package/build/lib/state-manager.js.map +2 -2
- package/build/lib/timing-constants.js +3 -0
- package/build/lib/timing-constants.js.map +2 -2
- package/build/main.js +21 -9
- package/build/main.js.map +3 -3
- package/io-package.json +14 -15
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Full user documentation lives in the **[Wiki](https://github.com/krobipd/ioBroke
|
|
|
47
47
|
|
|
48
48
|
## Sentry / Error reporting
|
|
49
49
|
|
|
50
|
-
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting
|
|
50
|
+
**This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.** Reporting is active by default. It stays off when the ioBroker diagnostics setting is `none` (`diag` in the system configuration), when data reporting is disabled for this instance or its host (`disableDataReporting`), and on CI systems. A report contains the error with its stack trace and technical context such as versions and platform, plus an anonymous installation ID.
|
|
51
51
|
|
|
52
52
|
For details and how to disable it, see the [Sentry plugin documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry). Error reporting requires js-controller 3.0 or newer.
|
|
53
53
|
|
|
@@ -95,6 +95,11 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
|
|
|
95
95
|
### **WORK IN PROGRESS**
|
|
96
96
|
-->
|
|
97
97
|
|
|
98
|
+
### 2.41.0 (2026-09-26)
|
|
99
|
+
|
|
100
|
+
- Changed: Discovery follows the selected network interface only — the additional scan addresses setting is gone, and the broadcast goes to the network of the chosen card
|
|
101
|
+
- Fixed: `info.cloudConnected` turns false while the Govee Cloud stays unreachable and true again with its next answer — until now only a rejected API key cleared it
|
|
102
|
+
|
|
98
103
|
### 2.40.0 (2026-09-25)
|
|
99
104
|
|
|
100
105
|
- New: Optional additional scan addresses — lights in another subnet or behind a router that blocks multicast are found by asking them directly
|
|
@@ -140,10 +145,6 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
|
|
|
140
145
|
- New: 28 more Govee models are recognised — meat thermometers, motion and pressure sensors, heaters, kettles, a composter and the gateways that carry battery sensors
|
|
141
146
|
- New: H1771 Table Lamp reported working by a user
|
|
142
147
|
|
|
143
|
-
### 2.38.3 (2026-09-17) — stable
|
|
144
|
-
|
|
145
|
-
- Changed: Internal cleanup. No user-facing changes.
|
|
146
|
-
|
|
147
148
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
148
149
|
|
|
149
150
|
## Support
|
package/admin/i18n/de.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Musik-Empfindlichkeit",
|
|
75
75
|
"bindLabel": "Netzwerk-Interface",
|
|
76
76
|
"bindTooltip": "Netzwerk-Interface für LAN-Discovery und -Steuerung. Standard (0.0.0.0 = alle Interfaces) beibehalten, außer dieser Host hat mehrere Netzwerk-Interfaces.",
|
|
77
|
-
"scanTargetsLabel": "Zusätzliche Suchadressen",
|
|
78
|
-
"scanTargetsTooltip": "IPv4-Adressen von Govee-Lampen, die die Suche zusätzlich direkt fragen soll, durch Kommas getrennt — für Lampen in einem anderen Subnetz oder hinter einem Router, der Multicast und Broadcast blockt. Normalerweise leer lassen.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Ein/Aus",
|
|
81
79
|
"refreshCloud": "Cloud-Daten aktualisieren",
|
package/admin/i18n/en.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Music Sensitivity",
|
|
75
75
|
"bindLabel": "Network Interface",
|
|
76
76
|
"bindTooltip": "Select the network interface for LAN discovery and control. Keep the default (0.0.0.0 = all interfaces) unless this host has multiple network interfaces.",
|
|
77
|
-
"scanTargetsLabel": "Additional scan addresses",
|
|
78
|
-
"scanTargetsTooltip": "IPv4 addresses of Govee lights the discovery should also ask directly, separated by commas — for lights in another subnet or behind a router that blocks multicast and broadcast. Leave empty normally.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Power",
|
|
81
79
|
"refreshCloud": "Refresh Cloud Data",
|
package/admin/i18n/es.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Sensibilidad musical",
|
|
75
75
|
"bindLabel": "Interfaz de red",
|
|
76
76
|
"bindTooltip": "Selecciona la interfaz de red para el descubrimiento y control LAN. Mantén el valor predeterminado (0.0.0.0 = todas) salvo que este host tenga varias interfaces.",
|
|
77
|
-
"scanTargetsLabel": "Direcciones de búsqueda adicionales",
|
|
78
|
-
"scanTargetsTooltip": "Direcciones IPv4 de luces Govee que la búsqueda debe consultar también directamente, separadas por comas — para luces en otra subred o detrás de un router que bloquea multicast y broadcast. Normalmente déjelo vacío.",
|
|
79
77
|
"online": "Conectado",
|
|
80
78
|
"power": "Encendido",
|
|
81
79
|
"refreshCloud": "Actualizar datos Cloud",
|
package/admin/i18n/fr.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Sensibilité musicale",
|
|
75
75
|
"bindLabel": "Interface réseau",
|
|
76
76
|
"bindTooltip": "Sélectionnez l'interface réseau pour la découverte et le contrôle LAN. Conservez la valeur par défaut (0.0.0.0 = toutes) sauf si cet hôte possède plusieurs interfaces.",
|
|
77
|
-
"scanTargetsLabel": "Adresses de recherche supplémentaires",
|
|
78
|
-
"scanTargetsTooltip": "Adresses IPv4 de lampes Govee que la recherche doit aussi interroger directement, séparées par des virgules — pour des lampes dans un autre sous-réseau ou derrière un routeur qui bloque le multicast et le broadcast. Laisser vide en temps normal.",
|
|
79
77
|
"online": "En ligne",
|
|
80
78
|
"power": "Marche/Arrêt",
|
|
81
79
|
"refreshCloud": "Actualiser données Cloud",
|
package/admin/i18n/it.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Sensibilità musica",
|
|
75
75
|
"bindLabel": "Interfaccia di rete",
|
|
76
76
|
"bindTooltip": "Seleziona l'interfaccia di rete per il rilevamento e il controllo LAN. Mantieni il valore predefinito (0.0.0.0 = tutte) a meno che questo host non abbia più interfacce.",
|
|
77
|
-
"scanTargetsLabel": "Indirizzi di ricerca aggiuntivi",
|
|
78
|
-
"scanTargetsTooltip": "Indirizzi IPv4 di luci Govee che la ricerca deve interrogare anche direttamente, separati da virgole — per luci in un'altra sottorete o dietro un router che blocca multicast e broadcast. Di norma lasciare vuoto.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Accensione",
|
|
81
79
|
"refreshCloud": "Aggiorna dati Cloud",
|
package/admin/i18n/nl.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Muziekgevoeligheid",
|
|
75
75
|
"bindLabel": "Netwerkinterface",
|
|
76
76
|
"bindTooltip": "Selecteer de netwerkinterface voor LAN-detectie en -besturing. Behoud de standaard (0.0.0.0 = alle interfaces), tenzij deze host meerdere netwerkinterfaces heeft.",
|
|
77
|
-
"scanTargetsLabel": "Extra zoekadressen",
|
|
78
|
-
"scanTargetsTooltip": "IPv4-adressen van Govee-lampen die de zoekactie ook rechtstreeks moet bevragen, gescheiden door komma's — voor lampen in een ander subnet of achter een router die multicast en broadcast blokkeert. Normaal leeg laten.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Aan/Uit",
|
|
81
79
|
"refreshCloud": "Cloudgegevens vernieuwen",
|
package/admin/i18n/pl.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Czułość muzyki",
|
|
75
75
|
"bindLabel": "Interfejs sieciowy",
|
|
76
76
|
"bindTooltip": "Wybierz interfejs sieciowy do wykrywania i sterowania LAN. Pozostaw domyślny (0.0.0.0 = wszystkie), chyba że ten host ma wiele interfejsów.",
|
|
77
|
-
"scanTargetsLabel": "Dodatkowe adresy wyszukiwania",
|
|
78
|
-
"scanTargetsTooltip": "Adresy IPv4 lamp Govee, o które wyszukiwanie ma dodatkowo pytać bezpośrednio, rozdzielone przecinkami — dla lamp w innej podsieci lub za routerem blokującym multicast i broadcast. Zwykle zostaw puste.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Zasilanie",
|
|
81
79
|
"refreshCloud": "Odśwież dane Cloud",
|
package/admin/i18n/pt.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Sensibilidade musical",
|
|
75
75
|
"bindLabel": "Interface de rede",
|
|
76
76
|
"bindTooltip": "Selecione a interface de rede para descoberta e controlo LAN. Mantenha o padrão (0.0.0.0 = todas) a menos que este host tenha várias interfaces.",
|
|
77
|
-
"scanTargetsLabel": "Endereços de pesquisa adicionais",
|
|
78
|
-
"scanTargetsTooltip": "Endereços IPv4 de luzes Govee que a pesquisa deve também consultar diretamente, separados por vírgulas — para luzes noutra sub-rede ou atrás de um router que bloqueia multicast e broadcast. Normalmente deixe vazio.",
|
|
79
77
|
"online": "Online",
|
|
80
78
|
"power": "Ligar/Desligar",
|
|
81
79
|
"refreshCloud": "Atualizar dados da Cloud",
|
package/admin/i18n/ru.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Чувствительность к музыке",
|
|
75
75
|
"bindLabel": "Сетевой интерфейс",
|
|
76
76
|
"bindTooltip": "Выберите сетевой интерфейс для обнаружения и управления по LAN. Оставьте значение по умолчанию (0.0.0.0 = все интерфейсы), если у хоста нет нескольких интерфейсов.",
|
|
77
|
-
"scanTargetsLabel": "Дополнительные адреса поиска",
|
|
78
|
-
"scanTargetsTooltip": "IPv4-адреса ламп Govee, которые поиск должен дополнительно опрашивать напрямую, через запятую — для ламп в другой подсети или за маршрутизатором, блокирующим multicast и broadcast. Обычно оставьте пустым.",
|
|
79
77
|
"online": "Онлайн",
|
|
80
78
|
"power": "Питание",
|
|
81
79
|
"refreshCloud": "Обновить данные Cloud",
|
package/admin/i18n/uk.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "Чутливість до музики",
|
|
75
75
|
"bindLabel": "Мережевий інтерфейс",
|
|
76
76
|
"bindTooltip": "Виберіть мережевий інтерфейс для виявлення та керування по LAN. Залиште типове значення (0.0.0.0 = всі інтерфейси), якщо хост не має кількох інтерфейсів.",
|
|
77
|
-
"scanTargetsLabel": "Додаткові адреси пошуку",
|
|
78
|
-
"scanTargetsTooltip": "IPv4-адреси ламп Govee, які пошук має додатково опитувати напряму, через кому — для ламп в іншій підмережі або за маршрутизатором, що блокує multicast і broadcast. Зазвичай залиште порожнім.",
|
|
79
77
|
"online": "Онлайн",
|
|
80
78
|
"power": "Живлення",
|
|
81
79
|
"refreshCloud": "Оновити дані Cloud",
|
package/admin/i18n/zh-cn.json
CHANGED
|
@@ -74,8 +74,6 @@
|
|
|
74
74
|
"musicSensitivity": "音乐灵敏度",
|
|
75
75
|
"bindLabel": "网络接口",
|
|
76
76
|
"bindTooltip": "选择用于局域网发现和控制的网络接口。除非此主机有多个网络接口,否则请保留默认值(0.0.0.0 = 所有接口)。",
|
|
77
|
-
"scanTargetsLabel": "附加扫描地址",
|
|
78
|
-
"scanTargetsTooltip": "发现过程还应直接询问的 Govee 灯的 IPv4 地址,用逗号分隔——适用于位于其他子网或位于阻止组播和广播的路由器之后的灯。通常留空。",
|
|
79
77
|
"online": "在线",
|
|
80
78
|
"power": "电源",
|
|
81
79
|
"refreshCloud": "刷新云数据",
|
package/admin/jsonConfig.json
CHANGED
|
@@ -77,16 +77,6 @@
|
|
|
77
77
|
"lg": 6,
|
|
78
78
|
"xl": 6
|
|
79
79
|
},
|
|
80
|
-
"scanTargets": {
|
|
81
|
-
"type": "text",
|
|
82
|
-
"label": "scanTargetsLabel",
|
|
83
|
-
"tooltip": "scanTargetsTooltip",
|
|
84
|
-
"xs": 12,
|
|
85
|
-
"sm": 12,
|
|
86
|
-
"md": 6,
|
|
87
|
-
"lg": 6,
|
|
88
|
-
"xl": 6
|
|
89
|
-
},
|
|
90
80
|
"_advancedInfo": {
|
|
91
81
|
"newLine": true,
|
|
92
82
|
"type": "staticText",
|
|
@@ -0,0 +1,85 @@
|
|
|
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 cloud_outage_exports = {};
|
|
20
|
+
__export(cloud_outage_exports, {
|
|
21
|
+
CloudOutage: () => CloudOutage,
|
|
22
|
+
cloudReachable: () => cloudReachable
|
|
23
|
+
});
|
|
24
|
+
module.exports = __toCommonJS(cloud_outage_exports);
|
|
25
|
+
var import_timing_constants = require("./timing-constants");
|
|
26
|
+
class CloudOutage {
|
|
27
|
+
firstFailureAt = null;
|
|
28
|
+
firstReason = "";
|
|
29
|
+
isConfirmed = false;
|
|
30
|
+
/** The outage is confirmed — `info.cloudConnected` shows false. */
|
|
31
|
+
get confirmed() {
|
|
32
|
+
return this.isConfirmed;
|
|
33
|
+
}
|
|
34
|
+
/** When the first failed call of the current run happened, or null. */
|
|
35
|
+
get since() {
|
|
36
|
+
return this.firstFailureAt;
|
|
37
|
+
}
|
|
38
|
+
/** Why the first failed call of the current run failed. */
|
|
39
|
+
get reason() {
|
|
40
|
+
return this.firstReason;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Note a call that could not reach Govee.
|
|
44
|
+
*
|
|
45
|
+
* @param now Time of the failure (ms)
|
|
46
|
+
* @param reason The error text of the failed call
|
|
47
|
+
* @returns true exactly when this failure confirms the outage
|
|
48
|
+
*/
|
|
49
|
+
noteUnreachable(now, reason) {
|
|
50
|
+
if (this.isConfirmed) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (this.firstFailureAt === null) {
|
|
54
|
+
this.firstFailureAt = now;
|
|
55
|
+
this.firstReason = reason;
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
if (now - this.firstFailureAt < import_timing_constants.CLOUD_UNREACHABLE_CONFIRM_MS) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
this.isConfirmed = true;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Note an answer Govee accepted.
|
|
66
|
+
*
|
|
67
|
+
* @returns true exactly when this answer ends a confirmed outage
|
|
68
|
+
*/
|
|
69
|
+
noteAnswer() {
|
|
70
|
+
const wasConfirmed = this.isConfirmed;
|
|
71
|
+
this.firstFailureAt = null;
|
|
72
|
+
this.firstReason = "";
|
|
73
|
+
this.isConfirmed = false;
|
|
74
|
+
return wasConfirmed;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function cloudReachable(adapter) {
|
|
78
|
+
return adapter.cloudWasConnected && !adapter.cloudOutage.confirmed;
|
|
79
|
+
}
|
|
80
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
81
|
+
0 && (module.exports = {
|
|
82
|
+
CloudOutage,
|
|
83
|
+
cloudReachable
|
|
84
|
+
});
|
|
85
|
+
//# sourceMappingURL=cloud-outage.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/lib/cloud-outage.ts"],
|
|
4
|
+
"sourcesContent": ["import { CLOUD_UNREACHABLE_CONFIRM_MS } from \"./timing-constants\";\n\n/**\n * Whether the Govee Cloud is down \u2014 decided from the outcome of real calls,\n * never from a probe of its own (2.41.0, issue #51).\n *\n * A call that could not reach Govee (no answer, 5xx) is noted; a second one at\n * least {@link CLOUD_UNREACHABLE_CONFIRM_MS} later, with no accepted answer in\n * between, confirms the outage. Any accepted answer ends it. Separate from\n * `cloudWasConnected`, which keeps meaning \"API key accepted / list loaded\" \u2014\n * the auth guard and the list-retry loop read that one unchanged.\n */\nexport class CloudOutage {\n private firstFailureAt: number | null = null;\n private firstReason = \"\";\n private isConfirmed = false;\n\n /** The outage is confirmed \u2014 `info.cloudConnected` shows false. */\n get confirmed(): boolean {\n return this.isConfirmed;\n }\n\n /** When the first failed call of the current run happened, or null. */\n get since(): number | null {\n return this.firstFailureAt;\n }\n\n /** Why the first failed call of the current run failed. */\n get reason(): string {\n return this.firstReason;\n }\n\n /**\n * Note a call that could not reach Govee.\n *\n * @param now Time of the failure (ms)\n * @param reason The error text of the failed call\n * @returns true exactly when this failure confirms the outage\n */\n noteUnreachable(now: number, reason: string): boolean {\n if (this.isConfirmed) {\n return false;\n }\n if (this.firstFailureAt === null) {\n this.firstFailureAt = now;\n this.firstReason = reason;\n return false;\n }\n if (now - this.firstFailureAt < CLOUD_UNREACHABLE_CONFIRM_MS) {\n return false;\n }\n this.isConfirmed = true;\n return true;\n }\n\n /**\n * Note an answer Govee accepted.\n *\n * @returns true exactly when this answer ends a confirmed outage\n */\n noteAnswer(): boolean {\n const wasConfirmed = this.isConfirmed;\n this.firstFailureAt = null;\n this.firstReason = \"\";\n this.isConfirmed = false;\n return wasConfirmed;\n }\n}\n\n/** The two fields {@link cloudReachable} reads from the handler host. */\nexport interface CloudReachabilityView {\n /** Key accepted / list loaded. */\n readonly cloudWasConnected: boolean;\n /** The outage tracker fed by the Cloud contact hook. */\n readonly cloudOutage: { readonly confirmed: boolean };\n}\n\n/**\n * The Cloud as the user sees it: the key is accepted / the list is loaded\n * (`cloudWasConnected`) AND real calls do not say it is down (issue #51).\n *\n * @param adapter Handler host\n */\nexport function cloudReachable(adapter: CloudReachabilityView): boolean {\n return adapter.cloudWasConnected && !adapter.cloudOutage.confirmed;\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,8BAA6C;AAYtC,MAAM,YAAY;AAAA,EACf,iBAAgC;AAAA,EAChC,cAAc;AAAA,EACd,cAAc;AAAA;AAAA,EAGtB,IAAI,YAAqB;AACvB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,QAAuB;AACzB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGA,IAAI,SAAiB;AACnB,WAAO,KAAK;AAAA,EACd;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,gBAAgB,KAAa,QAAyB;AACpD,QAAI,KAAK,aAAa;AACpB,aAAO;AAAA,IACT;AACA,QAAI,KAAK,mBAAmB,MAAM;AAChC,WAAK,iBAAiB;AACtB,WAAK,cAAc;AACnB,aAAO;AAAA,IACT;AACA,QAAI,MAAM,KAAK,iBAAiB,sDAA8B;AAC5D,aAAO;AAAA,IACT;AACA,SAAK,cAAc;AACnB,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,aAAsB;AACpB,UAAM,eAAe,KAAK;AAC1B,SAAK,iBAAiB;AACtB,SAAK,cAAc;AACnB,SAAK,cAAc;AACnB,WAAO;AAAA,EACT;AACF;AAgBO,SAAS,eAAe,SAAyC;AACtE,SAAO,QAAQ,qBAAqB,CAAC,QAAQ,YAAY;AAC3D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -70,6 +70,25 @@ function readRateLimitHeaders(headers) {
|
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
72
|
const BASE_URL = "https://openapi.api.govee.com";
|
|
73
|
+
const TLS_ERROR_CODES = /* @__PURE__ */ new Set([
|
|
74
|
+
"CERT_HAS_EXPIRED",
|
|
75
|
+
"CERT_NOT_YET_VALID",
|
|
76
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
77
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
78
|
+
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
79
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
80
|
+
"ERR_TLS_CERT_ALTNAME_INVALID"
|
|
81
|
+
]);
|
|
82
|
+
function isUnreachable(err, category) {
|
|
83
|
+
if (category === "NETWORK" || category === "TIMEOUT") {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (err instanceof import_http_client.HttpError) {
|
|
87
|
+
return err.statusCode >= 500;
|
|
88
|
+
}
|
|
89
|
+
const code = err == null ? void 0 : err.code;
|
|
90
|
+
return typeof code === "string" && TLS_ERROR_CODES.has(code);
|
|
91
|
+
}
|
|
73
92
|
let requestIdCounter = 0;
|
|
74
93
|
function nextRequestId(prefix) {
|
|
75
94
|
requestIdCounter = (requestIdCounter + 1) % 1e6;
|
|
@@ -114,10 +133,11 @@ class GoveeCloudClient {
|
|
|
114
133
|
*/
|
|
115
134
|
lastErrorCategory = null;
|
|
116
135
|
/**
|
|
117
|
-
* Hook called after every Cloud
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
136
|
+
* Hook called after every Cloud call that says something about the
|
|
137
|
+
* connection: `ok` for any accepted call, `auth-failed` for a 401/403,
|
|
138
|
+
* `unreachable` for no answer or a 5xx ({@link isUnreachable}). A 429 or
|
|
139
|
+
* another 4xx stays silent — Govee answered. One `unreachable` alone decides
|
|
140
|
+
* nothing; the handler waits for a second one a minute later.
|
|
121
141
|
*/
|
|
122
142
|
onContact = null;
|
|
123
143
|
/**
|
|
@@ -163,10 +183,10 @@ class GoveeCloudClient {
|
|
|
163
183
|
this.onResponse = cb;
|
|
164
184
|
}
|
|
165
185
|
/**
|
|
166
|
-
* Register the hook that learns
|
|
186
|
+
* Register the hook that learns what each Cloud call said — see
|
|
167
187
|
* {@link onContact}. A throwing hook never fails the request.
|
|
168
188
|
*
|
|
169
|
-
* @param cb Callback receiving `ok
|
|
189
|
+
* @param cb Callback receiving `ok`, `auth-failed` or `unreachable` (with the error text)
|
|
170
190
|
*/
|
|
171
191
|
setContactHook(cb) {
|
|
172
192
|
this.onContact = cb;
|
|
@@ -390,18 +410,22 @@ class GoveeCloudClient {
|
|
|
390
410
|
throw err;
|
|
391
411
|
}
|
|
392
412
|
this.lastErrorCategory = (0, import_types.classifyError)(err);
|
|
413
|
+
if (isUnreachable(err, this.lastErrorCategory)) {
|
|
414
|
+
this.reportContact("unreachable", (0, import_types.errMessage)(err));
|
|
415
|
+
}
|
|
393
416
|
throw err;
|
|
394
417
|
}
|
|
395
418
|
}
|
|
396
419
|
/**
|
|
397
420
|
* Tell the contact hook — a throwing hook is logged, never rethrown.
|
|
398
421
|
*
|
|
399
|
-
* @param outcome What the
|
|
422
|
+
* @param outcome What the call said
|
|
423
|
+
* @param reason The error text of an `unreachable` call
|
|
400
424
|
*/
|
|
401
|
-
reportContact(outcome) {
|
|
425
|
+
reportContact(outcome, reason) {
|
|
402
426
|
var _a;
|
|
403
427
|
try {
|
|
404
|
-
(_a = this.onContact) == null ? void 0 : _a.call(this, outcome);
|
|
428
|
+
(_a = this.onContact) == null ? void 0 : _a.call(this, outcome, reason);
|
|
405
429
|
} catch (e) {
|
|
406
430
|
this.log.debug(`Cloud contact hook failed: ${(0, import_types.errMessage)(e)}`);
|
|
407
431
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/govee-cloud-client.ts"],
|
|
4
|
-
"sourcesContent": ["import { formatFallback, httpsRequest, HttpError, type HttpsRequestFn } from \"./http-client\";\n\n/**\n * Govee accepted the request but refused the command (payload-level `code` or\n * a per-capability `failure`). `deviceOffline` names the one refusal the\n * adapter acts on: the device is not talking to Govee right now \u2014 the command\n * is held for the device's next sign of life (issue #46, 2.39.0).\n */\nexport class CloudControlRejected extends Error {\n /**\n * @param message The rejection line (sku/device/instance, code, reason)\n * @param deviceOffline Whether Govee's reason says the device was offline\n */\n constructor(\n message: string,\n public readonly deviceOffline: boolean,\n ) {\n super(message);\n this.name = \"CloudControlRejected\";\n }\n}\n\n/**\n * Whether a rejection reason says the device was offline \u2014 a whole word, never a substring.\n *\n * @param reason Govee's `msg` / `errorMsg`\n */\nfunction reasonSaysOffline(reason: unknown): boolean {\n return typeof reason === \"string\" && /\\boffline\\b/i.test(reason);\n}\n\n/**\n * What Govee says about its own budget, read off one answer's headers. The\n * named fields follow the v1 docs (`X-RateLimit-*` = day, `API-RateLimit-*` =\n * minute); `raw` keeps every header whose name mentions the rate limit, so\n * a v2 rename still shows up in the report.\n */\nexport interface GoveeRateLimit {\n /** The endpoint whose answer carried the headers. */\n endpoint: string;\n /** When that answer arrived (ms epoch). */\n at: number;\n /** `X-RateLimit-Limit` \u2014 the day's ceiling, when Govee names one. */\n dayLimit?: number;\n /** `X-RateLimit-Remaining` \u2014 what is left of the day. */\n dayRemaining?: number;\n /** `X-RateLimit-Reset` \u2014 when the day's window resets (epoch seconds). */\n dayReset?: number;\n /** `API-RateLimit-Limit` \u2014 the minute's ceiling. */\n minuteLimit?: number;\n /** `API-RateLimit-Remaining` \u2014 what is left of the minute. */\n minuteRemaining?: number;\n /** `API-RateLimit-Reset` \u2014 when the minute's window resets (epoch seconds). */\n minuteReset?: number;\n /** Every header whose name mentions the rate limit, verbatim (lower-cased names). */\n raw: Record<string, string>;\n}\n\n/**\n * Pull the rate-limit headers out of an answer. Null when the answer carries\n * none \u2014 an unchanged `lastRateLimit` then says \"Govee said nothing new\".\n *\n * @param headers Response headers (any case)\n */\nexport function readRateLimitHeaders(\n headers: Record<string, string | string[] | undefined>,\n): Omit<GoveeRateLimit, \"endpoint\" | \"at\"> | null {\n const raw: Record<string, string> = {};\n for (const [name, value] of Object.entries(headers)) {\n if (!/ratelimit/i.test(name) || value === undefined) {\n continue;\n }\n raw[name.toLowerCase()] = Array.isArray(value) ? value.join(\",\") : String(value);\n }\n if (Object.keys(raw).length === 0) {\n return null;\n }\n const num = (key: string): number | undefined => {\n const v = raw[key];\n if (v === undefined) {\n return undefined;\n }\n const n = Number(v);\n return Number.isFinite(n) ? n : undefined;\n };\n return {\n dayLimit: num(\"x-ratelimit-limit\"),\n dayRemaining: num(\"x-ratelimit-remaining\"),\n dayReset: num(\"x-ratelimit-reset\"),\n minuteLimit: num(\"api-ratelimit-limit\"),\n minuteRemaining: num(\"api-ratelimit-remaining\"),\n minuteReset: num(\"api-ratelimit-reset\"),\n raw,\n };\n}\nimport {\n classifyError,\n errMessage,\n type CapabilityOption,\n type CloudDevice,\n type CloudDeviceListResponse,\n type CloudDeviceStateResponse,\n type CloudScene,\n type CloudScenesResponse,\n type CloudStateCapability,\n type ErrorCategory,\n} from \"./types\";\n\nconst BASE_URL = \"https://openapi.api.govee.com\";\n\n/** What one Cloud answer says about the API key \u2014 see `GoveeCloudClient.setContactHook`. */\nexport type CloudContact = \"ok\" | \"auth-failed\";\n\n/**\n * Monotonic counter for `requestId` so two calls in the same millisecond\n * don't collide. Govee uses requestId for idempotency on its side; concurrent\n * calls with identical requestIds can be coalesced or rejected as duplicates.\n * Module-level so all clients share the same sequence.\n */\nlet requestIdCounter = 0;\n\n/**\n * Build a unique `requestId` (`<prefix>_<ms>_<counter>`).\n *\n * @param prefix Short label identifying the call site (e.g. \"ctrl\", \"scenes\").\n */\nfunction nextRequestId(prefix: string): string {\n requestIdCounter = (requestIdCounter + 1) % 1_000_000;\n return `${prefix}_${Date.now()}_${requestIdCounter}`;\n}\n\n/**\n * Map a scenes-endpoint capability's options to CloudScene entries, dropping\n * malformed options (missing name or non-object value). Shared by getScenes\n * and getDiyScenes.\n *\n * @param opts Raw options array from a scenes-endpoint capability\n */\nfunction mapSceneOptions(opts: CapabilityOption[] | undefined): CloudScene[] {\n return (Array.isArray(opts) ? opts : [])\n .filter(\n // `typeof null === \"object\"` \u2014 the old object-only guard let null-valued\n // options through as phantom entries AND dropped the integer values that\n // snapshots use (CloudScene.value is `Record | number`). Accept object OR\n // number, reject null/undefined (L7).\n (o): o is { name: string; value: Record<string, unknown> | number } =>\n !!o &&\n typeof o.name === \"string\" &&\n o.value != null &&\n (typeof o.value === \"object\" || typeof o.value === \"number\"),\n )\n .map(o => ({ name: o.name, value: o.value }));\n}\n\n/**\n * Govee answers a device endpoint with HTTP 200 and a body-level `code` when it\n * refuses the call (`400 devices not exist`, `Invalid parameter type`); the\n * reason sits in `msg`. Such an answer is a failure, never an empty result.\n *\n * @param resp The parsed answer (null/undefined pass \u2014 the callers handle them)\n * @param what Start of the error message, naming the call and the device\n */\nfunction throwIfRejected(\n resp: { code?: unknown; msg?: unknown; message?: unknown } | null | undefined,\n what: string,\n): void {\n if (resp && typeof resp.code === \"number\" && resp.code !== 200 && resp.code !== 0) {\n // The device list names its reason `message`, the state/scene envelopes `msg`.\n const reason = [resp.msg, resp.message].find(r => typeof r === \"string\" && r);\n const msg = typeof reason === \"string\" ? ` \u2014 ${reason}` : \"\";\n throw new Error(`${what}: code=${resp.code}${msg}`);\n }\n}\n\n/**\n * Govee Cloud API v2 client.\n * Used for device list, capabilities, scenes, segments, and as control fallback.\n */\nexport class GoveeCloudClient {\n private readonly apiKey: string;\n private readonly log: ioBroker.Logger;\n private readonly httpsRequestImpl: HttpsRequestFn;\n private lastRateLimit: GoveeRateLimit | null = null;\n /**\n * True if a previous getDevices call returned an empty array \u2014 the first\n * empty result emits an info log so the user has a starting point for\n * \"where are my devices?\", repeats stay silent.\n */\n private warnedEmptyDeviceList = false;\n /**\n * Diagnostics hook \u2014 receives (deviceId, endpoint, body) for each\n * response. Optional; the adapter wires it to a DiagnosticsCollector\n * for the diagnostics report.\n */\n private onResponse:\n ((deviceId: string, endpoint: string, body: unknown, rateLimit?: GoveeRateLimit | null) => void) | null = null;\n\n /**\n * Last error category for getFailureReason() \u2014 set on every HTTP error in\n * the request path.\n */\n private lastErrorCategory: ErrorCategory | null = null;\n\n /**\n * Hook called after every Cloud answer that says something about the API\n * key: `ok` for any accepted call, `auth-failed` for a 401/403. Other\n * failures (429, 5xx, network) stay silent \u2014 one failed state query says\n * nothing about the Cloud connection as a whole.\n */\n private onContact: ((outcome: CloudContact) => void) | null = null;\n\n /**\n * @param apiKey Govee API key\n * @param log ioBroker logger\n * @param httpsRequestImpl optional DI for tests \u2014 default is the real httpsRequest\n */\n constructor(apiKey: string, log: ioBroker.Logger, httpsRequestImpl: HttpsRequestFn = httpsRequest) {\n this.apiKey = apiKey;\n this.log = log;\n this.httpsRequestImpl = httpsRequestImpl;\n }\n\n /**\n * Short user-facing reason for \"Cloud not connected\", or null when the\n * client has not seen an error yet. Like the mqtt-client \u2014 `logDeviceSummary`\n * uses it so the adapter can log clear diagnostic text instead of\n * \"see earlier errors\".\n */\n getFailureReason(): string | null {\n switch (this.lastErrorCategory) {\n case \"AUTH\":\n return \"API key rejected \u2014 check Govee API key\";\n case \"RATE_LIMIT\":\n return \"rate-limited by Govee \u2014 will retry\";\n case \"NETWORK\":\n return \"cannot reach Govee servers \u2014 will retry\";\n case \"TIMEOUT\":\n return \"Cloud request timeout\";\n case \"UNKNOWN\":\n return \"Cloud request failed \u2014 see earlier log\";\n case null:\n default:\n return null;\n }\n }\n\n /**\n * Register a hook called after every successful Cloud API response.\n * Used to populate the DiagnosticsCollector ring buffer.\n *\n * @param cb Callback receiving (deviceId, endpoint, body)\n */\n setResponseHook(\n cb: ((deviceId: string, endpoint: string, body: unknown, rateLimit?: GoveeRateLimit | null) => void) | null,\n ): void {\n this.onResponse = cb;\n }\n\n /**\n * Register the hook that learns whether Govee accepted the API key \u2014 see\n * {@link onContact}. A throwing hook never fails the request.\n *\n * @param cb Callback receiving `ok` or `auth-failed`\n */\n setContactHook(cb: ((outcome: CloudContact) => void) | null): void {\n this.onContact = cb;\n }\n\n /**\n * The newest set of rate-limit headers Govee sent, or null before the first\n * answer that carried any. The diagnostics report shows it; that is the\n * measurement the daily numbers of the budget wait for (see CLOUD_LIMITS).\n */\n getLastRateLimit(): GoveeRateLimit | null {\n return this.lastRateLimit;\n }\n\n /** Fetch all devices with their capabilities */\n async getDevices(): Promise<CloudDevice[]> {\n const resp = await this.request<CloudDeviceListResponse>(\"GET\", \"/router/api/v1/user/devices\");\n // A rejection inside the envelope (HTTP 200, `code` \u2260 200) is a failed\n // list, never an empty account \u2014 it throws so the retry loop takes it.\n throwIfRejected(resp, \"Device list rejected\");\n // Defensive \u2014 API can drift. Guard for non-array to protect downstream iteration.\n const devices = Array.isArray(resp?.data) ? resp.data : [];\n // v2.9.1 \u2014 fire onResponse per-device so every device sees the canonical\n // capability data Govee Cloud has for it in its own apiHistory. Without\n // this, the user-devices endpoint never appeared in diag \u2014 even though\n // it's the authoritative source of truth for what Govee thinks each\n // device can do. Per-device is the right grain because callers want to\n // see \"what Cloud said about MY device\" without scanning a global list.\n if (this.onResponse) {\n for (const cd of devices) {\n if (cd && typeof cd.device === \"string\" && cd.device) {\n this.onResponse(cd.device, \"/router/api/v1/user/devices\", cd, this.lastRateLimit);\n }\n }\n }\n if (devices.length === 0 && !this.warnedEmptyDeviceList) {\n this.warnedEmptyDeviceList = true;\n // First empty response is worth surfacing \u2014 common cause is \"API key\n // belongs to a different Govee account than where the devices were\n // added\" or \"user hasn't enabled control-via-cloud per device\". Repeat\n // calls keep quiet so a temporarily flaky API doesn't spam.\n this.log.info(`Cloud: device list returned empty \u2014 check the API key matches the account that owns the devices`);\n } else if (devices.length > 0) {\n this.warnedEmptyDeviceList = false;\n }\n return devices;\n }\n\n /**\n * Fetch current state of a device\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getDeviceState(sku: string, device: string): Promise<CloudStateCapability[]> {\n const resp = await this.request<CloudDeviceStateResponse>(\"POST\", \"/router/api/v1/device/state\", {\n requestId: nextRequestId(\"state\"),\n payload: { sku, device },\n });\n if (resp === null) {\n // HTTP 200 without a body \u2014 the http client's word for \"nothing came\".\n this.onResponse?.(device, \"/router/api/v1/device/state\", resp, this.lastRateLimit);\n return [];\n }\n // A rejection and an answer of unknown shape THROW \u2014 both callers catch\n // per device and record the reason in the diagnostics report. Only a\n // payload without capabilities is a legitimate empty result. Folding all\n // of it into [] is how the wrong envelope stayed invisible from v0.1.0 to\n // 2.34.0: \"no value\" and \"wrong field\" rendered the same (issue #47).\n throwIfRejected(resp, `Device state rejected for ${sku}/${device}`);\n if (typeof resp.payload !== \"object\" || resp.payload === null) {\n throw new Error(`Device state answer for ${sku}/${device} carries no payload \u2014 has Govee changed the envelope?`);\n }\n // Recorded once the answer is accepted: a rejection reaches the report as\n // the caller's failure entry, not a second time as a \"successful\" answer.\n this.onResponse?.(device, \"/router/api/v1/device/state\", resp, this.lastRateLimit);\n // Govee wraps this answer in `payload`, like the scene endpoints below \u2014\n // `data` is the envelope of the device LIST (three captures: the\n // reporter's export, tukey42's H61A8, Govee's own docs).\n const caps = resp.payload.capabilities;\n return Array.isArray(caps) ? caps : [];\n }\n\n /**\n * Send a control command to a device\n *\n * @param sku Product model\n * @param device Device ID\n * @param capabilityType Full capability type string\n * @param instance Capability instance name\n * @param value Value to set\n */\n async controlDevice(\n sku: string,\n device: string,\n capabilityType: string,\n instance: string,\n value: unknown,\n ): Promise<void> {\n const reqBody = {\n requestId: nextRequestId(\"ctrl\"),\n payload: {\n sku,\n device,\n capability: {\n type: capabilityType,\n instance,\n value,\n },\n },\n };\n const resp = await this.request<{\n code?: number;\n msg?: string;\n message?: string;\n capability?: { state?: { status?: string; errorCode?: number; errorMsg?: string } };\n }>(\"POST\", \"/router/api/v1/device/control\", reqBody);\n this.onResponse?.(\n device,\n \"/router/api/v1/device/control\",\n { request: reqBody.payload.capability, response: resp },\n this.lastRateLimit,\n );\n // Govee returns 200 with a payload-level `code`/`msg` on logical failures\n // (capability not allowed, wrong value shape, device offline as seen by\n // Cloud). The field is `msg` \u2014 reading `message` dropped the only\n // actionable half of the line (\"Invalid parameter type\", issue #47) \u2014 and\n // the per-capability `state.status` can say failure while the envelope\n // still says 200.\n const capState = resp?.capability?.state;\n // Govee names the reason `msg`, not `message` \u2014 reading the wrong field left\n // the log line at a bare \"code=400\" and threw away the only actionable half\n // (issue #47). `message` stays as a fallback for the older shape.\n const reason = resp?.msg ?? resp?.message ?? capState?.errorMsg;\n if (resp && typeof resp.code === \"number\" && resp.code !== 200 && resp.code !== 0) {\n throw new CloudControlRejected(\n `Cloud control rejected for ${sku}/${device}/${instance}: code=${resp.code}${reason ? ` \u2014 ${reason}` : \"\"}`,\n reasonSaysOffline(reason),\n );\n }\n // A rejection can arrive with a 200 envelope while the per-capability state\n // carries the failure \u2014 without this the command was acked as done\n // (issue #47). Either marker is enough: the captured payload sets both, and\n // a lone errorCode must not slip through.\n if (capState?.status === \"failure\" || typeof capState?.errorCode === \"number\") {\n throw new CloudControlRejected(\n `Cloud control rejected for ${sku}/${device}/${instance}: code=${capState.errorCode ?? \"?\"}${reason ? ` \u2014 ${reason}` : \"\"}`,\n reasonSaysOffline(reason),\n );\n }\n }\n\n /**\n * Fetch dynamic scenes and snapshots for a device.\n * The scenes endpoint returns capabilities with options.\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getScenes(\n sku: string,\n device: string,\n ): Promise<{\n lightScenes: CloudScene[];\n diyScenes: CloudScene[];\n snapshots: CloudScene[];\n }> {\n const resp = await this.request<CloudScenesResponse>(\"POST\", \"/router/api/v1/device/scenes\", {\n requestId: nextRequestId(\"scenes\"),\n payload: { sku, device },\n });\n // A rejection is not \"this device has no scenes\": read as empty, it took\n // the snapshot list from the capability fallback and marked the scenes as\n // checked. It throws; the loader keeps what it had (issue #50 follow-up).\n throwIfRejected(resp, `Scenes rejected for ${sku}/${device}`);\n this.onResponse?.(device, \"/router/api/v1/device/scenes\", resp, this.lastRateLimit);\n\n const lightScenes: CloudScene[] = [];\n const diyScenes: CloudScene[] = [];\n const snapshots: CloudScene[] = [];\n\n const caps = Array.isArray(resp?.payload?.capabilities) ? resp.payload.capabilities : [];\n for (const cap of caps) {\n if (!cap || typeof cap.instance !== \"string\") {\n continue;\n }\n const opts = Array.isArray(cap.parameters?.options) ? cap.parameters.options : [];\n this.log.debug(`Scenes endpoint: instance=${cap.instance}, options=${opts.length}`);\n const mapped = mapSceneOptions(opts);\n\n if (cap.instance === \"lightScene\") {\n lightScenes.push(...mapped);\n } else if (cap.instance === \"diyScene\") {\n diyScenes.push(...mapped);\n } else if (cap.instance === \"snapshot\") {\n snapshots.push(...mapped);\n }\n }\n\n return { lightScenes, diyScenes, snapshots };\n }\n\n /**\n * Fetch DIY scenes for a device from the dedicated diy-scenes endpoint.\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getDiyScenes(sku: string, device: string): Promise<CloudScene[]> {\n const resp = await this.request<CloudScenesResponse>(\"POST\", \"/router/api/v1/device/diy-scenes\", {\n requestId: nextRequestId(\"diy\"),\n payload: { sku, device },\n });\n throwIfRejected(resp, `DIY scenes rejected for ${sku}/${device}`);\n this.onResponse?.(device, \"/router/api/v1/device/diy-scenes\", resp, this.lastRateLimit);\n\n const scenes: CloudScene[] = [];\n const caps = Array.isArray(resp?.payload?.capabilities) ? resp.payload.capabilities : [];\n for (const cap of caps) {\n if (!cap || typeof cap.instance !== \"string\") {\n continue;\n }\n const opts = Array.isArray(cap.parameters?.options) ? cap.parameters.options : [];\n this.log.debug(`DIY-Scenes endpoint: instance=${cap.instance}, options=${opts.length}`);\n scenes.push(...mapSceneOptions(opts));\n }\n\n return scenes;\n }\n\n /**\n * Make an HTTPS request to the Govee Cloud API\n *\n * @param method HTTP method (GET, POST)\n * @param path API endpoint path\n * @param body Optional request body\n */\n /**\n * Read Govee's rate-limit headers off one answer and keep the newest set.\n * The v1 docs name `X-RateLimit-*` (day) and `API-RateLimit-*` (minute);\n * every header whose name mentions the rate limit is kept verbatim as well,\n * so a renamed header on v2 still reaches the report.\n *\n * @param path The endpoint the answer belongs to\n * @param headers The answer's headers\n */\n private noteRateLimit(path: string, headers: Record<string, string | string[] | undefined>): void {\n const info = readRateLimitHeaders(headers);\n if (!info) {\n return;\n }\n const first = this.lastRateLimit === null;\n this.lastRateLimit = { ...info, endpoint: path, at: Date.now() };\n if (first) {\n this.log.debug(`Cloud API rate-limit headers (first seen): ${JSON.stringify(info.raw)}`);\n }\n }\n\n private async request<T>(method: string, path: string, body?: unknown): Promise<T | null> {\n this.log.debug(`Cloud API: ${method} ${path} auth=apiKey`);\n try {\n const result = await this.httpsRequestImpl<T>({\n method: method as \"GET\" | \"POST\",\n url: new URL(path, BASE_URL).toString(),\n headers: { \"Govee-API-Key\": this.apiKey },\n body,\n });\n if (result.fallback) {\n this.log.debug(`Cloud API: ${method} ${path}: ${formatFallback(result)}`);\n }\n this.noteRateLimit(path, result.headers ?? {});\n // Reset the failure category on success \u2014 getFailureReason() then returns\n // null until the next error.\n this.lastErrorCategory = null;\n this.reportContact(\"ok\");\n return result.value;\n } catch (err) {\n // Classify 429 explicitly by status code \u2014 classifyError only looks at\n // err.message, and HttpError(\"Too many requests\", 429, \u2026) has no \"429\"\n // or \"Rate limit\" marker in the text. Without this branch 429 lands as\n // UNKNOWN and getFailureReason() returns the wrong ready hint.\n if (err instanceof HttpError) {\n // A rejection carries headers too \u2014 a 429 is the one answer whose\n // rate-limit headers matter most.\n this.noteRateLimit(path, err.headers);\n }\n if (err instanceof HttpError && err.statusCode === 429) {\n this.lastErrorCategory = \"RATE_LIMIT\";\n const retryAfter = String(err.headers[\"retry-after\"] ?? \"unknown\");\n throw new HttpError(`Rate limited \u2014 retry after ${retryAfter}s`, 429, err.headers);\n }\n // Classify 401/403 explicitly by status code (L26) \u2014 a 401/403 whose body\n // doesn't contain \"auth\"/\"unauthorized\" (e.g. \"Access denied\") must still\n // produce the actionable \"API key rejected \u2014 check Govee API key\" instead\n // of \"Cloud request failed\".\n //\n // classifyError() reads `err.statusCode` itself since the text-marker\n // hardening, so this branch is redundant TODAY \u2014 kept as defence in depth\n // for the one thing it owns: this class's own error category, independent\n // of how the shared classifier evolves. Measured 2026-09-03: removing it\n // changes no test (equivalent mutant G90). Do not re-add the old claim\n // that classifyError only looks at the message \u2014 that stopped being true.\n if (err instanceof HttpError && (err.statusCode === 401 || err.statusCode === 403)) {\n this.lastErrorCategory = \"AUTH\";\n this.reportContact(\"auth-failed\");\n throw err;\n }\n this.lastErrorCategory = classifyError(err);\n throw err;\n }\n }\n\n /**\n * Tell the contact hook \u2014 a throwing hook is logged, never rethrown.\n *\n * @param outcome What the answer said about the API key\n */\n private reportContact(outcome: CloudContact): void {\n try {\n this.onContact?.(outcome);\n } catch (e) {\n this.log.debug(`Cloud contact hook failed: ${errMessage(e)}`);\n }\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA6E;AA+F7E,mBAWO;AAlGA,MAAM,6BAA6B,MAAM;AAAA;AAAA;AAAA;AAAA;AAAA,EAK9C,YACE,SACgB,eAChB;AACA,UAAM,OAAO;AAFG;AAGhB,SAAK,OAAO;AAAA,EACd;AAAA,EAJkB;AAKpB;AAOA,SAAS,kBAAkB,QAA0B;AACnD,SAAO,OAAO,WAAW,YAAY,eAAe,KAAK,MAAM;AACjE;AAmCO,SAAS,qBACd,SACgD;AAChD,QAAM,MAA8B,CAAC;AACrC,aAAW,CAAC,MAAM,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AACnD,QAAI,CAAC,aAAa,KAAK,IAAI,KAAK,UAAU,QAAW;AACnD;AAAA,IACF;AACA,QAAI,KAAK,YAAY,CAAC,IAAI,MAAM,QAAQ,KAAK,IAAI,MAAM,KAAK,GAAG,IAAI,OAAO,KAAK;AAAA,EACjF;AACA,MAAI,OAAO,KAAK,GAAG,EAAE,WAAW,GAAG;AACjC,WAAO;AAAA,EACT;AACA,QAAM,MAAM,CAAC,QAAoC;AAC/C,UAAM,IAAI,IAAI,GAAG;AACjB,QAAI,MAAM,QAAW;AACnB,aAAO;AAAA,IACT;AACA,UAAM,IAAI,OAAO,CAAC;AAClB,WAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAAA,EAClC;AACA,SAAO;AAAA,IACL,UAAU,IAAI,mBAAmB;AAAA,IACjC,cAAc,IAAI,uBAAuB;AAAA,IACzC,UAAU,IAAI,mBAAmB;AAAA,IACjC,aAAa,IAAI,qBAAqB;AAAA,IACtC,iBAAiB,IAAI,yBAAyB;AAAA,IAC9C,aAAa,IAAI,qBAAqB;AAAA,IACtC;AAAA,EACF;AACF;AAcA,MAAM,WAAW;
|
|
4
|
+
"sourcesContent": ["import { formatFallback, httpsRequest, HttpError, type HttpsRequestFn } from \"./http-client\";\n\n/**\n * Govee accepted the request but refused the command (payload-level `code` or\n * a per-capability `failure`). `deviceOffline` names the one refusal the\n * adapter acts on: the device is not talking to Govee right now \u2014 the command\n * is held for the device's next sign of life (issue #46, 2.39.0).\n */\nexport class CloudControlRejected extends Error {\n /**\n * @param message The rejection line (sku/device/instance, code, reason)\n * @param deviceOffline Whether Govee's reason says the device was offline\n */\n constructor(\n message: string,\n public readonly deviceOffline: boolean,\n ) {\n super(message);\n this.name = \"CloudControlRejected\";\n }\n}\n\n/**\n * Whether a rejection reason says the device was offline \u2014 a whole word, never a substring.\n *\n * @param reason Govee's `msg` / `errorMsg`\n */\nfunction reasonSaysOffline(reason: unknown): boolean {\n return typeof reason === \"string\" && /\\boffline\\b/i.test(reason);\n}\n\n/**\n * What Govee says about its own budget, read off one answer's headers. The\n * named fields follow the v1 docs (`X-RateLimit-*` = day, `API-RateLimit-*` =\n * minute); `raw` keeps every header whose name mentions the rate limit, so\n * a v2 rename still shows up in the report.\n */\nexport interface GoveeRateLimit {\n /** The endpoint whose answer carried the headers. */\n endpoint: string;\n /** When that answer arrived (ms epoch). */\n at: number;\n /** `X-RateLimit-Limit` \u2014 the day's ceiling, when Govee names one. */\n dayLimit?: number;\n /** `X-RateLimit-Remaining` \u2014 what is left of the day. */\n dayRemaining?: number;\n /** `X-RateLimit-Reset` \u2014 when the day's window resets (epoch seconds). */\n dayReset?: number;\n /** `API-RateLimit-Limit` \u2014 the minute's ceiling. */\n minuteLimit?: number;\n /** `API-RateLimit-Remaining` \u2014 what is left of the minute. */\n minuteRemaining?: number;\n /** `API-RateLimit-Reset` \u2014 when the minute's window resets (epoch seconds). */\n minuteReset?: number;\n /** Every header whose name mentions the rate limit, verbatim (lower-cased names). */\n raw: Record<string, string>;\n}\n\n/**\n * Pull the rate-limit headers out of an answer. Null when the answer carries\n * none \u2014 an unchanged `lastRateLimit` then says \"Govee said nothing new\".\n *\n * @param headers Response headers (any case)\n */\nexport function readRateLimitHeaders(\n headers: Record<string, string | string[] | undefined>,\n): Omit<GoveeRateLimit, \"endpoint\" | \"at\"> | null {\n const raw: Record<string, string> = {};\n for (const [name, value] of Object.entries(headers)) {\n if (!/ratelimit/i.test(name) || value === undefined) {\n continue;\n }\n raw[name.toLowerCase()] = Array.isArray(value) ? value.join(\",\") : String(value);\n }\n if (Object.keys(raw).length === 0) {\n return null;\n }\n const num = (key: string): number | undefined => {\n const v = raw[key];\n if (v === undefined) {\n return undefined;\n }\n const n = Number(v);\n return Number.isFinite(n) ? n : undefined;\n };\n return {\n dayLimit: num(\"x-ratelimit-limit\"),\n dayRemaining: num(\"x-ratelimit-remaining\"),\n dayReset: num(\"x-ratelimit-reset\"),\n minuteLimit: num(\"api-ratelimit-limit\"),\n minuteRemaining: num(\"api-ratelimit-remaining\"),\n minuteReset: num(\"api-ratelimit-reset\"),\n raw,\n };\n}\nimport {\n classifyError,\n errMessage,\n type CapabilityOption,\n type CloudDevice,\n type CloudDeviceListResponse,\n type CloudDeviceStateResponse,\n type CloudScene,\n type CloudScenesResponse,\n type CloudStateCapability,\n type ErrorCategory,\n} from \"./types\";\n\nconst BASE_URL = \"https://openapi.api.govee.com\";\n\n/**\n * What one Cloud call says \u2014 see `GoveeCloudClient.setContactHook`: `ok` (Govee\n * accepted it), `auth-failed` (401/403, the API key), `unreachable` (Govee did\n * not answer, or answered 5xx \u2014 issue #51).\n */\nexport type CloudContact = \"ok\" | \"auth-failed\" | \"unreachable\";\n\n/** TLS failures \u2014 no connection to Govee ever came about (wrong clock, intercepting proxy). */\nconst TLS_ERROR_CODES = new Set([\n \"CERT_HAS_EXPIRED\",\n \"CERT_NOT_YET_VALID\",\n \"DEPTH_ZERO_SELF_SIGNED_CERT\",\n \"SELF_SIGNED_CERT_IN_CHAIN\",\n \"UNABLE_TO_GET_ISSUER_CERT_LOCALLY\",\n \"UNABLE_TO_VERIFY_LEAF_SIGNATURE\",\n \"ERR_TLS_CERT_ALTNAME_INVALID\",\n]);\n\n/**\n * A failed call that never reached a working Govee server: no answer (network,\n * DNS, refused, reset, timeout, TLS) or a 5xx. An answer Govee gave \u2014 429,\n * 401/403, any other 4xx \u2014 is not an outage, nor is the adapter's own abort.\n *\n * @param err The error of the failed call\n * @param category What `classifyError` made of it\n */\nfunction isUnreachable(err: unknown, category: ErrorCategory): boolean {\n if (category === \"NETWORK\" || category === \"TIMEOUT\") {\n return true;\n }\n if (err instanceof HttpError) {\n return err.statusCode >= 500;\n }\n const code = (err as { code?: unknown } | null)?.code;\n return typeof code === \"string\" && TLS_ERROR_CODES.has(code);\n}\n\n/**\n * Monotonic counter for `requestId` so two calls in the same millisecond\n * don't collide. Govee uses requestId for idempotency on its side; concurrent\n * calls with identical requestIds can be coalesced or rejected as duplicates.\n * Module-level so all clients share the same sequence.\n */\nlet requestIdCounter = 0;\n\n/**\n * Build a unique `requestId` (`<prefix>_<ms>_<counter>`).\n *\n * @param prefix Short label identifying the call site (e.g. \"ctrl\", \"scenes\").\n */\nfunction nextRequestId(prefix: string): string {\n requestIdCounter = (requestIdCounter + 1) % 1_000_000;\n return `${prefix}_${Date.now()}_${requestIdCounter}`;\n}\n\n/**\n * Map a scenes-endpoint capability's options to CloudScene entries, dropping\n * malformed options (missing name or non-object value). Shared by getScenes\n * and getDiyScenes.\n *\n * @param opts Raw options array from a scenes-endpoint capability\n */\nfunction mapSceneOptions(opts: CapabilityOption[] | undefined): CloudScene[] {\n return (Array.isArray(opts) ? opts : [])\n .filter(\n // `typeof null === \"object\"` \u2014 the old object-only guard let null-valued\n // options through as phantom entries AND dropped the integer values that\n // snapshots use (CloudScene.value is `Record | number`). Accept object OR\n // number, reject null/undefined (L7).\n (o): o is { name: string; value: Record<string, unknown> | number } =>\n !!o &&\n typeof o.name === \"string\" &&\n o.value != null &&\n (typeof o.value === \"object\" || typeof o.value === \"number\"),\n )\n .map(o => ({ name: o.name, value: o.value }));\n}\n\n/**\n * Govee answers a device endpoint with HTTP 200 and a body-level `code` when it\n * refuses the call (`400 devices not exist`, `Invalid parameter type`); the\n * reason sits in `msg`. Such an answer is a failure, never an empty result.\n *\n * @param resp The parsed answer (null/undefined pass \u2014 the callers handle them)\n * @param what Start of the error message, naming the call and the device\n */\nfunction throwIfRejected(\n resp: { code?: unknown; msg?: unknown; message?: unknown } | null | undefined,\n what: string,\n): void {\n if (resp && typeof resp.code === \"number\" && resp.code !== 200 && resp.code !== 0) {\n // The device list names its reason `message`, the state/scene envelopes `msg`.\n const reason = [resp.msg, resp.message].find(r => typeof r === \"string\" && r);\n const msg = typeof reason === \"string\" ? ` \u2014 ${reason}` : \"\";\n throw new Error(`${what}: code=${resp.code}${msg}`);\n }\n}\n\n/**\n * Govee Cloud API v2 client.\n * Used for device list, capabilities, scenes, segments, and as control fallback.\n */\nexport class GoveeCloudClient {\n private readonly apiKey: string;\n private readonly log: ioBroker.Logger;\n private readonly httpsRequestImpl: HttpsRequestFn;\n private lastRateLimit: GoveeRateLimit | null = null;\n /**\n * True if a previous getDevices call returned an empty array \u2014 the first\n * empty result emits an info log so the user has a starting point for\n * \"where are my devices?\", repeats stay silent.\n */\n private warnedEmptyDeviceList = false;\n /**\n * Diagnostics hook \u2014 receives (deviceId, endpoint, body) for each\n * response. Optional; the adapter wires it to a DiagnosticsCollector\n * for the diagnostics report.\n */\n private onResponse:\n ((deviceId: string, endpoint: string, body: unknown, rateLimit?: GoveeRateLimit | null) => void) | null = null;\n\n /**\n * Last error category for getFailureReason() \u2014 set on every HTTP error in\n * the request path.\n */\n private lastErrorCategory: ErrorCategory | null = null;\n\n /**\n * Hook called after every Cloud call that says something about the\n * connection: `ok` for any accepted call, `auth-failed` for a 401/403,\n * `unreachable` for no answer or a 5xx ({@link isUnreachable}). A 429 or\n * another 4xx stays silent \u2014 Govee answered. One `unreachable` alone decides\n * nothing; the handler waits for a second one a minute later.\n */\n private onContact: ((outcome: CloudContact, reason?: string) => void) | null = null;\n\n /**\n * @param apiKey Govee API key\n * @param log ioBroker logger\n * @param httpsRequestImpl optional DI for tests \u2014 default is the real httpsRequest\n */\n constructor(apiKey: string, log: ioBroker.Logger, httpsRequestImpl: HttpsRequestFn = httpsRequest) {\n this.apiKey = apiKey;\n this.log = log;\n this.httpsRequestImpl = httpsRequestImpl;\n }\n\n /**\n * Short user-facing reason for \"Cloud not connected\", or null when the\n * client has not seen an error yet. Like the mqtt-client \u2014 `logDeviceSummary`\n * uses it so the adapter can log clear diagnostic text instead of\n * \"see earlier errors\".\n */\n getFailureReason(): string | null {\n switch (this.lastErrorCategory) {\n case \"AUTH\":\n return \"API key rejected \u2014 check Govee API key\";\n case \"RATE_LIMIT\":\n return \"rate-limited by Govee \u2014 will retry\";\n case \"NETWORK\":\n return \"cannot reach Govee servers \u2014 will retry\";\n case \"TIMEOUT\":\n return \"Cloud request timeout\";\n case \"UNKNOWN\":\n return \"Cloud request failed \u2014 see earlier log\";\n case null:\n default:\n return null;\n }\n }\n\n /**\n * Register a hook called after every successful Cloud API response.\n * Used to populate the DiagnosticsCollector ring buffer.\n *\n * @param cb Callback receiving (deviceId, endpoint, body)\n */\n setResponseHook(\n cb: ((deviceId: string, endpoint: string, body: unknown, rateLimit?: GoveeRateLimit | null) => void) | null,\n ): void {\n this.onResponse = cb;\n }\n\n /**\n * Register the hook that learns what each Cloud call said \u2014 see\n * {@link onContact}. A throwing hook never fails the request.\n *\n * @param cb Callback receiving `ok`, `auth-failed` or `unreachable` (with the error text)\n */\n setContactHook(cb: ((outcome: CloudContact, reason?: string) => void) | null): void {\n this.onContact = cb;\n }\n\n /**\n * The newest set of rate-limit headers Govee sent, or null before the first\n * answer that carried any. The diagnostics report shows it; that is the\n * measurement the daily numbers of the budget wait for (see CLOUD_LIMITS).\n */\n getLastRateLimit(): GoveeRateLimit | null {\n return this.lastRateLimit;\n }\n\n /** Fetch all devices with their capabilities */\n async getDevices(): Promise<CloudDevice[]> {\n const resp = await this.request<CloudDeviceListResponse>(\"GET\", \"/router/api/v1/user/devices\");\n // A rejection inside the envelope (HTTP 200, `code` \u2260 200) is a failed\n // list, never an empty account \u2014 it throws so the retry loop takes it.\n throwIfRejected(resp, \"Device list rejected\");\n // Defensive \u2014 API can drift. Guard for non-array to protect downstream iteration.\n const devices = Array.isArray(resp?.data) ? resp.data : [];\n // v2.9.1 \u2014 fire onResponse per-device so every device sees the canonical\n // capability data Govee Cloud has for it in its own apiHistory. Without\n // this, the user-devices endpoint never appeared in diag \u2014 even though\n // it's the authoritative source of truth for what Govee thinks each\n // device can do. Per-device is the right grain because callers want to\n // see \"what Cloud said about MY device\" without scanning a global list.\n if (this.onResponse) {\n for (const cd of devices) {\n if (cd && typeof cd.device === \"string\" && cd.device) {\n this.onResponse(cd.device, \"/router/api/v1/user/devices\", cd, this.lastRateLimit);\n }\n }\n }\n if (devices.length === 0 && !this.warnedEmptyDeviceList) {\n this.warnedEmptyDeviceList = true;\n // First empty response is worth surfacing \u2014 common cause is \"API key\n // belongs to a different Govee account than where the devices were\n // added\" or \"user hasn't enabled control-via-cloud per device\". Repeat\n // calls keep quiet so a temporarily flaky API doesn't spam.\n this.log.info(`Cloud: device list returned empty \u2014 check the API key matches the account that owns the devices`);\n } else if (devices.length > 0) {\n this.warnedEmptyDeviceList = false;\n }\n return devices;\n }\n\n /**\n * Fetch current state of a device\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getDeviceState(sku: string, device: string): Promise<CloudStateCapability[]> {\n const resp = await this.request<CloudDeviceStateResponse>(\"POST\", \"/router/api/v1/device/state\", {\n requestId: nextRequestId(\"state\"),\n payload: { sku, device },\n });\n if (resp === null) {\n // HTTP 200 without a body \u2014 the http client's word for \"nothing came\".\n this.onResponse?.(device, \"/router/api/v1/device/state\", resp, this.lastRateLimit);\n return [];\n }\n // A rejection and an answer of unknown shape THROW \u2014 both callers catch\n // per device and record the reason in the diagnostics report. Only a\n // payload without capabilities is a legitimate empty result. Folding all\n // of it into [] is how the wrong envelope stayed invisible from v0.1.0 to\n // 2.34.0: \"no value\" and \"wrong field\" rendered the same (issue #47).\n throwIfRejected(resp, `Device state rejected for ${sku}/${device}`);\n if (typeof resp.payload !== \"object\" || resp.payload === null) {\n throw new Error(`Device state answer for ${sku}/${device} carries no payload \u2014 has Govee changed the envelope?`);\n }\n // Recorded once the answer is accepted: a rejection reaches the report as\n // the caller's failure entry, not a second time as a \"successful\" answer.\n this.onResponse?.(device, \"/router/api/v1/device/state\", resp, this.lastRateLimit);\n // Govee wraps this answer in `payload`, like the scene endpoints below \u2014\n // `data` is the envelope of the device LIST (three captures: the\n // reporter's export, tukey42's H61A8, Govee's own docs).\n const caps = resp.payload.capabilities;\n return Array.isArray(caps) ? caps : [];\n }\n\n /**\n * Send a control command to a device\n *\n * @param sku Product model\n * @param device Device ID\n * @param capabilityType Full capability type string\n * @param instance Capability instance name\n * @param value Value to set\n */\n async controlDevice(\n sku: string,\n device: string,\n capabilityType: string,\n instance: string,\n value: unknown,\n ): Promise<void> {\n const reqBody = {\n requestId: nextRequestId(\"ctrl\"),\n payload: {\n sku,\n device,\n capability: {\n type: capabilityType,\n instance,\n value,\n },\n },\n };\n const resp = await this.request<{\n code?: number;\n msg?: string;\n message?: string;\n capability?: { state?: { status?: string; errorCode?: number; errorMsg?: string } };\n }>(\"POST\", \"/router/api/v1/device/control\", reqBody);\n this.onResponse?.(\n device,\n \"/router/api/v1/device/control\",\n { request: reqBody.payload.capability, response: resp },\n this.lastRateLimit,\n );\n // Govee returns 200 with a payload-level `code`/`msg` on logical failures\n // (capability not allowed, wrong value shape, device offline as seen by\n // Cloud). The field is `msg` \u2014 reading `message` dropped the only\n // actionable half of the line (\"Invalid parameter type\", issue #47) \u2014 and\n // the per-capability `state.status` can say failure while the envelope\n // still says 200.\n const capState = resp?.capability?.state;\n // Govee names the reason `msg`, not `message` \u2014 reading the wrong field left\n // the log line at a bare \"code=400\" and threw away the only actionable half\n // (issue #47). `message` stays as a fallback for the older shape.\n const reason = resp?.msg ?? resp?.message ?? capState?.errorMsg;\n if (resp && typeof resp.code === \"number\" && resp.code !== 200 && resp.code !== 0) {\n throw new CloudControlRejected(\n `Cloud control rejected for ${sku}/${device}/${instance}: code=${resp.code}${reason ? ` \u2014 ${reason}` : \"\"}`,\n reasonSaysOffline(reason),\n );\n }\n // A rejection can arrive with a 200 envelope while the per-capability state\n // carries the failure \u2014 without this the command was acked as done\n // (issue #47). Either marker is enough: the captured payload sets both, and\n // a lone errorCode must not slip through.\n if (capState?.status === \"failure\" || typeof capState?.errorCode === \"number\") {\n throw new CloudControlRejected(\n `Cloud control rejected for ${sku}/${device}/${instance}: code=${capState.errorCode ?? \"?\"}${reason ? ` \u2014 ${reason}` : \"\"}`,\n reasonSaysOffline(reason),\n );\n }\n }\n\n /**\n * Fetch dynamic scenes and snapshots for a device.\n * The scenes endpoint returns capabilities with options.\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getScenes(\n sku: string,\n device: string,\n ): Promise<{\n lightScenes: CloudScene[];\n diyScenes: CloudScene[];\n snapshots: CloudScene[];\n }> {\n const resp = await this.request<CloudScenesResponse>(\"POST\", \"/router/api/v1/device/scenes\", {\n requestId: nextRequestId(\"scenes\"),\n payload: { sku, device },\n });\n // A rejection is not \"this device has no scenes\": read as empty, it took\n // the snapshot list from the capability fallback and marked the scenes as\n // checked. It throws; the loader keeps what it had (issue #50 follow-up).\n throwIfRejected(resp, `Scenes rejected for ${sku}/${device}`);\n this.onResponse?.(device, \"/router/api/v1/device/scenes\", resp, this.lastRateLimit);\n\n const lightScenes: CloudScene[] = [];\n const diyScenes: CloudScene[] = [];\n const snapshots: CloudScene[] = [];\n\n const caps = Array.isArray(resp?.payload?.capabilities) ? resp.payload.capabilities : [];\n for (const cap of caps) {\n if (!cap || typeof cap.instance !== \"string\") {\n continue;\n }\n const opts = Array.isArray(cap.parameters?.options) ? cap.parameters.options : [];\n this.log.debug(`Scenes endpoint: instance=${cap.instance}, options=${opts.length}`);\n const mapped = mapSceneOptions(opts);\n\n if (cap.instance === \"lightScene\") {\n lightScenes.push(...mapped);\n } else if (cap.instance === \"diyScene\") {\n diyScenes.push(...mapped);\n } else if (cap.instance === \"snapshot\") {\n snapshots.push(...mapped);\n }\n }\n\n return { lightScenes, diyScenes, snapshots };\n }\n\n /**\n * Fetch DIY scenes for a device from the dedicated diy-scenes endpoint.\n *\n * @param sku Product model\n * @param device Device identifier\n */\n async getDiyScenes(sku: string, device: string): Promise<CloudScene[]> {\n const resp = await this.request<CloudScenesResponse>(\"POST\", \"/router/api/v1/device/diy-scenes\", {\n requestId: nextRequestId(\"diy\"),\n payload: { sku, device },\n });\n throwIfRejected(resp, `DIY scenes rejected for ${sku}/${device}`);\n this.onResponse?.(device, \"/router/api/v1/device/diy-scenes\", resp, this.lastRateLimit);\n\n const scenes: CloudScene[] = [];\n const caps = Array.isArray(resp?.payload?.capabilities) ? resp.payload.capabilities : [];\n for (const cap of caps) {\n if (!cap || typeof cap.instance !== \"string\") {\n continue;\n }\n const opts = Array.isArray(cap.parameters?.options) ? cap.parameters.options : [];\n this.log.debug(`DIY-Scenes endpoint: instance=${cap.instance}, options=${opts.length}`);\n scenes.push(...mapSceneOptions(opts));\n }\n\n return scenes;\n }\n\n /**\n * Make an HTTPS request to the Govee Cloud API\n *\n * @param method HTTP method (GET, POST)\n * @param path API endpoint path\n * @param body Optional request body\n */\n /**\n * Read Govee's rate-limit headers off one answer and keep the newest set.\n * The v1 docs name `X-RateLimit-*` (day) and `API-RateLimit-*` (minute);\n * every header whose name mentions the rate limit is kept verbatim as well,\n * so a renamed header on v2 still reaches the report.\n *\n * @param path The endpoint the answer belongs to\n * @param headers The answer's headers\n */\n private noteRateLimit(path: string, headers: Record<string, string | string[] | undefined>): void {\n const info = readRateLimitHeaders(headers);\n if (!info) {\n return;\n }\n const first = this.lastRateLimit === null;\n this.lastRateLimit = { ...info, endpoint: path, at: Date.now() };\n if (first) {\n this.log.debug(`Cloud API rate-limit headers (first seen): ${JSON.stringify(info.raw)}`);\n }\n }\n\n private async request<T>(method: string, path: string, body?: unknown): Promise<T | null> {\n this.log.debug(`Cloud API: ${method} ${path} auth=apiKey`);\n try {\n const result = await this.httpsRequestImpl<T>({\n method: method as \"GET\" | \"POST\",\n url: new URL(path, BASE_URL).toString(),\n headers: { \"Govee-API-Key\": this.apiKey },\n body,\n });\n if (result.fallback) {\n this.log.debug(`Cloud API: ${method} ${path}: ${formatFallback(result)}`);\n }\n this.noteRateLimit(path, result.headers ?? {});\n // Reset the failure category on success \u2014 getFailureReason() then returns\n // null until the next error.\n this.lastErrorCategory = null;\n this.reportContact(\"ok\");\n return result.value;\n } catch (err) {\n // Classify 429 explicitly by status code \u2014 classifyError only looks at\n // err.message, and HttpError(\"Too many requests\", 429, \u2026) has no \"429\"\n // or \"Rate limit\" marker in the text. Without this branch 429 lands as\n // UNKNOWN and getFailureReason() returns the wrong ready hint.\n if (err instanceof HttpError) {\n // A rejection carries headers too \u2014 a 429 is the one answer whose\n // rate-limit headers matter most.\n this.noteRateLimit(path, err.headers);\n }\n if (err instanceof HttpError && err.statusCode === 429) {\n this.lastErrorCategory = \"RATE_LIMIT\";\n const retryAfter = String(err.headers[\"retry-after\"] ?? \"unknown\");\n throw new HttpError(`Rate limited \u2014 retry after ${retryAfter}s`, 429, err.headers);\n }\n // Classify 401/403 explicitly by status code (L26) \u2014 a 401/403 whose body\n // doesn't contain \"auth\"/\"unauthorized\" (e.g. \"Access denied\") must still\n // produce the actionable \"API key rejected \u2014 check Govee API key\" instead\n // of \"Cloud request failed\".\n //\n // classifyError() reads `err.statusCode` itself since the text-marker\n // hardening, so this branch is redundant TODAY \u2014 kept as defence in depth\n // for the one thing it owns: this class's own error category, independent\n // of how the shared classifier evolves. Measured 2026-09-03: removing it\n // changes no test (equivalent mutant G90). Do not re-add the old claim\n // that classifyError only looks at the message \u2014 that stopped being true.\n if (err instanceof HttpError && (err.statusCode === 401 || err.statusCode === 403)) {\n this.lastErrorCategory = \"AUTH\";\n this.reportContact(\"auth-failed\");\n throw err;\n }\n this.lastErrorCategory = classifyError(err);\n if (isUnreachable(err, this.lastErrorCategory)) {\n this.reportContact(\"unreachable\", errMessage(err));\n }\n throw err;\n }\n }\n\n /**\n * Tell the contact hook \u2014 a throwing hook is logged, never rethrown.\n *\n * @param outcome What the call said\n * @param reason The error text of an `unreachable` call\n */\n private reportContact(outcome: CloudContact, reason?: string): void {\n try {\n this.onContact?.(outcome, reason);\n } catch (e) {\n this.log.debug(`Cloud contact hook failed: ${errMessage(e)}`);\n }\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|