homebridge-nuheat2 1.2.11 → 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 +9 -0
- package/index.js +1 -1
- package/lib/NuHeatListener.js +40 -7
- package/lib/NuHeatThermostat.js +66 -25
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ 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
|
+
|
|
7
16
|
## [1.2.11] - 2026-04-21
|
|
8
17
|
|
|
9
18
|
### Fixed
|
package/index.js
CHANGED
|
@@ -189,7 +189,7 @@ class NuHeatPlatform {
|
|
|
189
189
|
entry = { uuid };
|
|
190
190
|
this.accessories.push(entry);
|
|
191
191
|
}
|
|
192
|
-
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);
|
|
193
193
|
entry.existsInConfig = true;
|
|
194
194
|
this.log.info("Loaded thermostat " +
|
|
195
195
|
deviceData.serialNumber +
|
package/lib/NuHeatListener.js
CHANGED
|
@@ -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
|
-
|
|
90
|
+
shouldRefreshThermostats = shouldRefreshThermostats || !this.isDuplicateNotification(notification);
|
|
86
91
|
break;
|
|
87
92
|
case 3:
|
|
88
93
|
notificationType = "Schedule";
|
|
89
|
-
|
|
94
|
+
shouldRefreshThermostats = shouldRefreshThermostats || !this.isDuplicateNotification(notification);
|
|
90
95
|
break;
|
|
91
96
|
case 4:
|
|
92
97
|
notificationType = "Group";
|
|
93
|
-
|
|
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
|
-
".
|
|
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;
|
package/lib/NuHeatThermostat.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
.getService(ThermostatService)
|
|
34
|
+
thermostatService
|
|
31
35
|
.getCharacteristic(Characteristic.TargetHeatingCoolingState)
|
|
32
36
|
.setProps({
|
|
33
|
-
validValues: [
|
|
37
|
+
validValues: [Characteristic.TargetHeatingCoolingState.HEAT],
|
|
34
38
|
})
|
|
35
39
|
.on("set", this.setTargetHeatingCooling.bind(this));
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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 + "
|
|
57
|
-
if (value <
|
|
58
|
-
value =
|
|
59
|
-
if (value >
|
|
60
|
-
value =
|
|
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 + "
|
|
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 <
|
|
92
|
-
setPointTemperature =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
116
|
+
let currentHeatingCoolingState = Characteristic.CurrentHeatingCoolingState.OFF;
|
|
101
117
|
if (newValues.isHeating) {
|
|
102
|
-
currentHeatingCoolingState =
|
|
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;
|