homebridge-nuheat2 1.2.10 → 1.2.12

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 CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project should be documented in this file
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.12] - 2026-04-21
8
+
9
+ ### Fixed
10
+
11
+ - Treat Nuheat thermostats as heat-only in HomeKit so the target mode no longer advertises a misleading off option
12
+ - Translate any incoming HomeKit off request into the minimum Nuheat setpoint instead of bouncing back after refresh
13
+ - Apply the Nuheat account temperature scale to the thermostat display-units characteristic so HomeKit shows the correct hardware display unit
14
+ - Coalesce and briefly de-duplicate repeated SignalR thermostat and group notifications to reduce redundant full refreshes and log noise
15
+
16
+ ## [1.2.11] - 2026-04-21
17
+
18
+ ### Fixed
19
+
20
+ - Relax the optional `devices` and `groups` array item schema so Homebridge Config UI no longer shows validation errors for blank rows
21
+ - Ignore blank `devices` rows at runtime so they do not interfere with thermostat auto-discovery
22
+
7
23
  ## [1.2.10] - 2026-04-21
8
24
 
9
25
  ### Added
package/README.md CHANGED
@@ -67,11 +67,11 @@ Most users should configure the plugin through Homebridge Config UI X, but the e
67
67
  - `email`: MyNuheat account email address
68
68
  - `Email`: Legacy alias still accepted for backward compatibility, but `email` is the preferred documented field
69
69
  - `password`: MyNuheat account password
70
- - `devices`: Optional list of thermostats to expose. If omitted or empty, every thermostat on the account will be discovered automatically
70
+ - `devices`: Optional list of thermostats to expose. If omitted or empty, every thermostat on the account will be discovered automatically. Blank rows in the UI are ignored
71
71
  - `serialNumber`: Thermostat serial number from MyNuheat
72
72
  - `autoPopulateAwayModeSwitches`: Automatically expose away-mode switches for all groups on the account
73
73
  - `exposeScheduleSwitches`: Optionally expose a switch per thermostat that reflects whether the thermostat is following its schedule and can be turned on to resume the schedule
74
- - `groups`: Optional allow-list of groups to expose as away-mode switches. This only affects group/away-mode accessories
74
+ - `groups`: Optional allow-list of groups to expose as away-mode switches. This only affects group/away-mode accessories. Blank rows in the UI are ignored
75
75
  - `groupName`: Group name as shown in MyNuheat
76
76
  - `holdLength`: Hold duration in minutes
77
77
  - `refresh`: Poll interval in seconds, default `60`
@@ -138,10 +138,12 @@ npm test
138
138
  GitHub Actions now handles two jobs for this repository:
139
139
 
140
140
  - `.github/workflows/ci.yml` runs `npm ci`, `npm run typecheck`, and `npm test` on pushes and pull requests across Node 20, 22, and 24
141
- - `.github/workflows/publish.yml` runs on pushes to `master` when `package.json` changes, re-runs the checks on Node 24, and publishes to npm only when the `package.json` version is not already on the registry
141
+ - `.github/workflows/publish.yml` runs on pushes to `master` when `package.json` changes, re-runs the checks on Node 24, publishes to npm only when the `package.json` version is not already on the registry, and creates or updates the matching GitHub Release
142
142
 
143
143
  The publish workflow also maps prerelease versions to npm dist-tags automatically. For example, `1.2.7-beta.1` publishes with the `beta` tag, while stable versions publish to `latest`.
144
144
 
145
+ Release notes are expected in `docs/release-notes/<version>.md`. The publish workflow will fail if that file is missing for the version in `package.json`, which makes the GitHub Release step part of the normal release checklist instead of a manual follow-up.
146
+
145
147
  ### Recommended npm Setup
146
148
 
147
149
  Use npm trusted publishing rather than a long-lived automation token.
