homebridge-nuheat2 1.2.4-beta.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/CHANGELOG.md ADDED
@@ -0,0 +1,89 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project should be documented in this file
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [1.2.4-beta.0] - 2026-04-11
8
+
9
+ ### Changed
10
+
11
+ - Delay platform startup until Homebridge finishes restoring cached accessories
12
+ - Allow overriding Nuheat OAuth client settings through config or environment variables
13
+ - Improve SignalR reconnection handling and token refresh behavior
14
+ - Add basic regression tests and modernize package metadata for Homebridge 1.8+ and 2.0 betas
15
+ - Publish the maintained fork under the new npm package identity `homebridge-nuheat2`
16
+
17
+ ### Fixed
18
+
19
+ - Correct manual-mode thermostat mapping so HomeKit no longer snaps back to off
20
+ - Fix thermostat online-state handling so status updates no longer rely on an assignment bug
21
+ - Pin transitive websocket and cookie dependencies away from known vulnerable versions
22
+
23
+ ## [1.2.3] - 2024-11-04
24
+
25
+ ### Fixed
26
+
27
+ - Bug when using away mode switches
28
+ - Variable handling in some debug logging
29
+
30
+ ## [1.2.2] - 2023-06-13
31
+
32
+ ### Fixed
33
+
34
+ - variable typo
35
+
36
+ ## [1.2.1] - 2023-06-01
37
+
38
+ ### Fixed
39
+
40
+ - hold length bug
41
+
42
+ ## [1.2.0] - 2023-05-26
43
+
44
+ ### Changed
45
+
46
+ - client secret for api auth
47
+
48
+ ### Fixed
49
+
50
+ - async updates from the api to reduce cookie creation
51
+
52
+ ## [1.1.4] - 2023-05-12
53
+
54
+ ### Changed
55
+
56
+ - error handling when unable to get access token, as to not crash homebridge
57
+
58
+ ## [1.1.3] - 2023-04-17
59
+
60
+ ### Fixed
61
+
62
+ - typo in away mode
63
+
64
+ ## [1.1.2] - 2022-12-24
65
+
66
+ ### Fixed
67
+
68
+ - some unhandled api auth error
69
+
70
+ ### Changed
71
+
72
+ - some debug logging code
73
+
74
+ ## [1.1.1] - 2022-11-17
75
+
76
+ ### Fixed
77
+
78
+ - changed the 'homebridge-nuheat' name to be lower case so HOOBS handles properly
79
+
80
+ ## [1.1.0] - 2022-11-15
81
+
82
+ ### Added
83
+
84
+ - Added `homebridge-ui` support with auto detection
85
+ - Added Away Mode switches for groups
86
+
87
+ ### Changed
88
+
89
+ - Changed the underlying API to use nuheats new api system
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # homebridge-nuheat2
2
+
3
+ [![npm version](https://img.shields.io/npm/v/homebridge-nuheat2.svg)](https://www.npmjs.com/package/homebridge-nuheat2)
4
+ [![npm downloads](https://img.shields.io/npm/dm/homebridge-nuheat2.svg)](https://www.npmjs.com/package/homebridge-nuheat2)
5
+
6
+ Homebridge platform plugin for Nuheat Signature floor-heating thermostats.
7
+
8
+ This fork focuses on modernizing the plugin for current Homebridge releases, improving runtime stability, and preparing for Homebridge 2.0 while keeping the existing `NuHeat` platform configuration intact.
9
+
10
+ ## Highlights
11
+
12
+ - Automatically discovers thermostats on the authenticated Nuheat account
13
+ - Optionally creates HomeKit switches for Nuheat group away mode
14
+ - Supports permanent, scheduled, and timed holds
15
+ - Uses Nuheat's OAuth-based API instead of legacy site scraping
16
+ - Includes compatibility improvements for Homebridge 1.8+ and 2.0 betas
17
+ - Allows advanced OAuth overrides for long-term API stability
18
+
19
+ ## Compatibility
20
+
21
+ - Homebridge: `^1.8.0 || ^2.0.0-beta.0`
22
+ - Node.js: `^18.20.4 || ^20.18.0 || ^22 || ^24`
23
+
24
+ For current Homebridge 2.0 betas, use Node 22 or 24.
25
+
26
+ ## Installation
27
+
28
+ Install Homebridge first:
29
+
30
+ ```bash
31
+ npm install -g homebridge
32
+ ```
33
+
34
+ Then install the plugin:
35
+
36
+ ```bash
37
+ npm install -g homebridge-nuheat2
38
+ ```
39
+
40
+ The published package name for this maintained fork is `homebridge-nuheat2`. The Homebridge platform name in config remains `NuHeat`.
41
+
42
+ ## Configuration
43
+
44
+ Most users should configure the plugin through Homebridge Config UI X, but the equivalent JSON looks like this:
45
+
46
+ ```json
47
+ {
48
+ "platform": "NuHeat",
49
+ "name": "NuHeat",
50
+ "email": "email@address.com",
51
+ "password": "password123",
52
+ "devices": [{ "serialNumber": "1111111" }, { "serialNumber": "2222222" }],
53
+ "autoPopulateAwayModeSwitches": true,
54
+ "holdLength": 1440,
55
+ "refresh": 60
56
+ }
57
+ ```
58
+
59
+ ### Options
60
+
61
+ - `platform`: Must be `NuHeat`
62
+ - `name`: Display name used in Homebridge logs
63
+ - `email`: MyNuheat account email address
64
+ - `password`: MyNuheat account password
65
+ - `devices`: Optional list of thermostats to expose
66
+ - `serialNumber`: Thermostat serial number from MyNuheat
67
+ - `autoPopulateAwayModeSwitches`: Automatically expose switches for all groups on the account
68
+ - `groups`: Optional allow-list of groups to expose as away-mode switches
69
+ - `groupName`: Group name as shown in MyNuheat
70
+ - `holdLength`: Hold duration in minutes
71
+ - `refresh`: Poll interval in seconds, default `60`
72
+ - `debug`: Enables verbose logging
73
+ - `clientId`: Optional advanced override for the Nuheat OAuth client ID
74
+ - `clientSecret`: Optional advanced override for the Nuheat OAuth client secret
75
+ - `redirectUri`: Optional advanced override for the Nuheat OAuth redirect URI, default `http://localhost`
76
+
77
+ ### Hold Length Behavior
78
+
79
+ - `0`: hold until the next scheduled event
80
+ - `1-1439`: timed hold for the configured number of minutes
81
+ - `1440`: permanent hold
82
+
83
+ ### Device Discovery
84
+
85
+ If `devices` is omitted or empty, the plugin will automatically expose every thermostat on the authenticated account.
86
+
87
+ If `groups` is omitted and `autoPopulateAwayModeSwitches` is enabled, the plugin will automatically expose away-mode switches for all groups on the account.
88
+
89
+ ## Nuheat API Access
90
+
91
+ Nuheat's public OpenAPI documentation indicates that third-party developers should request their own API credentials:
92
+
93
+ - [Nuheat OpenAPI docs](https://api.mynuheat.com/)
94
+ - [Nuheat API access request page](https://www.nuheat.com/openapi)
95
+
96
+ This fork still supports the legacy built-in OAuth client settings as a fallback, but using your own `clientId` and `clientSecret` is the recommended long-term path.
97
+
98
+ ## What's New In This Fork
99
+
100
+ - Fixed the manual-mode thermostat issue where HomeKit could immediately snap back to `Off`
101
+ - Hardened online-state parsing and general accessory refresh behavior
102
+ - Delayed platform startup until Homebridge finishes restoring cached accessories
103
+ - Improved SignalR reconnect handling
104
+ - Added regression tests for the key thermostat behavior fixes
105
+ - Updated package metadata and dependency overrides for a cleaner modern release
106
+ - Published under the maintainer-owned package identity `homebridge-nuheat2`
107
+
108
+ ## Development
109
+
110
+ Run the test suite with:
111
+
112
+ ```bash
113
+ npm test
114
+ ```
115
+
116
+ ## Roadmap
117
+
118
+ - Validate the plugin against an official Nuheat API client registration
119
+ - Verify group and away-mode behavior against current live API responses
120
+ - Revisit whether SignalR notifications can reduce polling further in real-world deployments
@@ -0,0 +1,101 @@
1
+ {
2
+ "pluginAlias": "NuHeat",
3
+ "pluginType": "platform",
4
+ "singular": false,
5
+ "schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "name": {
9
+ "title": "Name",
10
+ "type": "string",
11
+ "required": true,
12
+ "default": "NuHeat"
13
+ },
14
+ "email": {
15
+ "title": "email",
16
+ "type": "string",
17
+ "required": true,
18
+ "format": "email"
19
+ },
20
+ "password": {
21
+ "title": "password",
22
+ "type": "string",
23
+ "required": true
24
+ },
25
+ "clientId": {
26
+ "title": "Nuheat Client ID",
27
+ "type": "string",
28
+ "description": "Optional advanced override. Leave blank to use the built-in default client ID."
29
+ },
30
+ "clientSecret": {
31
+ "title": "Nuheat Client Secret",
32
+ "type": "string",
33
+ "description": "Optional advanced override. Leave blank to use the built-in default client secret."
34
+ },
35
+ "redirectUri": {
36
+ "title": "Nuheat Redirect URI",
37
+ "type": "string",
38
+ "description": "Optional advanced override for OAuth redirect URI.",
39
+ "placeholder": "http://localhost"
40
+ },
41
+ "devices": {
42
+ "title": "Devices",
43
+ "type": "array",
44
+ "items": {
45
+ "type": "object",
46
+ "properties": {
47
+ "serialNumber": {
48
+ "title": "Serial Number",
49
+ "type": "string",
50
+ "required": true
51
+ },
52
+ "disabled": {
53
+ "title": "Disabled",
54
+ "type": "boolean"
55
+ }
56
+ }
57
+ }
58
+ },
59
+ "autoPopulateAwayModeSwitches": {
60
+ "title": "Auto populate Away Mode switches for all available groups",
61
+ "type": "boolean"
62
+ },
63
+ "groups": {
64
+ "title": "Groups",
65
+ "type": "array",
66
+ "items": {
67
+ "type": "object",
68
+ "properties": {
69
+ "groupName": {
70
+ "title": "Group Name",
71
+ "type": "string",
72
+ "required": true
73
+ },
74
+ "disabled": {
75
+ "title": "Disabled",
76
+ "type": "boolean"
77
+ }
78
+ }
79
+ }
80
+ },
81
+ "holdLength": {
82
+ "title": "Hold Length (in minutes)",
83
+ "type": "integer",
84
+ "description": "If set to 0, set point changes will hold until the next scheduled event. If set to 1440 (default), set point changes will be permanent. Set to any value in between for a timed hold.",
85
+ "placeholder": 1440
86
+ },
87
+ "refresh": {
88
+ "title": "Refresh Interval (in seconds)",
89
+ "type": "integer",
90
+ "placeholder": 60,
91
+ "minimum": 1
92
+ },
93
+ "debug": {
94
+ "title": "Enable debug logs",
95
+ "type": "boolean"
96
+ }
97
+ }
98
+ },
99
+ "form": null,
100
+ "display": null
101
+ }
package/index.js ADDED
@@ -0,0 +1,360 @@
1
+ "use strict";
2
+
3
+ let NuHeatAPI = require("./lib/NuHeatAPI.js");
4
+ let NuHeatGroup = require("./lib/NuHeatGroup.js");
5
+ let NuHeatThermostat = require("./lib/NuHeatThermostat.js");
6
+ let NuHeatListener = require("./lib/NuHeatListener.js");
7
+ const logger = require("./lib/logger");
8
+
9
+ let Homebridge, PlatformAccessory, Service, Characteristic, UUIDGen;
10
+ const PLUGIN_NAME = "homebridge-nuheat2";
11
+ const PLATFORM_NAME = "NuHeat";
12
+
13
+ module.exports = function (homebridge) {
14
+ Homebridge = homebridge;
15
+ PlatformAccessory = homebridge.platformAccessory;
16
+ Characteristic = homebridge.hap.Characteristic;
17
+ Service = homebridge.hap.Service;
18
+ UUIDGen = homebridge.hap.uuid;
19
+
20
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, NuHeatPlatform, true);
21
+ };
22
+
23
+ class NuHeatPlatform {
24
+ constructor(log, config, api) {
25
+ if (!config) {
26
+ log.warn("Ignoring NuHeat Platform setup because it is not configured");
27
+ this.disabled = true;
28
+ return;
29
+ }
30
+
31
+ if ((!config.Email && !config.email) || !config.password) {
32
+ log.warn(
33
+ "Ignoring NuHeat Platform setup because it is not configured properly. Missing email or password",
34
+ );
35
+ this.disabled = true;
36
+ return;
37
+ }
38
+
39
+ this.config = config;
40
+ this.config.email = this.config.Email || this.config.email;
41
+ this.config.holdLength = Math.min(
42
+ 1440,
43
+ Math.max(0, this.config.holdLength || 1440),
44
+ );
45
+ this.api = api;
46
+ this.accessories = [];
47
+ this.log = new logger.Logger(log, this.config.debug || false);
48
+ this.refreshTimer = null;
49
+ this.didFinishLaunching = false;
50
+
51
+ if (this.api?.on) {
52
+ // Modern Homebridge startup restores cached accessories first, then calls didFinishLaunching.
53
+ this.api.on("didFinishLaunching", async () => {
54
+ this.didFinishLaunching = true;
55
+ await this.setupPlatform();
56
+ });
57
+ this.api.on("shutdown", () => {
58
+ this.teardown();
59
+ });
60
+ } else {
61
+ this.setupPlatform();
62
+ }
63
+ }
64
+
65
+ configureAccessory(accessory) {
66
+ this.accessories.push({ uuid: accessory.UUID, accessory });
67
+ }
68
+
69
+ async setupPlatform() {
70
+ if (this.disabled) {
71
+ return;
72
+ }
73
+
74
+ if (this.api?.on && !this.didFinishLaunching) {
75
+ return;
76
+ }
77
+
78
+ this.log.info("Logging into NuHeat...");
79
+ this.NuHeatAPI = new NuHeatAPI(
80
+ this.config.email,
81
+ this.config.password,
82
+ this.log,
83
+ {
84
+ clientId: this.config.clientId,
85
+ clientSecret: this.config.clientSecret,
86
+ redirectUri: this.config.redirectUri,
87
+ },
88
+ );
89
+
90
+ if (await this.NuHeatAPI.returnAccessToken()) {
91
+ await this.setupGroups();
92
+ await this.setupThermostats();
93
+ this.cleanupRemovedAccessories();
94
+
95
+ if (this.refreshTimer) {
96
+ clearInterval(this.refreshTimer);
97
+ }
98
+
99
+ this.refreshTimer = setInterval(
100
+ this.refreshAccessories.bind(this),
101
+ (this.config.refresh || 60) * 1000,
102
+ );
103
+
104
+ if (!this.NuHeatListener) {
105
+ this.NuHeatListener = new NuHeatListener(this.NuHeatAPI, this);
106
+ this.NuHeatListener.connect();
107
+ }
108
+ } else {
109
+ this.log.error(
110
+ "Unable to acquire an access token. We will try again later.",
111
+ );
112
+ setTimeout(
113
+ this.setupPlatform.bind(this),
114
+ (this.config.refresh || 60) * 1000,
115
+ );
116
+ }
117
+ }
118
+
119
+ async setupGroups() {
120
+ const groupArray = this.config.groups || [];
121
+ if (!(this.config.autoPopulateAwayModeSwitches || groupArray.length > 0)) {
122
+ return;
123
+ }
124
+
125
+ const response = await this.NuHeatAPI.refreshGroups();
126
+ if (!response) {
127
+ this.log.error("Error getting data from NuHeatAPI");
128
+ return;
129
+ }
130
+
131
+ if (groupArray.length === 0) {
132
+ this.log.info(
133
+ "No groups defined in config. Auto populating away mode switches by pulling all groups from the account.",
134
+ );
135
+ }
136
+
137
+ await Promise.all(
138
+ response.map((deviceData) => {
139
+ if (
140
+ !(
141
+ groupArray.length === 0 ||
142
+ groupArray.find(
143
+ (device) =>
144
+ device.groupName == deviceData.groupName && !device.disabled,
145
+ )
146
+ )
147
+ ) {
148
+ return;
149
+ }
150
+
151
+ const uuid = UUIDGen.generate(deviceData.groupId.toString());
152
+ let deviceAccessory = false;
153
+
154
+ if (this.accessories.find((accessory) => accessory.uuid === uuid)) {
155
+ deviceAccessory = this.accessories.find(
156
+ (accessory) => accessory.uuid === uuid,
157
+ ).accessory;
158
+ }
159
+
160
+ if (!deviceAccessory) {
161
+ this.log.info("Creating new away mode switch", deviceData.groupName);
162
+ const accessory = new PlatformAccessory(deviceData.groupName, uuid);
163
+ accessory.addService(
164
+ Service.Switch,
165
+ deviceData.groupName + " Away Mode",
166
+ );
167
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
168
+ accessory,
169
+ ]);
170
+ deviceAccessory = accessory;
171
+ this.accessories.push({ uuid });
172
+ }
173
+
174
+ this.accessories.find(
175
+ (accessory) => accessory.uuid === uuid,
176
+ ).accessory = new NuHeatGroup(
177
+ this.log,
178
+ deviceData,
179
+ deviceAccessory instanceof NuHeatGroup
180
+ ? deviceAccessory.accessory
181
+ : deviceAccessory,
182
+ this.NuHeatAPI,
183
+ Homebridge,
184
+ );
185
+ this.accessories.find(
186
+ (accessory) => accessory.uuid === uuid,
187
+ ).existsInConfig = true;
188
+ this.log.info("Loaded away mode switch", deviceData.groupName);
189
+ this.accessories
190
+ .find((accessory) => accessory.uuid === uuid)
191
+ .accessory.updateValues(deviceData);
192
+ }),
193
+ );
194
+ }
195
+
196
+ async setupThermostats() {
197
+ const deviceArray = this.config.devices || [];
198
+ const response = await this.NuHeatAPI.refreshThermostats();
199
+
200
+ if (!response) {
201
+ this.log.error("Error getting data from NuHeatAPI");
202
+ return;
203
+ }
204
+
205
+ if (deviceArray.length === 0) {
206
+ this.log.info(
207
+ "No devices defined in config. Auto populating thermostats by pulling everything from the account.",
208
+ );
209
+ }
210
+
211
+ await Promise.all(
212
+ response.map((deviceData) => {
213
+ if (
214
+ !(
215
+ deviceArray.length === 0 ||
216
+ deviceArray.find(
217
+ (device) =>
218
+ device.serialNumber == deviceData.serialNumber &&
219
+ !device.disabled,
220
+ )
221
+ )
222
+ ) {
223
+ return;
224
+ }
225
+
226
+ const uuid = UUIDGen.generate(deviceData.serialNumber.toString());
227
+ let deviceAccessory = false;
228
+
229
+ if (this.accessories.find((accessory) => accessory.uuid === uuid)) {
230
+ deviceAccessory = this.accessories.find(
231
+ (accessory) => accessory.uuid === uuid,
232
+ ).accessory;
233
+ }
234
+
235
+ if (!deviceAccessory) {
236
+ this.log.info(
237
+ "Creating new thermostat for serial number: " +
238
+ deviceData.serialNumber,
239
+ );
240
+ const accessory = new PlatformAccessory(deviceData.name, uuid);
241
+ accessory.addService(Service.Thermostat, deviceData.name);
242
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
243
+ accessory,
244
+ ]);
245
+ deviceAccessory = accessory;
246
+ this.accessories.push({ uuid });
247
+ }
248
+
249
+ this.accessories.find(
250
+ (accessory) => accessory.uuid === uuid,
251
+ ).accessory = new NuHeatThermostat(
252
+ this.log,
253
+ deviceData,
254
+ this.config.holdLength,
255
+ deviceAccessory instanceof NuHeatThermostat
256
+ ? deviceAccessory.accessory
257
+ : deviceAccessory,
258
+ this.NuHeatAPI,
259
+ Homebridge,
260
+ );
261
+ this.accessories.find(
262
+ (accessory) => accessory.uuid === uuid,
263
+ ).existsInConfig = true;
264
+ this.log.info(
265
+ "Loaded thermostat " +
266
+ deviceData.serialNumber +
267
+ " " +
268
+ deviceData.name,
269
+ );
270
+ this.accessories
271
+ .find((accessory) => accessory.uuid === uuid)
272
+ .accessory.updateValues(deviceData);
273
+ }),
274
+ );
275
+ }
276
+
277
+ cleanupRemovedAccessories() {
278
+ this.accessories.forEach(function (thisAccessory) {
279
+ if (thisAccessory.existsInConfig !== true) {
280
+ try {
281
+ this.log.info(
282
+ "Deleting removed accessory",
283
+ thisAccessory.accessory
284
+ .getService(Service.AccessoryInformation)
285
+ .getCharacteristic(Characteristic.Name)
286
+ .getValue(),
287
+ );
288
+ } catch {
289
+ this.log.info("Deleting removed accessory");
290
+ }
291
+
292
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
293
+ thisAccessory.accessory,
294
+ ]);
295
+ }
296
+ }, this);
297
+ }
298
+
299
+ async refreshAccessories() {
300
+ await this.refreshGroups();
301
+ await this.refreshThermostats();
302
+ }
303
+
304
+ async refreshGroups() {
305
+ this.log.debug("Trying to refresh groups.");
306
+ const response = await this.NuHeatAPI.refreshGroups();
307
+
308
+ if (!response) {
309
+ this.log.error("Error getting data from NuHeatAPI in group refresh");
310
+ return false;
311
+ }
312
+
313
+ response.forEach(function (deviceData) {
314
+ const thisAccessory = this.accessories.find(
315
+ (accessory) =>
316
+ accessory.uuid === UUIDGen.generate(deviceData.groupId.toString()),
317
+ );
318
+ if (thisAccessory) {
319
+ thisAccessory.accessory.updateValues(deviceData);
320
+ }
321
+ }, this);
322
+
323
+ return true;
324
+ }
325
+
326
+ async refreshThermostats() {
327
+ this.log.debug("Trying to refresh thermostats.");
328
+ const response = await this.NuHeatAPI.refreshThermostats();
329
+
330
+ if (!response) {
331
+ this.log.error("Error getting data from NuHeatAPI in thermostat refresh");
332
+ return false;
333
+ }
334
+
335
+ response.forEach(function (deviceData) {
336
+ const thisAccessory = this.accessories.find(
337
+ (accessory) =>
338
+ accessory.uuid ===
339
+ UUIDGen.generate(deviceData.serialNumber.toString()),
340
+ );
341
+ if (thisAccessory) {
342
+ thisAccessory.accessory.updateValues(deviceData);
343
+ }
344
+ }, this);
345
+
346
+ return true;
347
+ }
348
+
349
+ teardown() {
350
+ if (this.refreshTimer) {
351
+ clearInterval(this.refreshTimer);
352
+ this.refreshTimer = null;
353
+ }
354
+
355
+ if (this.NuHeatListener) {
356
+ this.NuHeatListener.disconnect();
357
+ this.NuHeatListener = null;
358
+ }
359
+ }
360
+ }