homebridge-tydom 0.30.0 → 0.30.1

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
@@ -236,6 +236,33 @@ Nothing in your config needs to change, and no accessory is re-registered: rooms
236
236
 
237
237
  ## Troubleshooting
238
238
 
239
+ ### Duplicate accessories, or `Cannot serialize accessory`
240
+
241
+ Versions up to v0.30.0 handed each newly discovered accessory to Homebridge for
242
+ caching before registering it, which made the cache write fail:
243
+
244
+ ```text
245
+ Failed to save cached accessories to disk: Cannot serialize accessory 'Salon' - missing associated plugin
246
+ Accessory 'Salon' has the same UUID as existing accessory 'Salon'. Skipping duplicate.
247
+ ```
248
+
249
+ v0.30.1 fixes the cause, and Homebridge rewrites one entry per accessory on the
250
+ first scan after upgrading — so most installations recover on their own after a
251
+ restart. An accessory that was duplicated and then removed from the gateway can
252
+ be left orphaned in the cache, which only a reset clears. If the warnings
253
+ persist, stop Homebridge and delete the cache file once:
254
+
255
+ ```sh
256
+ rm ~/.homebridge/accessories/cachedAccessories
257
+ ```
258
+
259
+ On a child bridge the file is named `cachedAccessories.<bridge-username>`. This
260
+ re-registers every accessory, so rooms, names and automations for Tydom devices
261
+ have to be set up again — which is why it is a last resort rather than a routine
262
+ upgrade step.
263
+
264
+ ### Debugging
265
+
239
266
  Set `debug` to `true` in the config, or start Homebridge with:
240
267
 
