homebridge-nuheat2 1.2.16 → 1.2.18

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,21 @@ All notable changes to this project should be documented in this file
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.18] - 2026-04-29
8
+
9
+ ### Changed
10
+
11
+ - Remove user-facing Advanced OAuth settings from the Homebridge schema, custom UI, and README now that Nuheat provides a built-in PKCE public client for this plugin
12
+ - Remove the editable Platform Name field from the custom admin UI while keeping the config key supported for backward compatibility
13
+ - Improve Accessories allow-list rendering so saved thermostat and group rows are normalized and displayed as clearly editable rows
14
+
15
+ ## [1.2.17] - 2026-04-29
16
+
17
+ ### Fixed
18
+
19
+ - Treat the built-in Nuheat public client ID as PKCE-only even when it is explicitly saved in Advanced OAuth settings
20
+ - Ignore and clear stale saved client secrets when users return to the built-in PKCE public client
21
+
7
22
  ## [1.2.16] - 2026-04-29
8
23
 
9
24
  ### Changed
package/README.md CHANGED
@@ -18,7 +18,6 @@ This project builds on the original [`senorshaun/homebridge-nuheat`](https://git
18
18
  - Uses the official Nuheat PKCE public client by default, with no distributable client secret
19
19
  - Includes compatibility improvements for Homebridge 1.8+ and 2.0 betas
20
20
  - Can optionally expose a schedule switch for each thermostat
21
- - Allows advanced OAuth overrides for long-term API stability
22
21
 
23
22
  ## Compatibility
24
23
 
@@ -45,9 +44,9 @@ The published package name for this maintained fork is `homebridge-nuheat2`. The
45
44
 
46
45
  ## Configuration
47
46
 
48
- Most users should configure the plugin through the custom Homebridge admin UI. It is organized into Account, Accessories, Behavior, Advanced OAuth, and Diagnostics panels and writes the same config keys shown below.
47
+ Most users should configure the plugin through the custom Homebridge admin UI. It is organized into Account, Accessories, Behavior, and Diagnostics panels and writes the same config keys shown below.
49
48
 
50
- Sensitive values are handled deliberately: saved passwords and legacy client secrets are not redisplayed in the UI. Leave those fields blank to keep the saved value, enter a new value to replace it, or use the Clear Overrides button in Advanced OAuth to remove OAuth overrides.
49
+ Sensitive values are handled deliberately: saved passwords are not redisplayed in the UI. Leave the password field blank to keep the saved value, or enter a new value to replace it.
51
50
 
52
51
  The Diagnostics panel summarizes the saved configuration and exposure strategy before restart. It does not make live Nuheat API calls.
53
52
 
@@ -90,9 +89,6 @@ The equivalent JSON looks like this:
90
89
  - `refresh`: Poll interval in seconds, default `60`. Values lower than `30` are raised to `30` to reduce API traffic
91
90
  - `enableNotifications`: Enables Nuheat SignalR notifications for faster updates. Defaults to `true`; set to `false` only while troubleshooting
92
91
  - `debug`: Enables verbose Nuheat API, notification, and accessory logging. Defaults to `false`
93
- - `clientId`: Optional advanced override for the Nuheat OAuth client ID. Leave blank to use the built-in PKCE public client ID, `homebridge-nuheat2_260421`
94
- - `clientSecret`: Optional legacy OAuth client secret override for confidential-client credentials. Leave blank for the PKCE public-client flow. Do not publish or share this value
95
- - `redirectUri`: Optional advanced override for the Nuheat OAuth redirect URI, default `http://localhost`
96
92
 
97
93
  ### Hold Length Behavior
98
94
 
@@ -115,9 +111,7 @@ Nuheat's public OpenAPI documentation indicates that third-party developers shou
115
111
  - [Nuheat OpenAPI docs](https://api.mynuheat.com/)
116
112
  - [Nuheat API access request page](https://www.nuheat.com/openapi)
117
113
 
118
- This fork uses Nuheat's PKCE-based public client for normal authentication. The built-in public `clientId` is `homebridge-nuheat2_260421`; there is no distributable client secret.
119
-
120
- Legacy confidential-client credentials can still be supplied with `clientId` and `clientSecret` for testing alternate Nuheat-issued clients. Keep any `clientSecret` out of GitHub, npm, screenshots, and shared logs.
114
+ This fork uses Nuheat's PKCE-based public client for normal authentication. The built-in public `clientId` is `homebridge-nuheat2_260421`; there is no distributable client secret and no API key setup is required.
121
115
 
122
116
  ## What's New In This Fork
123
117
 
@@ -121,23 +121,6 @@
121
121
  "type": "boolean",
122
122
  "default": false,
123
123
  "description": "Write detailed Nuheat API and accessory activity to the Homebridge log."
124
- },
125
- "clientId": {
126
- "title": "Nuheat Client ID",
127
- "type": "string",
128
- "description": "Advanced OAuth override. Leave blank to use the built-in PKCE public client ID."
129
- },
130
- "clientSecret": {
131
- "title": "Nuheat Client Secret (Legacy)",
132
- "type": "string",
133
- "format": "password",
134
- "description": "Legacy OAuth override for confidential-client credentials. Leave blank for the PKCE public-client flow. Do not publish or share this value."
135
- },
136
- "redirectUri": {
137
- "title": "Nuheat Redirect URI",
138
- "type": "string",
139
- "default": "http://localhost",
140
- "description": "Advanced OAuth redirect URI. The default is http://localhost."
141
124
  }
142
125
  }
143
126
  },
