iobroker.homewizard 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,8 +45,6 @@ Real-time energy monitoring from [HomeWizard](https://www.homewizard.com) Energy
45
45
  | kWh Meter 3-Phase | HWE-KWH3 / SDM630 | Yes | Yes (as controller) |
46
46
  | Plug-In Battery | HWE-BAT | Yes | Controlled via P1/kWh |
47
47
 
48
- > **Note:** Energy Socket (HWE-SKT) and Watermeter (HWE-WTR) only support API v1 and are not yet supported. Support will be added when HomeWizard releases API v2 for these devices.
49
-
50
48
  ---
51
49
 
52
50
  ## Configuration
@@ -100,7 +98,9 @@ homewizard.0.
100
98
  ├── measurement/ — Measurement data
101
99
  │ ├── power_w — Total power (number, W)
102
100
  │ ├── power_l1_w .. l3_w — Power per phase (number, W)
101
+ │ ├── voltage_v — Voltage single-phase (number, V)
103
102
  │ ├── voltage_l1_v .. l3_v — Voltage per phase (number, V)
103
+ │ ├── current_a — Current single-phase (number, A)
104
104
  │ ├── current_l1_a .. l3_a — Current per phase (number, A)
105
105
  │ ├── frequency_hz — Grid frequency (number, Hz)
106
106
  │ ├── energy_import_kwh — Total import (number, kWh)
@@ -108,6 +108,13 @@ homewizard.0.
108
108
  │ ├── energy_export_kwh — Total export (number, kWh)
109
109
  │ ├── energy_export_t1..t4_kwh — Export per tariff (number, kWh)
110
110
  │ ├── tariff — Active tariff (number)
111
+ │ ├── state_of_charge_pct — Battery charge level (number, %)
112
+ │ ├── cycles — Battery charge cycles (number)
113
+ │ ├── average_power_15m_w — 15-min average power (number, W, Belgium)
114
+ │ ├── monthly_power_peak_w — Monthly power peak (number, W, Belgium)
115
+ │ ├── monthly_power_peak_timestamp — Monthly peak timestamp (string)
116
+ │ ├── meter_model — Meter model identifier (string)
117
+ │ ├── timestamp — Measurement timestamp (string)
111
118
  │ ├── quality/ — Power quality counters
112
119
  │ │ ├── voltage_sag_l1..l3_count
113
120
  │ │ ├── voltage_swell_l1..l3_count
@@ -135,7 +142,7 @@ homewizard.0.
135
142
  └── identify — Blink LED (button)
136
143
  ```
137
144
 
138
- > States are created dynamically based on what the device reports. Not all devices have all states.
145
+ > States are created dynamically based on what the device reports. Not all devices have all states. kWh meters additionally provide apparent/reactive current, apparent/reactive power, and power factor states.
139
146
 
140
147
  ---
141
148
 
@@ -158,6 +165,17 @@ homewizard.0.
158
165
 
159
166
  ## Changelog
160
167
 
168
+ ### 0.6.3 (2026-04-18)
169
+ - Harden WebSocket and REST input handling against unexpected API responses
170
+ - Stop endless reconnect when the device token is invalid (fires once after 3 failed auth attempts)
171
+ - Avoid creating an empty `external/` channel when a device reports no external meters
172
+
173
+ ### 0.6.2 (2026-04-13)
174
+ - Fix hanging promise when response stream errors mid-transfer (`res.on("error")`)
175
+ - Fix onUnload: wrap in try/finally so callback always fires (prevents adapter hang on shutdown)
176
+ - Optimize state creation hot path: use `setObjectNotExistsAsync` instead of `extendObjectAsync` (~50 fewer object writes per second per device)
177
+ - Remove unnecessary `removeDeviceFromObject` wrapper (DRY)
178
+
161
179
  ### 0.6.1 (2026-04-12)
162
180
  - Code cleanup: extract testable connection-utils module (classifyError, createDeviceConnection)
163
181
  - Add 20 unit tests for error classification, connection factory, and unstable threshold
@@ -180,12 +198,6 @@ homewizard.0.
180
198
  ### 0.4.2 (2026-04-05)
181
199
  - Consistent donation labels and about text across all adapters
182
200
 
183
- ### 0.4.1 (2026-04-05)
184
- - Move measurement data into `measurement/` channel for cleaner object tree
185
-
186
- ### 0.4.0 (2026-04-05)
187
- - Add online/offline status icon for devices (`statusStates`)
188
-
189
201
  Older entries have been moved to [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
190
202
 
191
203
  ---
@@ -0,0 +1,59 @@
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 coerce_exports = {};
20
+ __export(coerce_exports, {
21
+ coerceBoolean: () => coerceBoolean,
22
+ coerceFiniteNumber: () => coerceFiniteNumber,
23
+ coerceString: () => coerceString,
24
+ isPlainObject: () => isPlainObject
25
+ });
26
+ module.exports = __toCommonJS(coerce_exports);
27
+ function coerceFiniteNumber(value) {
28
+ if (typeof value === "number") {
29
+ return Number.isFinite(value) ? value : null;
30
+ }
31
+ if (typeof value === "string" && value.length > 0) {
32
+ const n = Number(value);
33
+ return Number.isFinite(n) ? n : null;
34
+ }
35
+ return null;
36
+ }
37
+ function coerceString(value) {
38
+ if (typeof value === "string" && value.length > 0) {
39
+ return value;
40
+ }
41
+ return null;
42
+ }
43
+ function coerceBoolean(value) {
44
+ if (typeof value === "boolean") {
45
+ return value;
46
+ }
47
+ return null;
48
+ }
49
+ function isPlainObject(value) {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+ // Annotate the CommonJS export names for ESM import in node:
53
+ 0 && (module.exports = {
54
+ coerceBoolean,
55
+ coerceFiniteNumber,
56
+ coerceString,
57
+ isPlainObject
58
+ });
59
+ //# sourceMappingURL=coerce.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/lib/coerce.ts"],
4
+ "sourcesContent": ["/**\n * Boundary coercion helpers for external API data (REST + WebSocket).\n * HomeWizard API v2 is well-documented but field types still drift in practice\n * (firmware bugs, future additions, null values). These helpers guard against\n * NaN/Infinity/non-string values reaching ioBroker states.\n */\n\n/**\n * Coerce to a finite number or null.\n * Accepts numbers directly; parses numeric strings; rejects NaN/Infinity/other.\n *\n * @param value Unknown external value\n */\nexport function coerceFiniteNumber(value: unknown): number | null {\n if (typeof value === \"number\") {\n return Number.isFinite(value) ? value : null;\n }\n if (typeof value === \"string\" && value.length > 0) {\n const n = Number(value);\n return Number.isFinite(n) ? n : null;\n }\n return null;\n}\n\n/**\n * Coerce to a non-empty string, or null.\n *\n * @param value Unknown external value\n */\nexport function coerceString(value: unknown): string | null {\n if (typeof value === \"string\" && value.length > 0) {\n return value;\n }\n return null;\n}\n\n/**\n * Coerce to a boolean (only `true`/`false` accepted \u2014 no truthy/falsy JS rules).\n *\n * @param value Unknown external value\n */\nexport function coerceBoolean(value: unknown): boolean | null {\n if (typeof value === \"boolean\") {\n return value;\n }\n return null;\n}\n\n/**\n * Guard for plain objects (not arrays, not null).\n *\n * @param value Unknown external value\n */\nexport function isPlainObject(\n value: unknown,\n): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n"],
5
+ "mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAaO,SAAS,mBAAmB,OAA+B;AAChE,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;AAAA,EAC1C;AACA,MAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,UAAM,IAAI,OAAO,KAAK;AACtB,WAAO,OAAO,SAAS,CAAC,IAAI,IAAI;AAAA,EAClC;AACA,SAAO;AACT;AAOO,SAAS,aAAa,OAA+B;AAC1D,MAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAOO,SAAS,cAAc,OAAgC;AAC5D,MAAI,OAAO,UAAU,WAAW;AAC9B,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAOO,SAAS,cACd,OACkC;AAClC,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;",
6
+ "names": []
7
+ }
@@ -121,6 +121,7 @@ class HomeWizardClient {
121
121
  },
122
122
  (res) => {
123
123
  const chunks = [];
124
+ res.on("error", reject);
124
125
  res.on("data", (chunk) => chunks.push(chunk));
125
126
  res.on("end", () => {
126
127
  var _a;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/lib/homewizard-client.ts"],
4
- "sourcesContent": ["import * as https from \"node:https\";\nimport { HW_AGENT } from \"./cacert\";\nimport type {\n BatteryControl,\n DeviceInfo,\n Measurement,\n PairingResponse,\n SystemInfo,\n} from \"./types\";\n\n/** HTTPS client for HomeWizard API v2 */\nexport class HomeWizardClient {\n private readonly ip: string;\n private readonly token: string;\n\n /**\n * @param ip Device IP address\n * @param token Bearer token (empty string for pairing requests)\n */\n constructor(ip: string, token: string = \"\") {\n this.ip = ip;\n this.token = token;\n }\n\n /** Get device info (GET /api) */\n async getDeviceInfo(): Promise<DeviceInfo> {\n return this.request<DeviceInfo>(\"GET\", \"/api\");\n }\n\n /** Request pairing token (POST /api/user) \u2014 403 until button pressed */\n async requestPairing(): Promise<PairingResponse> {\n return this.request<PairingResponse>(\"POST\", \"/api/user\", {\n name: \"local/iobroker\",\n });\n }\n\n /** Get current measurement (REST fallback) */\n async getMeasurement(): Promise<Measurement> {\n return this.request<Measurement>(\"GET\", \"/api/measurement\");\n }\n\n /** Get system info */\n async getSystem(): Promise<SystemInfo> {\n return this.request<SystemInfo>(\"GET\", \"/api/system\");\n }\n\n /**\n * Update system settings\n *\n * @param settings System settings to update\n */\n async setSystem(settings: Partial<SystemInfo>): Promise<SystemInfo> {\n return this.request<SystemInfo>(\"PUT\", \"/api/system\", settings);\n }\n\n /** Reboot device */\n async reboot(): Promise<void> {\n await this.request(\"PUT\", \"/api/system/reboot\");\n }\n\n /** Identify device (blink LED) */\n async identify(): Promise<void> {\n await this.request(\"PUT\", \"/api/system/identify\");\n }\n\n /** Get battery control status */\n async getBatteries(): Promise<BatteryControl> {\n return this.request<BatteryControl>(\"GET\", \"/api/batteries\");\n }\n\n /**\n * Set battery control\n *\n * @param settings Battery control settings to update\n */\n async setBatteries(\n settings: Partial<BatteryControl>,\n ): Promise<BatteryControl> {\n return this.request<BatteryControl>(\"PUT\", \"/api/batteries\", settings);\n }\n\n /**\n * @param method HTTP method\n * @param path API path\n * @param body Optional request body\n */\n private request<T>(method: string, path: string, body?: unknown): Promise<T> {\n return new Promise((resolve, reject) => {\n const bodyStr = body ? JSON.stringify(body) : undefined;\n const headers: Record<string, string> = {\n \"X-Api-Version\": \"2\",\n };\n\n if (this.token) {\n headers.Authorization = `Bearer ${this.token}`;\n }\n if (bodyStr) {\n headers[\"Content-Type\"] = \"application/json\";\n headers[\"Content-Length\"] = Buffer.byteLength(bodyStr).toString();\n }\n\n const req = https.request(\n {\n hostname: this.ip,\n port: 443,\n path,\n method,\n headers,\n agent: HW_AGENT,\n timeout: 10_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n res.on(\"end\", () => {\n const data = Buffer.concat(chunks).toString();\n if (!res.statusCode || res.statusCode >= 400) {\n const error = new HomeWizardApiError(\n res.statusCode ?? 0,\n data,\n `${method} ${path}`,\n );\n reject(error);\n return;\n }\n if (!data) {\n resolve(undefined as T);\n return;\n }\n try {\n resolve(JSON.parse(data) as T);\n } catch {\n reject(\n new Error(\n `Invalid JSON from ${method} ${path}: ${data.substring(0, 200)}`,\n ),\n );\n }\n });\n },\n );\n\n req.on(\"error\", reject);\n req.on(\"timeout\", () => {\n req.destroy(new Error(`Timeout: ${method} ${path}`));\n });\n\n if (bodyStr) {\n req.write(bodyStr);\n }\n req.end();\n });\n }\n}\n\n/** API error with status code and parsed error body */\nexport class HomeWizardApiError extends Error {\n readonly statusCode: number;\n readonly errorCode: string;\n\n /**\n * @param statusCode HTTP status code\n * @param body Response body\n * @param context Request context for error message\n */\n constructor(statusCode: number, body: string, context: string) {\n let errorCode = \"unknown\";\n let description = body;\n try {\n const parsed = JSON.parse(body);\n errorCode = parsed.error?.code ?? parsed.error ?? \"unknown\";\n description = parsed.error?.description ?? parsed.error?.code ?? body;\n } catch {\n // body is not JSON\n }\n super(`${context}: HTTP ${statusCode} \u2014 ${description}`);\n this.name = \"HomeWizardApiError\";\n this.statusCode = statusCode;\n this.errorCode = errorCode;\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as https from \"node:https\";\nimport { HW_AGENT } from \"./cacert\";\nimport type {\n BatteryControl,\n DeviceInfo,\n Measurement,\n PairingResponse,\n SystemInfo,\n} from \"./types\";\n\n/** HTTPS client for HomeWizard API v2 */\nexport class HomeWizardClient {\n private readonly ip: string;\n private readonly token: string;\n\n /**\n * @param ip Device IP address\n * @param token Bearer token (empty string for pairing requests)\n */\n constructor(ip: string, token: string = \"\") {\n this.ip = ip;\n this.token = token;\n }\n\n /** Get device info (GET /api) */\n async getDeviceInfo(): Promise<DeviceInfo> {\n return this.request<DeviceInfo>(\"GET\", \"/api\");\n }\n\n /** Request pairing token (POST /api/user) \u2014 403 until button pressed */\n async requestPairing(): Promise<PairingResponse> {\n return this.request<PairingResponse>(\"POST\", \"/api/user\", {\n name: \"local/iobroker\",\n });\n }\n\n /** Get current measurement (REST fallback) */\n async getMeasurement(): Promise<Measurement> {\n return this.request<Measurement>(\"GET\", \"/api/measurement\");\n }\n\n /** Get system info */\n async getSystem(): Promise<SystemInfo> {\n return this.request<SystemInfo>(\"GET\", \"/api/system\");\n }\n\n /**\n * Update system settings\n *\n * @param settings System settings to update\n */\n async setSystem(settings: Partial<SystemInfo>): Promise<SystemInfo> {\n return this.request<SystemInfo>(\"PUT\", \"/api/system\", settings);\n }\n\n /** Reboot device */\n async reboot(): Promise<void> {\n await this.request(\"PUT\", \"/api/system/reboot\");\n }\n\n /** Identify device (blink LED) */\n async identify(): Promise<void> {\n await this.request(\"PUT\", \"/api/system/identify\");\n }\n\n /** Get battery control status */\n async getBatteries(): Promise<BatteryControl> {\n return this.request<BatteryControl>(\"GET\", \"/api/batteries\");\n }\n\n /**\n * Set battery control\n *\n * @param settings Battery control settings to update\n */\n async setBatteries(\n settings: Partial<BatteryControl>,\n ): Promise<BatteryControl> {\n return this.request<BatteryControl>(\"PUT\", \"/api/batteries\", settings);\n }\n\n /**\n * @param method HTTP method\n * @param path API path\n * @param body Optional request body\n */\n private request<T>(method: string, path: string, body?: unknown): Promise<T> {\n return new Promise((resolve, reject) => {\n const bodyStr = body ? JSON.stringify(body) : undefined;\n const headers: Record<string, string> = {\n \"X-Api-Version\": \"2\",\n };\n\n if (this.token) {\n headers.Authorization = `Bearer ${this.token}`;\n }\n if (bodyStr) {\n headers[\"Content-Type\"] = \"application/json\";\n headers[\"Content-Length\"] = Buffer.byteLength(bodyStr).toString();\n }\n\n const req = https.request(\n {\n hostname: this.ip,\n port: 443,\n path,\n method,\n headers,\n agent: HW_AGENT,\n timeout: 10_000,\n },\n (res) => {\n const chunks: Buffer[] = [];\n res.on(\"error\", reject);\n res.on(\"data\", (chunk: Buffer) => chunks.push(chunk));\n res.on(\"end\", () => {\n const data = Buffer.concat(chunks).toString();\n if (!res.statusCode || res.statusCode >= 400) {\n const error = new HomeWizardApiError(\n res.statusCode ?? 0,\n data,\n `${method} ${path}`,\n );\n reject(error);\n return;\n }\n if (!data) {\n resolve(undefined as T);\n return;\n }\n try {\n resolve(JSON.parse(data) as T);\n } catch {\n reject(\n new Error(\n `Invalid JSON from ${method} ${path}: ${data.substring(0, 200)}`,\n ),\n );\n }\n });\n },\n );\n\n req.on(\"error\", reject);\n req.on(\"timeout\", () => {\n req.destroy(new Error(`Timeout: ${method} ${path}`));\n });\n\n if (bodyStr) {\n req.write(bodyStr);\n }\n req.end();\n });\n }\n}\n\n/** API error with status code and parsed error body */\nexport class HomeWizardApiError extends Error {\n readonly statusCode: number;\n readonly errorCode: string;\n\n /**\n * @param statusCode HTTP status code\n * @param body Response body\n * @param context Request context for error message\n */\n constructor(statusCode: number, body: string, context: string) {\n let errorCode = \"unknown\";\n let description = body;\n try {\n const parsed = JSON.parse(body);\n errorCode = parsed.error?.code ?? parsed.error ?? \"unknown\";\n description = parsed.error?.description ?? parsed.error?.code ?? body;\n } catch {\n // body is not JSON\n }\n super(`${context}: HTTP ${statusCode} \u2014 ${description}`);\n this.name = \"HomeWizardApiError\";\n this.statusCode = statusCode;\n this.errorCode = errorCode;\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -21,6 +21,7 @@ __export(state_manager_exports, {
21
21
  StateManager: () => StateManager
22
22
  });
23
23
  module.exports = __toCommonJS(state_manager_exports);
24
+ var import_coerce = require("./coerce");
24
25
  function sanitize(str) {
25
26
  return str.replace(/[^a-zA-Z0-9_-]/g, "_").toLowerCase();
26
27
  }
@@ -582,63 +583,93 @@ class StateManager {
582
583
  * @param data Measurement data
583
584
  */
584
585
  async updateMeasurement(config, data) {
585
- var _a;
586
+ if (!(0, import_coerce.isPlainObject)(data)) {
587
+ return;
588
+ }
586
589
  const prefix = this.devicePrefix(config);
587
590
  const mPrefix = `${prefix}.measurement`;
588
- await this.adapter.extendObjectAsync(mPrefix, {
591
+ await this.adapter.setObjectNotExistsAsync(mPrefix, {
589
592
  type: "channel",
590
593
  common: { name: "Measurement" },
591
594
  native: {}
592
595
  });
593
- const fields = MEASUREMENT_STATE_DEFS;
594
- for (const def of fields) {
595
- const rawValue = data[def.key];
596
- if (rawValue !== void 0 && rawValue !== null && !Array.isArray(rawValue)) {
596
+ const record = data;
597
+ for (const def of MEASUREMENT_STATE_DEFS) {
598
+ const raw = record[def.key];
599
+ let coerced = null;
600
+ if (def.type === "number") {
601
+ coerced = (0, import_coerce.coerceFiniteNumber)(raw);
602
+ } else if (def.type === "string") {
603
+ coerced = (0, import_coerce.coerceString)(raw);
604
+ }
605
+ if (coerced !== null) {
597
606
  await this.ensureAndSet(
598
607
  `${mPrefix}.${def.id}`,
599
608
  def.name,
600
609
  def.type,
601
610
  def.role,
602
- rawValue,
611
+ coerced,
603
612
  def.unit
604
613
  );
605
614
  }
606
615
  }
607
- if ((_a = data.external) == null ? void 0 : _a.length) {
608
- await this.adapter.extendObjectAsync(`${mPrefix}.external`, {
609
- type: "channel",
610
- common: { name: "External Meters" },
611
- native: {}
612
- });
613
- for (const ext of data.external) {
614
- const extId = `${mPrefix}.external.${sanitize(ext.type)}_${sanitize(ext.unique_id)}`;
615
- await this.adapter.extendObjectAsync(extId, {
616
+ const external = record.external;
617
+ if (Array.isArray(external) && external.length > 0) {
618
+ let extChannelEnsured = false;
619
+ for (const rawExt of external) {
620
+ if (!(0, import_coerce.isPlainObject)(rawExt)) {
621
+ continue;
622
+ }
623
+ const type = (0, import_coerce.coerceString)(rawExt.type);
624
+ const uniqueId = (0, import_coerce.coerceString)(rawExt.unique_id);
625
+ if (!type || !uniqueId) {
626
+ continue;
627
+ }
628
+ const value = (0, import_coerce.coerceFiniteNumber)(rawExt.value);
629
+ const unit = (0, import_coerce.coerceString)(rawExt.unit);
630
+ const timestamp = (0, import_coerce.coerceString)(rawExt.timestamp);
631
+ if (!extChannelEnsured) {
632
+ await this.adapter.setObjectNotExistsAsync(`${mPrefix}.external`, {
633
+ type: "channel",
634
+ common: { name: "External Meters" },
635
+ native: {}
636
+ });
637
+ extChannelEnsured = true;
638
+ }
639
+ const extId = `${mPrefix}.external.${sanitize(type)}_${sanitize(uniqueId)}`;
640
+ await this.adapter.setObjectNotExistsAsync(extId, {
616
641
  type: "channel",
617
- common: { name: ext.type },
642
+ common: { name: type },
618
643
  native: {}
619
644
  });
620
- await this.ensureAndSet(
621
- `${extId}.value`,
622
- "Value",
623
- "number",
624
- "value",
625
- ext.value,
626
- ext.unit
627
- );
628
- await this.ensureAndSet(
629
- `${extId}.unit`,
630
- "Unit",
631
- "string",
632
- "text",
633
- ext.unit
634
- );
635
- await this.ensureAndSet(
636
- `${extId}.timestamp`,
637
- "Timestamp",
638
- "string",
639
- "date",
640
- ext.timestamp
641
- );
645
+ if (value !== null) {
646
+ await this.ensureAndSet(
647
+ `${extId}.value`,
648
+ "Value",
649
+ "number",
650
+ "value",
651
+ value,
652
+ unit != null ? unit : void 0
653
+ );
654
+ }
655
+ if (unit) {
656
+ await this.ensureAndSet(
657
+ `${extId}.unit`,
658
+ "Unit",
659
+ "string",
660
+ "text",
661
+ unit
662
+ );
663
+ }
664
+ if (timestamp) {
665
+ await this.ensureAndSet(
666
+ `${extId}.timestamp`,
667
+ "Timestamp",
668
+ "string",
669
+ "date",
670
+ timestamp
671
+ );
672
+ }
642
673
  }
643
674
  }
644
675
  }
@@ -649,53 +680,70 @@ class StateManager {
649
680
  * @param system System info data
650
681
  */
651
682
  async updateSystem(config, system) {
683
+ if (!(0, import_coerce.isPlainObject)(system)) {
684
+ return;
685
+ }
652
686
  const prefix = this.devicePrefix(config);
653
- await this.ensureAndSet(
654
- `${prefix}.info.wifi_rssi_db`,
655
- "WiFi signal strength",
656
- "number",
657
- "value",
658
- system.wifi_rssi_db,
659
- "dB"
660
- );
661
- await this.ensureAndSet(
662
- `${prefix}.info.uptime_s`,
663
- "Uptime",
664
- "number",
665
- "value",
666
- system.uptime_s,
667
- "s"
668
- );
669
- await this.adapter.extendObjectAsync(`${prefix}.system`, {
687
+ const record = system;
688
+ const rssi = (0, import_coerce.coerceFiniteNumber)(record.wifi_rssi_db);
689
+ if (rssi !== null) {
690
+ await this.ensureAndSet(
691
+ `${prefix}.info.wifi_rssi_db`,
692
+ "WiFi signal strength",
693
+ "number",
694
+ "value",
695
+ rssi,
696
+ "dB"
697
+ );
698
+ }
699
+ const uptime = (0, import_coerce.coerceFiniteNumber)(record.uptime_s);
700
+ if (uptime !== null) {
701
+ await this.ensureAndSet(
702
+ `${prefix}.info.uptime_s`,
703
+ "Uptime",
704
+ "number",
705
+ "value",
706
+ uptime,
707
+ "s"
708
+ );
709
+ }
710
+ await this.adapter.setObjectNotExistsAsync(`${prefix}.system`, {
670
711
  type: "channel",
671
712
  common: { name: "System Settings" },
672
713
  native: {}
673
714
  });
674
- await this.ensureAndSet(
675
- `${prefix}.system.cloud_enabled`,
676
- "Cloud enabled",
677
- "boolean",
678
- "switch",
679
- system.cloud_enabled,
680
- void 0,
681
- true
682
- );
683
- await this.ensureAndSet(
684
- `${prefix}.system.status_led_brightness_pct`,
685
- "LED brightness",
686
- "number",
687
- "level",
688
- system.status_led_brightness_pct,
689
- "%",
690
- true
691
- );
692
- if (system.api_v1_enabled !== void 0) {
715
+ const cloudEnabled = (0, import_coerce.coerceBoolean)(record.cloud_enabled);
716
+ if (cloudEnabled !== null) {
717
+ await this.ensureAndSet(
718
+ `${prefix}.system.cloud_enabled`,
719
+ "Cloud enabled",
720
+ "boolean",
721
+ "switch",
722
+ cloudEnabled,
723
+ void 0,
724
+ true
725
+ );
726
+ }
727
+ const ledPct = (0, import_coerce.coerceFiniteNumber)(record.status_led_brightness_pct);
728
+ if (ledPct !== null) {
729
+ await this.ensureAndSet(
730
+ `${prefix}.system.status_led_brightness_pct`,
731
+ "LED brightness",
732
+ "number",
733
+ "level",
734
+ ledPct,
735
+ "%",
736
+ true
737
+ );
738
+ }
739
+ const apiV1 = (0, import_coerce.coerceBoolean)(record.api_v1_enabled);
740
+ if (apiV1 !== null) {
693
741
  await this.ensureAndSet(
694
742
  `${prefix}.system.api_v1_enabled`,
695
743
  "API v1 enabled",
696
744
  "boolean",
697
745
  "switch",
698
- system.api_v1_enabled,
746
+ apiV1,
699
747
  void 0,
700
748
  true
701
749
  );
@@ -713,80 +761,87 @@ class StateManager {
713
761
  * @param battery Battery control data
714
762
  */
715
763
  async updateBattery(config, battery) {
764
+ if (!(0, import_coerce.isPlainObject)(battery)) {
765
+ return;
766
+ }
716
767
  const prefix = this.devicePrefix(config);
717
- await this.adapter.extendObjectAsync(`${prefix}.battery`, {
768
+ const record = battery;
769
+ await this.adapter.setObjectNotExistsAsync(`${prefix}.battery`, {
718
770
  type: "channel",
719
771
  common: { name: "Battery Control" },
720
772
  native: {}
721
773
  });
722
- await this.ensureAndSet(
723
- `${prefix}.battery.mode`,
724
- "Battery mode",
725
- "string",
726
- "text",
727
- battery.mode,
728
- void 0,
729
- true
730
- );
731
- if (battery.permissions !== void 0) {
774
+ const mode = (0, import_coerce.coerceString)(record.mode);
775
+ if (mode) {
732
776
  await this.ensureAndSet(
733
- `${prefix}.battery.permissions`,
734
- "Battery permissions",
777
+ `${prefix}.battery.mode`,
778
+ "Battery mode",
735
779
  "string",
736
- "json",
737
- JSON.stringify(battery.permissions),
780
+ "text",
781
+ mode,
738
782
  void 0,
739
783
  true
740
784
  );
741
785
  }
742
- if (battery.battery_count !== void 0) {
743
- await this.ensureAndSet(
744
- `${prefix}.battery.battery_count`,
745
- "Connected batteries",
746
- "number",
747
- "value",
748
- battery.battery_count
749
- );
750
- }
751
- if (battery.power_w !== void 0) {
752
- await this.ensureAndSet(
753
- `${prefix}.battery.power_w`,
754
- "Battery power",
755
- "number",
756
- "value.power",
757
- battery.power_w,
758
- "W"
759
- );
760
- }
761
- if (battery.target_power_w !== void 0) {
786
+ if (Array.isArray(record.permissions)) {
762
787
  await this.ensureAndSet(
763
- `${prefix}.battery.target_power_w`,
764
- "Target power",
765
- "number",
766
- "value.power",
767
- battery.target_power_w,
768
- "W"
769
- );
770
- }
771
- if (battery.max_consumption_w !== void 0) {
772
- await this.ensureAndSet(
773
- `${prefix}.battery.max_consumption_w`,
774
- "Max consumption",
775
- "number",
776
- "value.power",
777
- battery.max_consumption_w,
778
- "W"
788
+ `${prefix}.battery.permissions`,
789
+ "Battery permissions",
790
+ "string",
791
+ "json",
792
+ JSON.stringify(record.permissions),
793
+ void 0,
794
+ true
779
795
  );
780
796
  }
781
- if (battery.max_production_w !== void 0) {
782
- await this.ensureAndSet(
783
- `${prefix}.battery.max_production_w`,
784
- "Max production",
785
- "number",
786
- "value.power",
787
- battery.max_production_w,
788
- "W"
789
- );
797
+ const numberFields = [
798
+ {
799
+ key: "battery_count",
800
+ id: "battery_count",
801
+ name: "Connected batteries",
802
+ role: "value"
803
+ },
804
+ {
805
+ key: "power_w",
806
+ id: "power_w",
807
+ name: "Battery power",
808
+ role: "value.power",
809
+ unit: "W"
810
+ },
811
+ {
812
+ key: "target_power_w",
813
+ id: "target_power_w",
814
+ name: "Target power",
815
+ role: "value.power",
816
+ unit: "W"
817
+ },
818
+ {
819
+ key: "max_consumption_w",
820
+ id: "max_consumption_w",
821
+ name: "Max consumption",
822
+ role: "value.power",
823
+ unit: "W"
824
+ },
825
+ {
826
+ key: "max_production_w",
827
+ id: "max_production_w",
828
+ name: "Max production",
829
+ role: "value.power",
830
+ unit: "W"
831
+ }
832
+ ];
833
+ for (const field of numberFields) {
834
+ const coerced = (0, import_coerce.coerceFiniteNumber)(record[field.key]);
835
+ if (coerced !== null) {
836
+ await this.ensureAndSet(
837
+ `${prefix}.battery.${field.id}`,
838
+ field.name,
839
+ "number",
840
+ field.role,
841
+ coerced,
842
+ field.unit
843
+ );
844
+ }
790
845
  }
791
846
  }
792
847
  /**
@@ -859,7 +914,7 @@ class StateManager {
859
914
  if (unit) {
860
915
  common.unit = unit;
861
916
  }
862
- await this.adapter.extendObjectAsync(id, {
917
+ await this.adapter.setObjectNotExistsAsync(id, {
863
918
  type: "state",
864
919
  common,
865
920
  native: {}
@@ -872,7 +927,7 @@ class StateManager {
872
927
  * @param name State name
873
928
  */
874
929
  async createButton(id, name) {
875
- await this.adapter.extendObjectAsync(id, {
930
+ await this.adapter.setObjectNotExistsAsync(id, {
876
931
  type: "state",
877
932
  common: {
878
933
  name,