241
268
  ```bash
package/dist/index.mjs CHANGED
@@ -4903,6 +4903,16 @@ var TydomPlatform = class {
4903
4903
  * that made per-accessory teardown impossible. The platform fans out instead.
4904
4904
  */
4905
4905
  companions = /* @__PURE__ */ new Map();
4906
+ /**
4907
+ * The `device` handlers still in flight.
4908
+ *
4909
+ * The controller announces every device synchronously from `scan`, but the
4910
+ * handler is async and only reaches `accessories.set` after two awaits, so
4911
+ * nothing it registers has landed when `scan` resolves. The startup sweep and
4912
+ * the count used to run right there — which is why a fully populated gateway
4913
+ * reported `Properly loaded 0-accessories`.
4914
+ */
4915
+ pendingDevices = /* @__PURE__ */ new Set();
4906
4916
  controller;
4907
4917
  /**
4908
4918
  * The endpoint-facing API, handed to every accessory.
@@ -4950,9 +4960,12 @@ var TydomPlatform = class {
4950
4960
  this.stop();
4951
4961
  });
4952
4962
  this.controller.on("device", (context) => {
4953
- this.handleControllerDevice(context).catch((err) => {
4963
+ const pending = this.handleControllerDevice(context).catch((err) => {
4954
4964
  this.log.error(`Failed to handle device ${context.deviceId}: ${stringifyError(err)}`);
4965
+ }).finally(() => {
4966
+ this.pendingDevices.delete(pending);
4955
4967
  });
4968
+ this.pendingDevices.add(pending);
4956
4969
  });
4957
4970
  this.controller.on("update", this.handleControllerDataUpdate.bind(this));
4958
4971
  this.controller.on("notification", this.handleControllerNotification.bind(this));
@@ -4992,14 +5005,51 @@ var TydomPlatform = class {
4992
5005
  }
4993
5006
  }
4994
5007
  await this.controller.scan();
5008
+ await this.settlePendingDevices();
4995
5009
  this.cleanupAccessoriesIds.forEach((accessoryId) => {
4996
5010
  const accessory = this.accessories.get(accessoryId);
4997
5011
  if (!accessory) return;
4998
5012
  this.log.warn(`Deleting missing accessory with id=${styleNumber(accessoryId)}`);
4999
5013
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
5014
+ this.forgetAccessory(accessoryId);
5000
5015
  });
5001
5016
  this.log.info(`Properly loaded ${this.accessories.size}-accessories`);
5002
5017
  }
5018
+ /**
5019
+ * Drain the announced-device handlers.
5020
+ *
5021
+ * Looping rather than awaiting once covers a handler that itself announces
5022
+ * more work; the `finally` that empties the set is attached before
5023
+ * `allSettled` subscribes, so the loop always terminates.
5024
+ */
5025
+ async settlePendingDevices() {
5026
+ while (this.pendingDevices.size > 0) await Promise.allSettled(this.pendingDevices);
5027
+ }
5028
+ /**
5029
+ * Drop every trace of an accessory the platform no longer owns.
5030
+ *
5031
+ * `unregisterPlatformAccessories` only tells Homebridge; the platform's own
5032
+ * tables used to keep the entry. That inflated the `Properly loaded` count by
5033
+ * every accessory the sweep had just removed, and left the handler holding
5034
+ * its timers and the companion links pointing at an accessory that is gone.
5035
+ *
5036
+ * `pruneCompanions` is false when the accessory is about to be rebuilt under
5037
+ * the same UUID: the links are keyed by the primary but written by the
5038
+ * companion's own pass, and the controller announces each accessory once.
5039
+ */
5040
+ forgetAccessory(id, { pruneCompanions = true } = {}) {
5041
+ this.accessories.delete(id);
5042
+ this.handlers.get(id)?.dispose();
5043
+ this.handlers.delete(id);
5044
+ if (!pruneCompanions) return;
5045
+ this.companions.delete(id);
5046
+ for (const [primaryId, companionIds] of this.companions) {
5047
+ if (!companionIds.includes(id)) continue;
5048
+ const remaining = companionIds.filter((companionId) => companionId !== id);
5049
+ if (remaining.length > 0) this.companions.set(primaryId, remaining);
5050
+ else this.companions.delete(primaryId);
5051
+ }
5052
+ }
5003
5053
  async handleControllerDevice(context) {
5004
5054
  const { name, deviceId, category, accessoryId } = context;
5005
5055
  const id = this.api.hap.uuid.generate(accessoryId);
@@ -5015,8 +5065,10 @@ var TydomPlatform = class {
5015
5065
  } else {
5016
5066
  this.log.warn(`Deleting accessory with new category with id=${styleNumber(accessoryId)}`);
5017
5067
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
5068
+ this.forgetAccessory(id, { pruneCompanions: false });
5018
5069
  }
5019
5070
  const accessory = await this.createAccessory(name, id, category, context);
5071
+ await this.configureHandler(accessory, context);
5020
5072
  this.accessories.set(id, accessory);
5021
5073
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
5022
5074
  }
@@ -5063,18 +5115,37 @@ var TydomPlatform = class {
5063
5115
  this.log.info(`Creating accessory named=${styleString(accessoryName)}, deviceId="${styleNumber(context.deviceId)} (id=${styleKeyword(id)})"`);
5064
5116
  const accessory = new PlatformAccessory(accessoryName, id, category);
5065
5117
  Object.assign(accessory.context, context);
5066
- await this.updateAccessory(accessory, context);
5067
5118
  return accessory;
5068
5119
  }
5120
+ /**
5121
+ * Re-configure an accessory Homebridge already knows about, and persist it.
5122
+ *
5123
+ * Only ever call this on an accessory that came back from `configureAccessory`
5124
+ * or that has already been through `registerPlatformAccessories` — those are
5125
+ * the only two ways an accessory gets its plugin association, and without one
5126
+ * the cache write below throws and takes every other accessory down with it.
5127
+ */
5069
5128
  async updateAccessory(accessory, context) {
5070
5129
  const { displayName: accessoryName, UUID: id } = accessory;
5071
5130
  this.log.info(`Updating accessory named=${styleString(accessoryName)}, deviceId=${styleNumber(context.deviceId)} (id=${styleKeyword(id)})"`);
5131
+ if (!await this.configureHandler(accessory, context)) return;
5132
+ this.api.updatePlatformAccessories([accessory]);
5133
+ }
5134
+ /**
5135
+ * Attach (or re-attach) the handler and its companion links.
5136
+ *
5137
+ * Deliberately touches no Homebridge persistence, so it is safe to run on an
5138
+ * accessory that has not been registered yet. Returns whether a handler was
5139
+ * actually wired, so callers can skip persisting a category they cannot serve.
5140
+ */
5141
+ async configureHandler(accessory, context) {
5142
+ const { displayName: accessoryName, UUID: id } = accessory;
5072
5143
  Object.assign(accessory.context, context);
5073
5144
  assert(this.apiClient);
5074
5145
  const deviceType = this.resolveDeviceType(context, accessory.category);
5075
5146
  if (!deviceType) {
5076
5147
  this.log.warn(`Skipping accessory named=${styleString(accessoryName)}: no handler for category=${styleNumber(accessory.category)}`);
5077
- return;
5148
+ return false;
5078
5149
  }
5079
5150
  if (context.companionOf) {
5080
5151
  const primaryId = this.api.hap.uuid.generate(context.companionOf);
@@ -5094,7 +5165,7 @@ var TydomPlatform = class {
5094
5165
  });
5095
5166
  }
5096
5167
  }));
5097
- this.api.updatePlatformAccessories([accessory]);
5168
+ return true;
5098
5169
  }
5099
5170
  configureAccessory(accessory) {
5100
5171
  this.log.debug(`Found cached accessory with id="${accessory.UUID}"`);