@@ -171,18 +154,6 @@
171
154
  "enableNotifications",
172
155
  "debug"
173
156
  ]
174
- },
175
- {
176
- "type": "fieldset",
177
- "title": "Advanced OAuth",
178
- "expandable": true,
179
- "expanded": false,
180
- "description": "Only change these fields when testing alternate Nuheat-issued API credentials.",
181
- "items": [
182
- "clientId",
183
- "clientSecret",
184
- "redirectUri"
185
- ]
186
157
  }
187
158
  ],
188
159
  "form": null,
@@ -22,12 +22,6 @@
22
22
  </div>
23
23
 
24
24
  <div class="settings-grid">
25
- <label class="field">
26
- <span>Platform Name</span>
27
- <input id="name" type="text" placeholder="NuHeat" />
28
- <small>Name shown in Homebridge logs for this platform instance.</small>
29
- </label>
30
-
31
25
  <label class="field">
32
26
  <span>MyNuheat Email</span>
33
27
  <input id="email" type="email" placeholder="name@example.com" />
@@ -168,56 +162,6 @@
168
162
  </div>
169
163
  </section>
170
164
 
171
- <section class="panel">
172
- <div class="section-header">
173
- <div>
174
- <h2>Advanced OAuth</h2>
175
- <p class="help">
176
- Leave these fields blank to use the built-in Nuheat PKCE public
177
- client. Only change them when testing alternate Nuheat-issued API
178
- credentials.
179
- </p>
180
- </div>
181
- <span id="oauth-status" class="status-pill good">Built-in PKCE</span>
182
- </div>
183
-
184
- <div class="settings-grid">
185
- <label class="field">
186
- <span>Nuheat Client ID</span>
187
- <input id="client-id" type="text" placeholder="Optional client ID" />
188
- <small>
189
- Advanced override. Blank uses homebridge-nuheat2_260421.
190
- </small>
191
- </label>
192
-
193
- <label id="client-secret-row" class="field">
194
- <span>Nuheat Client Secret (Legacy)</span>
195
- <input
196
- id="client-secret"
197
- type="password"
198
- placeholder="Leave blank to keep existing"
199
- />
200
- <small>
201
- Leave blank for PKCE public-client auth. Only use this for legacy
202
- confidential-client credentials.
203
- </small>
204
- </label>
205
-
206
- <label class="field">
207
- <span>Nuheat Redirect URI</span>
208
- <input id="redirect-uri" type="text" placeholder="http://localhost" />
209
- <small>Defaults to http://localhost.</small>
210
- </label>
211
- </div>
212
-
213
- <div class="actions">
214
- <button id="save-oauth" class="primary">Save OAuth Settings</button>
215
- <button id="clear-oauth" class="secondary" type="button">
216
- Clear Overrides
217
- </button>
218
- </div>
219
- </section>
220
-
221
165
  <section class="panel">
