iobroker.homewizard 0.6.2 → 0.6.4

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
@@ -114,6 +112,7 @@ homewizard.0.
114
112
  │ ├── cycles — Battery charge cycles (number)
115
113
  │ ├── average_power_15m_w — 15-min average power (number, W, Belgium)
116
114
  │ ├── monthly_power_peak_w — Monthly power peak (number, W, Belgium)
115
+ │ ├── monthly_power_peak_timestamp — Monthly peak timestamp (string)
117
116
  │ ├── meter_model — Meter model identifier (string)
118
117
  │ ├── timestamp — Measurement timestamp (string)
119
118
  │ ├── quality/ — Power quality counters
@@ -165,6 +164,15 @@ homewizard.0.
165
164
  ---
166
165
 
167
166
  ## Changelog
167
+ ### 0.6.4 (2026-04-23)
168
+ - Separate test-build output (`build-test/`) from production `build/`, so `npm test` no longer risks leaving duplicated `build/src` + `build/test` trees in the published package.
169
+ - Wrap async `onReady` and `onStateChange` handlers with `.catch()` to prevent unhandled promise rejections from SIGKILLing the adapter.
170
+ - Declare `pairingIp` as an instance object (11-language name) instead of creating it dynamically in `onReady`.
171
+
172
+ ### 0.6.3 (2026-04-18)
173
+ - Harden WebSocket and REST input handling against unexpected API responses
174
+ - Stop endless reconnect when the device token is invalid (fires once after 3 failed auth attempts)
175
+ - Avoid creating an empty `external/` channel when a device reports no external meters
168
176
 
169
177
  ### 0.6.2 (2026-04-13)
170
178
  - Fix hanging promise when response stream errors mid-transfer (`res.on("error")`)
@@ -184,23 +192,6 @@ homewizard.0.
184
192
  - Persistent REST fallback for unstable devices (30s interval instead of stopping)
185
193
  - Automatic mode switch: stabilizes after 10 min connected, detects instability after 3 short-lived connections
186
194
 
187
- ### 0.5.1 (2026-04-08)
188
- - Restore standard GitHub-based tests, remove CHANGELOG.md, add FORBIDDEN_CHARS reference
189
-
190
- ### 0.5.0 (2026-04-05)
191
- - Robust reconnect: never give up after WiFi loss, retry every 5 minutes indefinitely
192
- - Periodic mDNS IP recovery (~hourly), only WebSocket controls online state
193
-
194
- ### 0.4.2 (2026-04-05)
195
- - Consistent donation labels and about text across all adapters
196
-
197
- ### 0.4.1 (2026-04-05)
198
- - Move measurement data into `measurement/` channel for cleaner object tree
199
-
200
- Older entries have been moved to [CHANGELOG_OLD.md](CHANGELOG_OLD.md).
201
-
202
- ---
203
-
204
195
  ## Support
205
196
 
206
197
  - [ioBroker Forum](https://forum.iobroker.net/)
@@ -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
+ }
@@ -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,7 +583,9 @@ 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
591
  await this.adapter.setObjectNotExistsAsync(mPrefix, {
@@ -590,55 +593,83 @@ class StateManager {
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.setObjectNotExistsAsync(`${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)}`;
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)}`;
615
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
- );
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
+ }
669
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);
768
+ const record = battery;
717
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
  /**