iobroker.govee-smart 2.40.0 → 3.0.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.
Files changed (55) hide show
  1. package/README.md +15 -17
  2. package/admin/i18n/de.json +0 -2
  3. package/admin/i18n/en.json +0 -2
  4. package/admin/i18n/es.json +0 -2
  5. package/admin/i18n/fr.json +0 -2
  6. package/admin/i18n/it.json +0 -2
  7. package/admin/i18n/nl.json +0 -2
  8. package/admin/i18n/pl.json +0 -2
  9. package/admin/i18n/pt.json +0 -2
  10. package/admin/i18n/ru.json +0 -2
  11. package/admin/i18n/uk.json +0 -2
  12. package/admin/i18n/zh-cn.json +0 -2
  13. package/admin/jsonConfig.json +0 -10
  14. package/build/lib/appliance-frames.js +10 -1
  15. package/build/lib/appliance-frames.js.map +2 -2
  16. package/build/lib/capability-mapper.js +14 -4
  17. package/build/lib/capability-mapper.js.map +2 -2
  18. package/build/lib/cloud-outage.js +85 -0
  19. package/build/lib/cloud-outage.js.map +7 -0
  20. package/build/lib/device-id-migration.js +136 -0
  21. package/build/lib/device-id-migration.js.map +7 -0
  22. package/build/lib/device-id.js +151 -0
  23. package/build/lib/device-id.js.map +7 -0
  24. package/build/lib/device-key.js +5 -0
  25. package/build/lib/device-key.js.map +2 -2
  26. package/build/lib/device-manager.js +16 -2
  27. package/build/lib/device-manager.js.map +2 -2
  28. package/build/lib/device-move.js +184 -0
  29. package/build/lib/device-move.js.map +7 -0
  30. package/build/lib/enum-carry.js +65 -0
  31. package/build/lib/enum-carry.js.map +7 -0
  32. package/build/lib/govee-cloud-client.js +33 -9
  33. package/build/lib/govee-cloud-client.js.map +2 -2
  34. package/build/lib/govee-lan-client.js +34 -26
  35. package/build/lib/govee-lan-client.js.map +2 -2
  36. package/build/lib/govee-openapi-mqtt-client.js +1 -1
  37. package/build/lib/govee-openapi-mqtt-client.js.map +2 -2
  38. package/build/lib/handlers/cloud-retry-handler.js +27 -8
  39. package/build/lib/handlers/cloud-retry-handler.js.map +2 -2
  40. package/build/lib/handlers/connection-state.js +5 -4
  41. package/build/lib/handlers/connection-state.js.map +2 -2
  42. package/build/lib/local-snapshots.js +39 -3
  43. package/build/lib/local-snapshots.js.map +2 -2
  44. package/build/lib/native-key-migration.js +44 -8
  45. package/build/lib/native-key-migration.js.map +2 -2
  46. package/build/lib/sku-cache.js +40 -4
  47. package/build/lib/sku-cache.js.map +2 -2
  48. package/build/lib/state-manager.js +86 -39
  49. package/build/lib/state-manager.js.map +2 -2
  50. package/build/lib/timing-constants.js +3 -0
  51. package/build/lib/timing-constants.js.map +2 -2
  52. package/build/main.js +28 -11
  53. package/build/main.js.map +3 -3
  54. package/io-package.json +70 -29
  55. 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 only happens if you have enabled error reporting in the ioBroker diagnostics (**System settings → Diagnostics and error reporting**). Only an anonymous installation ID is transmitted — no name, e-mail address or IP address.
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
 