222
166
  <div class="section-header">
223
167
  <div>
@@ -1,7 +1,6 @@
1
1
  const PLATFORM_NAME = "NuHeat";
2
2
 
3
3
  const elements = {
4
- name: document.getElementById("name"),
5
4
  email: document.getElementById("email"),
6
5
  password: document.getElementById("password"),
7
6
  devicesList: document.getElementById("devices-list"),
@@ -14,20 +13,14 @@ const elements = {
14
13
  refresh: document.getElementById("refresh"),
15
14
  enableNotifications: document.getElementById("enable-notifications"),
16
15
  debug: document.getElementById("debug"),
17
- clientId: document.getElementById("client-id"),
18
- clientSecret: document.getElementById("client-secret"),
19
- redirectUri: document.getElementById("redirect-uri"),
20
16
  saveAccount: document.getElementById("save-account"),
21
17
  addDevice: document.getElementById("add-device"),
22
18
  addGroup: document.getElementById("add-group"),
23
19
  saveAccessories: document.getElementById("save-accessories"),
24
20
  saveBehavior: document.getElementById("save-behavior"),
25
- saveOauth: document.getElementById("save-oauth"),
26
- clearOauth: document.getElementById("clear-oauth"),
27
21
  authStatus: document.getElementById("auth-status"),
28
22
  accessoryStatus: document.getElementById("accessory-status"),
29
23
  behaviorStatus: document.getElementById("behavior-status"),
30
- oauthStatus: document.getElementById("oauth-status"),
31
24
  toastContainer: document.getElementById("toast-container"),
32
25
  refreshDiagnostics: document.getElementById("refresh-diagnostics"),
33
26
  diagnosticsSummary: document.getElementById("diagnostics-summary"),
@@ -39,7 +32,6 @@ const state = {
39
32
  configs: [],
40
33
  config: null,
41
34
  hasPassword: false,
42
- hasClientSecret: false,
43
35
  };
44
36
 
45
37
  function showToast(type, message) {
@@ -117,7 +109,6 @@ function createDefaultConfig() {
117
109
  }
118
110
 
119
111
  function renderConfig(config) {
120
- elements.name.value = config.name || "NuHeat";
121
112
  elements.email.value = config.email || config.Email || "";
122
113
  elements.password.value = "";
123
114
  state.hasPassword = Boolean(config.password);
@@ -137,25 +128,13 @@ function renderConfig(config) {
137
128
  elements.enableNotifications.checked = config.enableNotifications !== false;
138
129
  elements.debug.checked = Boolean(config.debug);
139
130
 
140
- elements.clientId.value = config.clientId || "";
141
- elements.clientSecret.value = "";
142
- state.hasClientSecret = Boolean(config.clientSecret);
143
- elements.clientSecret.placeholder = state.hasClientSecret
144
- ? "Saved secret (leave blank to keep)"
145
- : "Optional client secret";
146
- elements.redirectUri.value = config.redirectUri || "http://localhost";
147
-
148
131
  updateStatuses();
149
132
  renderDiagnostics();
150
133
  }
151
134
 
152
135
  function renderRows(container, type, items) {
153
136
  container.innerHTML = "";
154
- const normalizedItems = Array.isArray(items) ? items : [];
155
- const visibleItems = normalizedItems.filter((item) => {
156
- const value = type === "device" ? item.serialNumber : item.groupName;
157
- return typeof value === "string" && value.trim().length > 0;
158
- });
137
+ const visibleItems = normalizeConfigRows(type, items);
159
138
 
160
139
  if (visibleItems.length === 0) {
161
140
  const empty = document.createElement("div");
@@ -172,12 +151,44 @@ function renderRows(container, type, items) {
172
151
  addRow(
173
152
  container,
174
153
  type,
175
- type === "device" ? item.serialNumber : item.groupName,
176
- Boolean(item.disabled),
154
+ item.value,
155
+ item.disabled,
177
156
  );
178
157
  });
179
158
  }
180
159
 
160
+ function normalizeConfigRows(type, items) {
161
+ const normalizedItems = Array.isArray(items) ? items : [];
162
+ return normalizedItems
163
+ .map((item) => ({
164
+ value: getConfigRowValue(type, item),
165
+ disabled: typeof item === "object" && item !== null
166
+ ? Boolean(item.disabled)
167
+ : false,
168
+ }))
169
+ .filter((item) => item.value.length > 0);
170
+ }
171
+
172
+ function getConfigRowValue(type, item) {
173
+ if (typeof item === "string" || typeof item === "number") {
174
+ return String(item).trim();
175
+ }
176
+
177
+ if (!item || typeof item !== "object") {
178
+ return "";
179
+ }
180
+
181
+ const keys = type === "device"
182
+ ? ["serialNumber", "SerialNumber", "serial", "Serial", "deviceId", "DeviceId"]
183
+ : ["groupName", "GroupName", "name", "Name"];
184
+
185
+ const value = keys
186
+ .map((key) => item[key])
187
+ .find((candidate) => typeof candidate === "string" || typeof candidate === "number");
188
+
189
+ return value === undefined ? "" : String(value).trim();
190
+ }
191
+
181
192
  function addRow(container, type, value = "", disabled = false) {
182
193
  const empty = container.querySelector(".empty-list");
183
194
  if (empty) {
@@ -243,7 +254,6 @@ function collectRows(container, key) {
243
254
  }
244
255
 
245
256
  async function saveAccount() {
246
- const name = elements.name.value.trim() || "NuHeat";
247
257
  const email = elements.email.value.trim();
248
258
  const password = elements.password.value;
249
259
 
@@ -261,7 +271,6 @@ async function saveAccount() {
261
271
 
262
272
  const patch = {
263
273
  platform: PLATFORM_NAME,
264
- name,
265
274
  email,
266
275
  Email: undefined,
267
276
  };
@@ -309,55 +318,6 @@ async function saveBehavior() {
309
318
  showToast("success", "Behavior settings saved.");
310
319
  }
311
320
 
312
- async function saveOauth() {
313
- const clientId = elements.clientId.value.trim();
314
- const clientSecret = elements.clientSecret.value;
315
- const redirectUri = elements.redirectUri.value.trim();
316
-
317
- if (!clientId && clientSecret) {
318
- showToast("error", "A client secret requires a Nuheat client ID.");
319
- elements.clientId.focus();
320
- return;
321
- }
322
-
323
- const patch = {
324
- clientId: clientId || undefined,
325
- redirectUri: redirectUri && redirectUri !== "http://localhost" ? redirectUri : undefined,
326
- };
327
-
328
- if (clientSecret) {
329
- patch.clientSecret = clientSecret;
330
- } else if (!clientId || clientId !== (state.config?.clientId || "")) {
331
- patch.clientSecret = undefined;
332
- }
333
-
334
- await persistPatch(patch);
335
- if (clientSecret) {
336
- state.hasClientSecret = true;
337
- elements.clientSecret.value = "";
338
- elements.clientSecret.placeholder = "Saved secret (leave blank to keep)";
339
- updateStatuses();
340
- renderDiagnostics();
341
- }
342
- showToast("success", "OAuth settings saved.");
343
- }
344
-
345
- async function clearOauth() {
346
- await persistPatch({
347
- clientId: undefined,
348
- clientSecret: undefined,
349
- redirectUri: undefined,
350
- });
351
- state.hasClientSecret = false;
352
- elements.clientId.value = "";
353
- elements.clientSecret.value = "";
354
- elements.clientSecret.placeholder = "Optional client secret";
355
- elements.redirectUri.value = "http://localhost";
356
- updateStatuses();
357
- renderDiagnostics();
358
- showToast("success", "OAuth overrides cleared.");
359
- }
360
-
361
321
  async function persistPatch(patch) {
362
322
  await withSpinner(async () => {
363
323
  if (!state.config) {
@@ -390,7 +350,6 @@ function updateStatuses() {
390
350
  updateAuthStatus();
391
351
  updateAccessoryStatus();
392
352
  updateBehaviorStatus();
393
- updateOauthStatus();
394
353
  }
395
354
 
396
355
  function updateAuthStatus() {
@@ -436,26 +395,6 @@ function updateBehaviorStatus() {
436
395
  );
437
396
  }
438
397
 
439
- function updateOauthStatus() {
440
- const hasClientId = elements.clientId.value.trim().length > 0;
441
- const hasClientSecret = hasUsableClientSecret(elements.clientId.value.trim());
442
- const text = hasClientId
443
- ? hasClientSecret
444
- ? "Custom legacy OAuth"
445
- : "Custom PKCE"
446
- : "Built-in PKCE";
447
- setStatus(elements.oauthStatus, true, text);
448
- }
449
-
450
- function hasUsableClientSecret(clientId) {
451
- if (elements.clientSecret.value) {
452
- return true;
453
- }
454
-
455
- const savedClientId = state.config?.clientId || "";
456
- return Boolean(clientId && state.hasClientSecret && clientId === savedClientId);
457
- }
458
-
459
398
  function setStatus(element, isGood, text) {
460
399
  element.textContent = text;
461
400
  element.classList.toggle("good", Boolean(isGood));
@@ -478,10 +417,9 @@ function renderDiagnostics() {
478
417
 
479
418
  addDiagnosticCard("Account", [
480
419
  ["Platform", PLATFORM_NAME],
481
- ["Name", config.name || "NuHeat"],
482
420
  ["Email", config.email || "not configured"],
483
421
  ["Password", state.hasPassword || elements.password.value ? "saved" : "missing"],
484
- ["OAuth Mode", getOauthMode(config)],
422
+ ["API Access", "built-in Nuheat PKCE client"],
485
423
  ]);
486
424
 
487
425
  addDiagnosticCard("Accessories", [
@@ -527,7 +465,6 @@ function addDiagnosticCard(title, rows) {
527
465
  function getDraftConfig() {
528
466
  return {
529
467
  ...(state.config || createDefaultConfig()),
530
- name: elements.name.value.trim() || "NuHeat",
531
468
  email: elements.email.value.trim(),
532
469
  devices: collectRows(elements.devicesList, "serialNumber"),
533
470
  groups: collectRows(elements.groupsList, "groupName"),
@@ -539,8 +476,6 @@ function getDraftConfig() {
539
476
  refresh: normalizeRefresh(elements.refresh.value),
540
477
  enableNotifications: Boolean(elements.enableNotifications.checked),
541
478
  debug: Boolean(elements.debug.checked),
542
- clientId: elements.clientId.value.trim(),
543
- redirectUri: elements.redirectUri.value.trim() || "http://localhost",
544
479
  };
545
480
  }
546
481
 
@@ -576,16 +511,6 @@ function getHoldSummary(value) {
576
511
  return `${holdLength} minute timed hold`;
577
512
  }
578
513
 
579
- function getOauthMode(config) {
580
- if (config.clientId && hasUsableClientSecret(config.clientId)) {
581
- return "configured confidential client";
582
- }
583
- if (config.clientId) {
584
- return "configured PKCE public client";
585
- }
586
- return "built-in PKCE public client";
587
- }
588
-
589
514
  function normalizeHoldLength(value) {
590
515
  const parsed = Number.parseInt(value, 10);
591
516
  if (!Number.isFinite(parsed)) {
@@ -627,12 +552,6 @@ function bindEvents() {
627
552
  elements.saveBehavior.addEventListener("click", () => {
628
553
  saveBehavior().catch(() => showToast("error", "Failed to save behavior."));
629
554
  });
630
- elements.saveOauth.addEventListener("click", () => {
631
- saveOauth().catch(() => showToast("error", "Failed to save OAuth settings."));
632
- });
633
- elements.clearOauth.addEventListener("click", () => {
634
- clearOauth().catch(() => showToast("error", "Failed to clear OAuth settings."));
635
- });
636
555
  elements.addDevice.addEventListener("click", () => {
637
556
  addRow(elements.devicesList, "device");
638
557
  });
@@ -642,7 +561,6 @@ function bindEvents() {
642
561
  elements.refreshDiagnostics.addEventListener("click", renderDiagnostics);
643
562
 
644
563
  [
645
- elements.name,
646
564
  elements.email,
647
565
  elements.password,
648
566
  elements.autoPopulateAwayModeSwitches,
@@ -651,9 +569,6 @@ function bindEvents() {
651
569
  elements.refresh,
652
570
  elements.enableNotifications,
653
571
  elements.debug,
654
- elements.clientId,
655
- elements.clientSecret,
656
- elements.redirectUri,
657
572
  ].forEach((element) => {
658
573
  element.addEventListener("input", () => {
659
574
  updateStatuses();
@@ -295,7 +295,15 @@ button.secondary:hover {
295
295
  display: grid;
296
296
  grid-template-columns: minmax(0, 1fr) auto;
297
297
  gap: 10px;
298
- align-items: center;
298
+ align-items: end;
299
+ background: var(--plugin-control-surface);
300
+ border: 1px solid var(--plugin-border);
301
+ border-radius: 8px;
302
+ padding: 12px;
303
+ }
304
+
305
+ .config-row .field {
306
+ min-width: 0;
299
307
  }
300
308
 
301
309
  .row-checkbox {
package/lib/NuHeatAPI.js CHANGED
@@ -33,15 +33,16 @@ class NuHeatAPI {
33
33
  this.log = log;
34
34
  const configuredClientId = options.clientId || process.env.NUHEAT_API_CLIENT_ID || "";
35
35
  const configuredClientSecret = options.clientSecret || process.env.NUHEAT_API_CLIENT_SECRET || "";
36
+ const usingBuiltInPublicClient = !configuredClientId || configuredClientId === settings_1.NUHEAT_API_CLIENT_ID;
36
37
  this.oauthClientId = configuredClientId || settings_1.NUHEAT_API_CLIENT_ID;
37
- this.oauthClientSecret = configuredClientId
38
- ? configuredClientSecret || settings_1.NUHEAT_API_CLIENT_SECRET
39
- : "";
38
+ this.oauthClientSecret = usingBuiltInPublicClient
39
+ ? ""
40
+ : configuredClientSecret || settings_1.NUHEAT_API_CLIENT_SECRET;
40
41
  this.oauthRedirectUri =
41
42
  options.redirectUri ||
42
43
  process.env.NUHEAT_API_REDIRECT_URI ||
43
44
  settings_1.NUHEAT_API_REDIRECT_URI;
44
- this.usingBuiltInClient = !configuredClientId;
45
+ this.usingBuiltInClient = usingBuiltInPublicClient;
45
46
  this.usePkce = !this.oauthClientSecret;
46
47
  this.pkceCodeVerifier = "";
47
48
  this.headers = new HeadersCtor();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-nuheat2",
3
- "version": "1.2.16",
3
+ "version": "1.2.18",
4
4
  "description": "Homebridge Platform for NuHeat Signature Thermostats",
5
5
  "main": "index.js",
6
6
  "scripts": {