@@ -43,11 +43,9 @@
43
43
  "devices": {
44
44
  "title": "Devices",
45
45
  "type": "array",
46
+ "description": "Optional allow-list of thermostats to expose. Leave empty to auto-discover all thermostats on the account.",
46
47
  "items": {
47
48
  "type": "object",
48
- "required": [
49
- "serialNumber"
50
- ],
51
49
  "properties": {
52
50
  "serialNumber": {
53
51
  "title": "Serial Number",
@@ -72,11 +70,9 @@
72
70
  "groups": {
73
71
  "title": "Groups",
74
72
  "type": "array",
73
+ "description": "Optional allow-list of groups to expose as away-mode switches. Leave empty unless you want to add specific groups manually.",
75
74
  "items": {
76
75
  "type": "object",
77
- "required": [
78
- "groupName"
79
- ],
80
76
  "properties": {
81
77
  "groupName": {
82
78
  "title": "Group Name",
package/index.js CHANGED
@@ -74,6 +74,11 @@ class NuHeatPlatform {
74
74
  typeof group.groupName === "string" &&
75
75
  group.groupName.trim().length > 0);
76
76
  }
77
+ getConfiguredDevices() {
78
+ return (this.config.devices || []).filter((device) => !!device &&
79
+ typeof device.serialNumber === "string" &&
80
+ device.serialNumber.trim().length > 0);
81
+ }
77
82
  shouldManageGroups() {
78
83
  return (!!this.config.autoPopulateAwayModeSwitches ||
79
84
  this.getConfiguredGroups().length > 0);
@@ -154,7 +159,7 @@ class NuHeatPlatform {
154
159
  }));
155
160
  }
156
161
  async setupThermostats() {
157
- const deviceArray = this.config.devices || [];
162
+ const deviceArray = this.getConfiguredDevices();
158
163
  const response = await this.NuHeatAPI.refreshThermostats();
159
164
  if (!response || !Array.isArray(response)) {
160
165
  this.log.error("Error getting data from NuHeatAPI");
@@ -184,7 +189,7 @@ class NuHeatPlatform {
184
189
  entry = { uuid };
185
190
  this.accessories.push(entry);
186
191
  }
187
- entry.accessory = new NuHeatThermostat(this.log, deviceData, this.config.holdLength || 1440, getPlatformAccessory(deviceAccessory), this.NuHeatAPI, Homebridge);
192
+ entry.accessory = new NuHeatThermostat(this.log, deviceData, this.config.holdLength || 1440, getPlatformAccessory(deviceAccessory), this.NuHeatAPI, Homebridge, this.account?.temperatureScale);
188
193
  entry.existsInConfig = true;
189
194
  this.log.info("Loaded thermostat " +
190
195
  deviceData.serialNumber +
@@ -1,16 +1,19 @@
1
1
  "use strict";
2
2
  const signalR = require("@microsoft/signalr");
3
+ const NOTIFICATION_DEDUPE_WINDOW_MS = 2000;
3
4
  class NuHeatListener {
4
5
  nuHeatAPI;
5
6
  nuHeatPlatform;
6
7
  log;
7
8
  notificationTypes;
8
9
  connection;
10
+ recentNotifications;
9
11
  constructor(nuHeatAPI, nuheatPlatform) {
10
12
  this.nuHeatAPI = nuHeatAPI;
11
13
  this.nuHeatPlatform = nuheatPlatform;
12
14
  this.log = nuheatPlatform.log;
13
15
  this.notificationTypes = ["2", "4"];
16
+ this.recentNotifications = new Map();
14
17
  this.connection = new signalR.HubConnectionBuilder()
15
18
  .withUrl("https://api.mynuheat.com/notificationsHost", {
16
19
  accessTokenFactory: async () => {
@@ -73,8 +76,10 @@ class NuHeatListener {
73
76
  });
74
77
  }
75
78
  traceNotification(notificationList) {
79
+ let shouldRefreshThermostats = false;
80
+ let shouldRefreshGroups = false;
76
81
  notificationList.forEach((notification) => {
77
- let notificationType = "";
82
+ let notificationType = "Unknown";
78
83
  switch (notification.type) {
79
84
  case 0:
80
85
  case 1:
@@ -82,15 +87,15 @@ class NuHeatListener {
82
87
  break;
83
88
  case 2:
84
89
  notificationType = "Thermostat";
85
- void this.nuHeatPlatform.refreshThermostats();
90
+ shouldRefreshThermostats = shouldRefreshThermostats || !this.isDuplicateNotification(notification);
86
91
  break;
87
92
  case 3:
88
93
  notificationType = "Schedule";
89
- void this.nuHeatPlatform.refreshThermostats();
94
+ shouldRefreshThermostats = shouldRefreshThermostats || !this.isDuplicateNotification(notification);
90
95
  break;
91
96
  case 4:
92
97
  notificationType = "Group";
93
- void this.nuHeatPlatform.refreshGroups();
98
+ shouldRefreshGroups = shouldRefreshGroups || !this.isDuplicateNotification(notification);
94
99
  break;
95
100
  }
96
101
  this.log.debug(notificationType +
@@ -98,10 +103,38 @@ class NuHeatListener {
98
103
  notification.id +
99
104
  " at " +
100
105
  notification.timeStamp +
101
- ". Refreshing data for all " +
102
- notificationType +
103
- "s");
106
+ ".");
104
107
  });
108
+ if (shouldRefreshThermostats) {
109
+ void this.nuHeatPlatform.refreshThermostats();
110
+ }
111
+ if (shouldRefreshGroups) {
112
+ void this.nuHeatPlatform.refreshGroups();
113
+ }
114
+ }
115
+ isDuplicateNotification(notification) {
116
+ const key = String(notification.type) +
117
+ ":" +
118
+ String(notification.id) +
119
+ ":" +
120
+ notification.timeStamp;
121
+ const now = Date.now();
122
+ this.cleanupRecentNotifications(now);
123
+ const lastSeenAt = this.recentNotifications.get(key);
124
+ if (lastSeenAt !== undefined &&
125
+ now - lastSeenAt < NOTIFICATION_DEDUPE_WINDOW_MS) {
126
+ this.log.debug("Ignoring duplicate notification " + key + ".");
127
+ return true;
128
+ }
129
+ this.recentNotifications.set(key, now);
130
+ return false;
131
+ }
132
+ cleanupRecentNotifications(now) {
133
+ for (const [key, seenAt] of this.recentNotifications.entries()) {
134
+ if (now - seenAt >= NOTIFICATION_DEDUPE_WINDOW_MS) {
135
+ this.recentNotifications.delete(key);
136
+ }
137
+ }
105
138
  }
106
139
  }
107
140
  module.exports = NuHeatListener;
@@ -2,13 +2,16 @@
2
2
  const NuHeatModels_1 = require("./NuHeatModels");
3
3
  let Characteristic;
4
4
  let ThermostatService;
5
+ const MIN_TARGET_TEMPERATURE_C = 10;
6
+ const MAX_TARGET_TEMPERATURE_C = 38;
5
7
  class NuHeatThermostat {
6
8
  log;
7
9
  deviceData;
8
10
  holdLength;
9
11
  accessory;
10
12
  NuHeatAPI;
11
- constructor(log, deviceData, holdLength, accessory, NuHeatAPI, homebridge) {
13
+ temperatureScale;
14
+ constructor(log, deviceData, holdLength, accessory, NuHeatAPI, homebridge, temperatureScale) {
12
15
  Characteristic = homebridge.hap.Characteristic;
13
16
  ThermostatService = homebridge.hap.Service.Thermostat;
14
17
  this.log = log;
@@ -16,6 +19,7 @@ class NuHeatThermostat {
16
19
  this.holdLength = holdLength;
17
20
  this.accessory = accessory;
18
21
  this.NuHeatAPI = NuHeatAPI;
22
+ this.temperatureScale = temperatureScale;
19
23
  this.accessory
20
24
  .getService(homebridge.hap.Service.AccessoryInformation)
21
25
  .setCharacteristic(Characteristic.Manufacturer, "NuHeat")
@@ -25,39 +29,48 @@ class NuHeatThermostat {
25
29
  this.setupListeners();
26
30
  }
27
31
  setupListeners() {
32
+ const thermostatService = this.accessory.getService(ThermostatService);
28
33
  this.log.info("holdLength: " + this.holdLength, this.deviceData.name);
29
- this.accessory
30
- .getService(ThermostatService)
34
+ thermostatService
31
35
  .getCharacteristic(Characteristic.TargetHeatingCoolingState)
32
36
  .setProps({
33
- validValues: [0, 1],
37
+ validValues: [Characteristic.TargetHeatingCoolingState.HEAT],
34
38
  })
35
39
  .on("set", this.setTargetHeatingCooling.bind(this));
36
- this.accessory
37
- .getService(ThermostatService)
38
- .getCharacteristic(Characteristic.CurrentTemperature)
39
- .setProps({
40
+ thermostatService.getCharacteristic(Characteristic.CurrentTemperature).setProps({
40
41
  minValue: -100,
41
42
  maxValue: 100,
42
43
  });
43
- this.accessory
44
- .getService(ThermostatService)
44
+ thermostatService
45
45
  .getCharacteristic(Characteristic.TargetTemperature)
46
46
  .setProps({
47
47
  minStep: 0.5,
48
48
  })
49
49
  .on("set", this.setTargetTemperature.bind(this));
50
+ this.updateTemperatureDisplayUnits();
50
51
  }
51
- setTargetHeatingCooling(_value, callback) {
52
+ async setTargetHeatingCooling(value, callback) {
53
+ if (value !== Characteristic.TargetHeatingCoolingState.OFF) {
54
+ callback(null);
55
+ void this.updateAccessory();
56
+ return;
57
+ }
58
+ this.log.info("NuHeat does not support an off mode. Setting the thermostat to its minimum target temperature instead.", this.deviceData.name);
59
+ const response = await this.NuHeatAPI.setHeatSetpoint(this.deviceData.serialNumber ?? "", this.toNuHeatTemperature(MIN_TARGET_TEMPERATURE_C), this.holdLength);
60
+ if (!response) {
61
+ this.log.error("Error setting minimum target temperature for off request", this.deviceData.name);
62
+ callback(new Error("Error: setTargetHeatingCooling"));
63
+ return;
64
+ }
65
+ this.updateValues(response);
52
66
  callback(null);
53
- void this.updateAccessory();
54
67
  }
55
68
  async setTargetTemperature(value, callback) {
56
- this.log.info("Setting target temperature to " + value + "°C", this.deviceData.name);
57
- if (value < 10)
58
- value = 10;
59
- if (value > 38)
60
- value = 38;
69
+ this.log.info("Setting target temperature to " + value + " C", this.deviceData.name);
70
+ if (value < MIN_TARGET_TEMPERATURE_C)
71
+ value = MIN_TARGET_TEMPERATURE_C;
72
+ if (value > MAX_TARGET_TEMPERATURE_C)
73
+ value = MAX_TARGET_TEMPERATURE_C;
61
74
  const heatSetPoint = this.toNuHeatTemperature(value);
62
75
  this.log.debug("setTargetTemperature " + heatSetPoint, this.deviceData.name);
63
76
  const response = await this.NuHeatAPI.setHeatSetpoint(this.deviceData.serialNumber ?? "", heatSetPoint, this.holdLength);
@@ -81,25 +94,29 @@ class NuHeatThermostat {
81
94
  }
82
95
  updateValues(newValues) {
83
96
  if (this.isOnline(newValues)) {
97
+ this.updateTemperatureDisplayUnits();
84
98
  let currentTemperature = Number(this.toHBTemperature(newValues.currentTemperature ?? 0));
85
- this.log.debug("Current temperature is " + currentTemperature + "°C", this.deviceData.name);
99
+ this.log.debug("Current temperature is " + currentTemperature + " C", this.deviceData.name);
86
100
  this.accessory
87
101
  .getService(ThermostatService)
88
102
  .getCharacteristic(Characteristic.CurrentTemperature)
89
103
  .updateValue(currentTemperature);
90
104
  let setPointTemperature = Number(this.toHBTemperature(newValues.setPointTemp ?? 0));
91
- if (setPointTemperature < 10)
92
- setPointTemperature = 10;
93
- if (setPointTemperature > 38)
94
- setPointTemperature = 38;
95
- this.log.debug("Setpoint temperature is " + setPointTemperature + "°C", this.deviceData.name);
105
+ if (setPointTemperature < MIN_TARGET_TEMPERATURE_C) {
106
+ setPointTemperature = MIN_TARGET_TEMPERATURE_C;
107
+ }
108
+ if (setPointTemperature > MAX_TARGET_TEMPERATURE_C) {
109
+ setPointTemperature = MAX_TARGET_TEMPERATURE_C;
110
+ }
111
+ this.log.debug("Setpoint temperature is " + setPointTemperature + " C", this.deviceData.name);
96
112
  this.accessory
97
113
  .getService(ThermostatService)
98
114
  .getCharacteristic(Characteristic.TargetTemperature)
99
115
  .updateValue(setPointTemperature);
100
- let currentHeatingCoolingState = 0;
116
+ let currentHeatingCoolingState = Characteristic.CurrentHeatingCoolingState.OFF;
101
117
  if (newValues.isHeating) {
102
- currentHeatingCoolingState = 1;
118
+ currentHeatingCoolingState =
119
+ Characteristic.CurrentHeatingCoolingState.HEAT;
103
120
  }
104
121
  this.log.debug("Current heating state is " + currentHeatingCoolingState, this.deviceData.name);
105
122
  this.accessory
@@ -153,5 +170,29 @@ class NuHeatThermostat {
153
170
  }
154
171
  return true;
155
172
  }
173
+ updateTemperatureDisplayUnits() {
174
+ const displayUnits = this.toHomeKitDisplayUnits(this.temperatureScale);
175
+ if (displayUnits === undefined ||
176
+ !Characteristic.TemperatureDisplayUnits) {
177
+ return;
178
+ }
179
+ this.accessory
180
+ .getService(ThermostatService)
181
+ .getCharacteristic(Characteristic.TemperatureDisplayUnits)
182
+ .updateValue(displayUnits);
183
+ }
184
+ toHomeKitDisplayUnits(temperatureScale) {
185
+ if (!temperatureScale || !Characteristic.TemperatureDisplayUnits) {
186
+ return undefined;
187
+ }
188
+ const normalizedScale = temperatureScale.trim().toLowerCase();
189
+ if (normalizedScale.startsWith("f")) {
190
+ return Characteristic.TemperatureDisplayUnits.FAHRENHEIT;
191
+ }
192
+ if (normalizedScale.startsWith("c")) {
193
+ return Characteristic.TemperatureDisplayUnits.CELSIUS;
194
+ }
195
+ return undefined;
196
+ }
156
197
  }
157
198
  module.exports = NuHeatThermostat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-nuheat2",
3
- "version": "1.2.10",
3
+ "version": "1.2.12",
4
4
  "description": "Homebridge Platform for NuHeat Signature Thermostats",
5
5
  "main": "index.js",
6
6
  "scripts": {