@@ -55,7 +55,7 @@ For details and how to disable it, see the [Sentry plugin documentation](https:/
55
55
 
56
56
  - Node.js >= 22
57
57
  - ioBroker js-controller >= 7.2.2
58
- - ioBroker Admin >= 8.0.11
58
+ - ioBroker Admin >= 8.0.14
59
59
  - A Govee account and at least one Govee WiFi device. LAN control needs a light with LAN mode enabled in the Govee Home app — see Govee's [LAN-supported device list](https://app-h5.govee.com/user-manual/wlan-guide).
60
60
 
61
61
  > The adapter CANNOT be installed via GitHub: The adapter must be installed via the ioBroker repository (stable or latest).
@@ -95,6 +95,19 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
95
95
  ### **WORK IN PROGRESS**
96
96
  -->
97
97
 
98
+ ### 3.0.0 (2026-09-26)
99
+
100
+ - Changed: every device gets a new object ID once — model and last four characters with a hyphen, e.g. `devices.h61be-525f`; scripts and visualizations need the new IDs
101
+ - Changed: the move carries values, recording settings, rooms, functions and aliases along, and recorded history continues in its old series
102
+ - Fixed: two devices of one model whose IDs end alike now get a tree each and each receives its own commands — until now they shared one
103
+ - New: the H1741 battery table lamp reports its charge level in `sensor.battery`; Govee reports a fully charged battery as about 80 percent
104
+ - Fixed: fans and heaters with a numeric level (H7102, H7130) store it as a number, and the H7121 no longer puts a warning in the log at every refresh
105
+
106
+ ### 2.41.0 (2026-09-26)
107
+
108
+ - 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
109
+ - 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
110
+
98
111
  ### 2.40.0 (2026-09-25)
99
112
 
100
113
  - New: Optional additional scan addresses — lights in another subnet or behind a router that blocks multicast are found by asking them directly
@@ -129,21 +142,6 @@ This adapter's MQTT authentication and BLE-over-LAN (ptReal) protocol implementa
129
142
  - Fixed: The diagnostics report no longer contains your Wi-Fi network name, the Govee app's device number or a group's id — they appeared in clear in every exported file
130
143
  - Improved: The diagnostics report keeps Govee's complete account-list entry and shows commands waiting for an offline device and when the adapter started
131
144
 
132
- ### 2.39.0 (2026-09-22)
133
-
134
- - New: A device that has gone quiet is asked for its status over the Govee account connection — a bulb or purifier that works but showed as unreachable now stays reachable
135
- - Improved: The cloud budget follows Govee's per-device limits — a command for one light no longer waits for another light's calls or for library downloads
136
- - New: A command Govee refused because the device was offline is delivered once the device reports back (within five minutes), instead of being lost
137
- - Fixed: Scenes and libraries that arrived after a busy start now reach the scene dropdown and the cache — they used to stay at `---` and were fetched again on every start
138
- - Fixed: A scene list that shrank no longer leaves withdrawn scenes in the dropdown
139
- - Fixed: The diagnostics report no longer lists a reachability refresh for appliances, which never get one
140
- - 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
- - New: H1771 Table Lamp reported working by a user
142
-
143
- ### 2.38.3 (2026-09-17) — stable
144
-
145
- - Changed: Internal cleanup. No user-facing changes.
146
-
147
145
  [Older changelogs can be found there](CHANGELOG_OLD.md)
148
146
 
149
147
  ## Support
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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",
@@ -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": "刷新云数据",
@@ -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",
@@ -80,8 +80,17 @@ const purifierH7127 = (frames, device) => {
80
80
  }
81
81
  return out;
82
82
  };
83
+ const lampH1741 = (frames) => {
84
+ const frame = frames.find((f) => f.fn === 66);
85
+ if (!frame) {
86
+ return [];
87
+ }
88
+ const level = frame.sub & 127;
89
+ return level <= 100 ? [{ type: import_govee_constants.GOVEE_CAP_TYPE.PROPERTY, instance: "battery", state: { value: level } }] : [];
90
+ };
83
91
  const DECODERS = {
84
- H7127: purifierH7127
92
+ H7127: purifierH7127,
93
+ H1741: lampH1741
85
94
  };
86
95
  function decodeApplianceFrames(device, opCommand) {
87
96
  const decoder = DECODERS[device.sku];
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/appliance-frames.ts"],
4
- "sourcesContent": ["import { GOVEE_CAP_TYPE } from \"./govee-constants\";\nimport type { CapabilityOption, CloudStateCapability, GoveeDevice } from \"./types\";\n\n/**\n * A 20-byte status frame from an appliance's own status push: `aa <fn> <sub>\n * <payload\u2026> <xor>` \u2014 Govee's BLE notification format, relayed verbatim in\n * the account broker's `op.command`. The XOR over bytes 0\u201318 sits in byte 19;\n * measured on 794 of 794 `aa` frames of two H7127 exports (issue #47).\n */\ninterface Frame {\n fn: number;\n sub: number;\n bytes: Buffer;\n}\n\nfunction parseFrame(raw: unknown): Frame | null {\n if (typeof raw !== \"string\" || raw.length === 0) {\n return null;\n }\n const bytes = Buffer.from(raw, \"base64\");\n if (bytes.length !== 20 || bytes[0] !== 0xaa) {\n return null;\n }\n let xor = 0;\n for (let i = 0; i < 19; i++) {\n xor ^= bytes[i];\n }\n if (xor !== bytes[19]) {\n return null;\n }\n return { fn: bytes[1], sub: bytes[2], bytes };\n}\n\ntype FrameDecoder = (frames: Frame[], device: GoveeDevice) => CloudStateCapability[];\n\n/**\n * The `workMode` STRUCT capability the device declared, if any.\n *\n * @param device The device whose declared capabilities are searched\n */\nfunction declaredWorkMode(device: GoveeDevice): { modes: CapabilityOption[]; levels: CapabilityOption[] } | null {\n const cap = device.capabilities.find(c => c.type === GOVEE_CAP_TYPE.WORK_MODE && c.instance === \"workMode\");\n const fields = cap?.parameters?.fields ?? [];\n const modes = fields.find(f => f && f.fieldName === \"workMode\")?.options;\n const levels = fields.find(f => f && f.fieldName === \"modeValue\")?.options;\n return Array.isArray(modes) && Array.isArray(levels) ? { modes, levels } : null;\n}\n\n/**\n * H7127 air purifier (issue #47; spec \u00A711). Measured frames:\n * aa 05 00 <mode> work mode (declared option value)\n * aa 05 01 <level> last manual level (declared gearMode option value)\n * aa 19 00 ff ff <aq> 00 <filter> filter life in % (byte 7 \u2014 two values against\n * two references); byte 5 is probably air quality\n * but was measured once and never seen changing,\n * so it is NOT decoded \u2014 the cloud seed carries it.\n * The level is reported only for a mode that declares levels \u2014 the same shape\n * the cloud state read returns (`{workMode: 2, modeValue: 0}` for Custom).\n *\n * @param frames The checksum-valid frames of one status packet\n * @param device The device the packet belongs to (declared capabilities)\n */\nconst purifierH7127: FrameDecoder = (frames, device) => {\n const out: CloudStateCapability[] = [];\n const declared = declaredWorkMode(device);\n const modeFrame = frames.find(f => f.fn === 0x05 && f.sub === 0x00);\n const levelFrame = frames.find(f => f.fn === 0x05 && f.sub === 0x01);\n if (declared && modeFrame) {\n const mode = modeFrame.bytes[3];\n const modeOpt = declared.modes.find(o => o && o.value === mode);\n if (modeOpt) {\n const group = declared.levels.find(o => o && o.name === modeOpt.name && Array.isArray(o.options));\n let modeValue = 0;\n if (group && levelFrame) {\n const level = levelFrame.bytes[3];\n if (group.options!.some(o => o && o.value === level)) {\n modeValue = level;\n }\n }\n out.push({\n type: GOVEE_CAP_TYPE.WORK_MODE,\n instance: \"workMode\",\n state: { value: { workMode: mode, modeValue } },\n });\n }\n }\n const sensors = frames.find(f => f.fn === 0x19 && f.sub === 0x00);\n if (sensors) {\n const filter = sensors.bytes[7];\n if (filter <= 100 && device.capabilities.some(c => c.instance === \"filterLifeTime\")) {\n out.push({ type: GOVEE_CAP_TYPE.PROPERTY, instance: \"filterLifeTime\", state: { value: filter } });\n }\n }\n return out;\n};\n\n/** One decoder per measured device family. A SKU not listed here gets nothing from its push. */\nconst DECODERS: Readonly<Record<string, FrameDecoder>> = {\n H7127: purifierH7127,\n};\n\n/**\n * Decode an appliance's own status push into the capability shape the cloud\n * pipeline already understands (`applyCloudCapabilities`). Local first: the\n * device says what it does; the cloud read is only the seed at start.\n *\n * @param device The device the packet belongs to\n * @param opCommand `op.command` of the status packet (base64 frames), unchecked\n */\nexport function decodeApplianceFrames(device: GoveeDevice, opCommand: unknown): CloudStateCapability[] {\n const decoder = DECODERS[device.sku];\n if (!decoder || !Array.isArray(opCommand)) {\n return [];\n }\n const frames = opCommand.map(parseFrame).filter((f): f is Frame => f !== null);\n return frames.length > 0 ? decoder(frames, device) : [];\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { GOVEE_CAP_TYPE } from \"./govee-constants\";\nimport type { CapabilityOption, CloudStateCapability, GoveeDevice } from \"./types\";\n\n/**\n * A 20-byte status frame from an appliance's own status push: `aa <fn> <sub>\n * <payload\u2026> <xor>` \u2014 Govee's BLE notification format, relayed verbatim in\n * the account broker's `op.command`. The XOR over bytes 0\u201318 sits in byte 19;\n * measured on 794 of 794 `aa` frames of two H7127 exports (issue #47).\n */\ninterface Frame {\n fn: number;\n sub: number;\n bytes: Buffer;\n}\n\nfunction parseFrame(raw: unknown): Frame | null {\n if (typeof raw !== \"string\" || raw.length === 0) {\n return null;\n }\n const bytes = Buffer.from(raw, \"base64\");\n if (bytes.length !== 20 || bytes[0] !== 0xaa) {\n return null;\n }\n let xor = 0;\n for (let i = 0; i < 19; i++) {\n xor ^= bytes[i];\n }\n if (xor !== bytes[19]) {\n return null;\n }\n return { fn: bytes[1], sub: bytes[2], bytes };\n}\n\ntype FrameDecoder = (frames: Frame[], device: GoveeDevice) => CloudStateCapability[];\n\n/**\n * The `workMode` STRUCT capability the device declared, if any.\n *\n * @param device The device whose declared capabilities are searched\n */\nfunction declaredWorkMode(device: GoveeDevice): { modes: CapabilityOption[]; levels: CapabilityOption[] } | null {\n const cap = device.capabilities.find(c => c.type === GOVEE_CAP_TYPE.WORK_MODE && c.instance === \"workMode\");\n const fields = cap?.parameters?.fields ?? [];\n const modes = fields.find(f => f && f.fieldName === \"workMode\")?.options;\n const levels = fields.find(f => f && f.fieldName === \"modeValue\")?.options;\n return Array.isArray(modes) && Array.isArray(levels) ? { modes, levels } : null;\n}\n\n/**\n * H7127 air purifier (issue #47; spec \u00A711). Measured frames:\n * aa 05 00 <mode> work mode (declared option value)\n * aa 05 01 <level> last manual level (declared gearMode option value)\n * aa 19 00 ff ff <aq> 00 <filter> filter life in % (byte 7 \u2014 two values against\n * two references); byte 5 is probably air quality\n * but was measured once and never seen changing,\n * so it is NOT decoded \u2014 the cloud seed carries it.\n * The level is reported only for a mode that declares levels \u2014 the same shape\n * the cloud state read returns (`{workMode: 2, modeValue: 0}` for Custom).\n *\n * @param frames The checksum-valid frames of one status packet\n * @param device The device the packet belongs to (declared capabilities)\n */\nconst purifierH7127: FrameDecoder = (frames, device) => {\n const out: CloudStateCapability[] = [];\n const declared = declaredWorkMode(device);\n const modeFrame = frames.find(f => f.fn === 0x05 && f.sub === 0x00);\n const levelFrame = frames.find(f => f.fn === 0x05 && f.sub === 0x01);\n if (declared && modeFrame) {\n const mode = modeFrame.bytes[3];\n const modeOpt = declared.modes.find(o => o && o.value === mode);\n if (modeOpt) {\n const group = declared.levels.find(o => o && o.name === modeOpt.name && Array.isArray(o.options));\n let modeValue = 0;\n if (group && levelFrame) {\n const level = levelFrame.bytes[3];\n if (group.options!.some(o => o && o.value === level)) {\n modeValue = level;\n }\n }\n out.push({\n type: GOVEE_CAP_TYPE.WORK_MODE,\n instance: \"workMode\",\n state: { value: { workMode: mode, modeValue } },\n });\n }\n }\n const sensors = frames.find(f => f.fn === 0x19 && f.sub === 0x00);\n if (sensors) {\n const filter = sensors.bytes[7];\n if (filter <= 100 && device.capabilities.some(c => c.instance === \"filterLifeTime\")) {\n out.push({ type: GOVEE_CAP_TYPE.PROPERTY, instance: \"filterLifeTime\", state: { value: filter } });\n }\n }\n return out;\n};\n\n/**\n * H1741 battery table lamp (issue #50). Measured frame:\n * aa 42 <b> battery level in % (bits 0\u20136)\n * Four reports against the app's own battery icon: 0x30 = two of three bars (2026-09-23), 0x50 right\n * after a full charge, 0x20 one bar shortly before the battery saver (2026-09-25/26). Govee reports a\n * full battery as 80\u201384, not 100 \u2014 the value is shown as it comes. Bit 7 was set only in the minutes\n * before the lamp was unplugged (0xd4); that it means \"charging\" is not measured, so it is masked and\n * not decoded. `aa 42` exists on mains devices too (H1310 `aa4200`, H70B1 `aa4202`) \u2014 hence per SKU.\n *\n * @param frames The checksum-valid frames of one status packet\n * @returns the battery reading, or nothing\n */\nconst lampH1741: FrameDecoder = frames => {\n const frame = frames.find(f => f.fn === 0x42);\n if (!frame) {\n return [];\n }\n const level = frame.sub & 0x7f;\n return level <= 100 ? [{ type: GOVEE_CAP_TYPE.PROPERTY, instance: \"battery\", state: { value: level } }] : [];\n};\n\n/** One decoder per measured device family. A SKU not listed here gets nothing from its push. */\nconst DECODERS: Readonly<Record<string, FrameDecoder>> = {\n H7127: purifierH7127,\n H1741: lampH1741,\n};\n\n/**\n * Decode an appliance's own status push into the capability shape the cloud\n * pipeline already understands (`applyCloudCapabilities`). Local first: the\n * device says what it does; the cloud read is only the seed at start.\n *\n * @param device The device the packet belongs to\n * @param opCommand `op.command` of the status packet (base64 frames), unchecked\n */\nexport function decodeApplianceFrames(device: GoveeDevice, opCommand: unknown): CloudStateCapability[] {\n const decoder = DECODERS[device.sku];\n if (!decoder || !Array.isArray(opCommand)) {\n return [];\n }\n const frames = opCommand.map(parseFrame).filter((f): f is Frame => f !== null);\n return frames.length > 0 ? decoder(frames, device) : [];\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1099,7 +1099,7 @@ function planCloudCapabilityWrites(caps, hasLanIp, lanStateIds, declared) {
1099
1099
  return writes;
1100
1100
  }
1101
1101
  function mapCloudStateValues(cap, declared) {
1102
- var _a, _b, _c, _d, _e, _f;
1102
+ var _a, _b, _c, _d, _e;
1103
1103
  const primary = mapCloudStateValue(cap);
1104
1104
  if (!primary) {
1105
1105
  return [];
@@ -1120,9 +1120,19 @@ function mapCloudStateValues(cap, declared) {
1120
1120
  if (level === null) {
1121
1121
  return [primary];
1122
1122
  }
1123
- const levelOptions = (_f = (_e = (_d = (_c = declared == null ? void 0 : declared.find((c) => c.type === cap.type && c.instance === cap.instance)) == null ? void 0 : _c.parameters) == null ? void 0 : _d.fields) == null ? void 0 : _e.find((f) => f && f.fieldName === "modeValue")) == null ? void 0 : _f.options;
1124
- const asNumber = levelOptions !== void 0 && classifyModeLevels(levelOptions).kind === "number";
1125
- return [primary, { stateId: "mode_value", value: asNumber ? level : safeStringify(level) }];
1123
+ const kind = declared ? modeLevelKind(
1124
+ (_e = (_d = (_c = declared.find((c) => c.type === cap.type && c.instance === cap.instance)) == null ? void 0 : _c.parameters) == null ? void 0 : _d.fields) == null ? void 0 : _e.find((f) => f && f.fieldName === "modeValue")
1125
+ ) : "dropdown";
1126
+ if (kind === "none") {
1127
+ return [primary];
1128
+ }
1129
+ return [primary, { stateId: "mode_value", value: kind === "number" ? level : safeStringify(level) }];
1130
+ }
1131
+ function modeLevelKind(field) {
1132
+ if ((field == null ? void 0 : field.options) && field.options.length > 0) {
1133
+ return classifyModeLevels(field.options).kind;
1134
+ }
1135
+ return (field == null ? void 0 : field.range) ? "number" : "none";
1126
1136
  }
1127
1137
  const SCENE_DROPDOWN_RULES = [
1128
1138
  { id: "light_scene", cap: "lightScene", nameKey: "lightScene", channel: "scenes", source: (d) => d.scenes },