homebridge-tydom 0.30.0 → 0.31.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
@@ -79,9 +79,59 @@ Then configure the platform, either in the Homebridge UI or directly in `config.
79
79
 
80
80
  ### Finding your credentials
81
81
 
82
- Your **username** is the MAC address of your gateway: `001A25` followed by the 6-character home ID shown in the mobile app.
82
+ Your **username** is the MAC address of your gateway: `001A25` followed by the 6-character home ID shown in the mobile app. Separators and case do not matter — `00:1a:25:12:34:56` works too.
83
83
 
84
- Your **password** is the one for your Tydom account. Newer setups have it generated by the mobile app and never show it to you; the only known way to recover it is to inspect the app's traffic with an SSL proxy, following [this guide](https://github.com/mgcrea/homebridge-tydom/issues/72#issuecomment-1315036089) from @aure-olivier.
84
+ Your **password** is the gateway's own, which is not the one you sign in to the Tydom app with. Newer setups have it generated by the app and never show it to you.
85
+
86
+ Rather than extract it, add your account **e-mail** and let the plugin fetch it for you:
87
+
88
+ ```json
89
+ {
90
+ "platform": "Tydom",
91
+ "hostname": "mediation.tydom.com",
92
+ "username": "001A25123456",
93
+ "email": "you@example.com",
94
+ "password": "YourDeltaDoreAccountPassw0rd"
95
+ }
96
+ ```
97
+
98
+ With `email` set, `password` is read as your **Delta Dore account** password: at startup the plugin signs in to Delta Dore, looks up the gateway named in `username`, and uses the gateway password it gets back. Nothing else changes, and your account password is never sent to the gateway. Leave `email` out — the default — and `password` keeps its old meaning of the gateway's own password.
99
+
100
+ The account has to already own that gateway. To check a pair without waiting on Homebridge:
101
+
102
+ ```bash
103
+ pnpm resolve-credentials you@example.com 'YourDeltaDoreAccountPassw0rd' 001A25123456
104
+ ```
105
+
106
+ If you would rather extract the gateway password by hand, you still can, by inspecting the app's traffic with an SSL proxy following [this guide](https://github.com/mgcrea/homebridge-tydom/issues/72#issuecomment-1315036089) from @aure-olivier.
107
+
108
+ ### If your account has several houses
109
+
110
+ `username` is what picks the house. One Delta Dore account can hold several — a home, a holiday flat, a relative's place — and each is one gateway with its own MAC. Point `username` at the MAC of the house you want this Homebridge instance to expose, and that is the one you get. Adding `email` does not change that: the account lookup is scoped to the MAC you named, and hands back that gateway's password specifically.
111
+
112
+ To find the MAC of each house, open the Tydom app's home list and read the 6-character home ID next to each one; the MAC is `001A25` followed by that. An account holding three homes gives you three MACs, and each resolves to its own distinct gateway password:
113
+
114
+ ```text
115
+ Maison -> 001A25 AAAAAA
116
+ Appartement -> 001A25 BBBBBB
117
+ Chalet -> 001A25 CCCCCC
118
+ ```
119
+
120
+ Pick the one you want and put it in `username`. Note that the account only ever *confirms* a gateway you name — there is no public Delta Dore API that lists the houses on an account, and the lookup carries no house name either, so the home ID from the app is how you tell them apart.
121
+
122
+ Being able to *see* a house is not the same as holding its credentials. A house attached to your account but registered by someone else's comes back from the lookup without a password, and the plugin will tell you so rather than pretend the MAC is wrong:
123
+
124
+ ```text
125
+ Delta Dore returned no password for gateway 001A25XXXXXX. The account can see that house
126
+ but does not hold its credentials — sign in with the account that registered the gateway,
127
+ or set "password" to the gateway password directly and leave "email" out.
128
+ ```
129
+
130
+ As that says, the fallback is to drop `email` and give that house's gateway password directly.
131
+
132
+ Each house gets its own accessory namespace, derived from that home ID, so switching `username` from one house to another retires the first house's accessories and publishes the second's rather than mixing them up. Expect a batch of `Deleting missing accessory` lines in the log the first time you do it — that is the sweep clearing out the house you moved away from.
133
+
134
+ A platform block is one house at a time; the plugin is declared `singular`, so the Homebridge UI allows a single Tydom block. To expose two houses at once you would need a second Homebridge instance, or a hand-written second platform block run as a child bridge.
85
135
 
86
136
  Both secrets can be supplied out of band instead, base64-encoded, which is usually what you want in a container:
87
137
 
@@ -97,8 +147,9 @@ They take precedence over the corresponding config fields.
97
147
  | Field | Type | Default | Description |
98
148
  | --- | --- | --- | --- |
99
149
  | `hostname` | `string` | — | `mediation.tydom.com` for the relay, or your gateway's LAN address (see [Connecting locally](#connecting-locally)). Required. |
100
- | `username` | `string` | — | Gateway MAC address, e.g. `001A25123456`. Required. |
101
- | `password` | `string` | — | Tydom account password. Required, unless supplied via the environment. |
150
+ | `username` | `string` | — | Gateway MAC address, e.g. `001A25123456`. This is what selects the house — see [If your account has several houses](#if-your-account-has-several-houses). Required. |
151
+ | `email` | `string` | — | Delta Dore account e-mail. Set it to have the gateway password fetched for you, which also changes what `password` below means. See [Finding your credentials](#finding-your-credentials). |
152
+ | `password` | `string` | — | Your Delta Dore account password if `email` is set, otherwise the gateway's own password. Required, unless supplied via the environment. |
102
153
  | `pin` | `string` | — | TYXAL+ alarm PIN. Without it the alarm reports its state but cannot be armed or disarmed. |
103
154
  | `locale` | `"fr" \| "en"` | `"fr"` | Language of the labels Delta Dore supplies for zones, detectors and thermostat modes. |
104
155
  | `refreshInterval` | `number` | `14400` | Seconds between full state refreshes. The gateway pushes changes as they happen, so this is only a safety net; clamped to a minimum of 60. |
@@ -121,7 +172,8 @@ Environment variables:
121
172
 
122
173
  | Variable | Description |
123
174
  | --- | --- |
124
- | `HOMEBRIDGE_TYDOM_PASSWORD` | Tydom password, base64-encoded. Overrides `password`. |
175
+ | `HOMEBRIDGE_TYDOM_PASSWORD` | Gateway or account password, base64-encoded. Overrides `password`. |
176
+ | `HOMEBRIDGE_TYDOM_EMAIL` | Delta Dore account e-mail, in plain text. Overrides `email`. |
125
177
  | `HOMEBRIDGE_TYDOM_PIN` | TYXAL+ PIN, base64-encoded. Overrides `pin`. |
126
178
  | `HOMEBRIDGE_TYDOM_LOCALE` | `fr` or `en`. Overrides `locale`. |
127
179
 
@@ -236,6 +288,33 @@ Nothing in your config needs to change, and no accessory is re-registered: rooms
236
288
 
237
289
  ## Troubleshooting
238
290
 
291
+ ### Duplicate accessories, or `Cannot serialize accessory`
292
+
293
+ Versions up to v0.30.0 handed each newly discovered accessory to Homebridge for
294
+ caching before registering it, which made the cache write fail:
295
+
296
+ ```text
297
+ Failed to save cached accessories to disk: Cannot serialize accessory 'Salon' - missing associated plugin
298
+ Accessory 'Salon' has the same UUID as existing accessory 'Salon'. Skipping duplicate.
299
+ ```
300
+
301
+ v0.30.1 fixes the cause, and Homebridge rewrites one entry per accessory on the
302
+ first scan after upgrading — so most installations recover on their own after a
303
+ restart. An accessory that was duplicated and then removed from the gateway can
304
+ be left orphaned in the cache, which only a reset clears. If the warnings
305
+ persist, stop Homebridge and delete the cache file once:
306
+
307
+ ```sh
308
+ rm ~/.homebridge/accessories/cachedAccessories
309
+ ```
310
+
311
+ On a child bridge the file is named `cachedAccessories.<bridge-username>`. This
312
+ re-registers every accessory, so rooms, names and automations for Tydom devices
313
+ have to be set up again — which is why it is a last resort rather than a routine
314
+ upgrade step.
315
+
316
+ ### Debugging
317
+
239
318
  Set `debug` to `true` in the config, or start Homebridge with:
240
319
 
241
320
  ```bash
@@ -20,12 +20,17 @@
20
20
  "required": true,
21
21
  "description": "Your gateway's MAC address, uppercase and without separators — e.g. `001A25123456`. It is printed underneath the Tydom box."
22
22
  },
23
+ "email": {
24
+ "title": "Delta Dore account e-mail",
25
+ "type": "string",
26
+ "description": "Optional. The e-mail you sign in to the Tydom app with.\n\nFill it in and the password below is read as your **Delta Dore account** password: the plugin signs in at startup and fetches the gateway's own password for you. Leave it empty — the default — and the password below is the **gateway's** password, used as-is.\n\nThe account has to already own the gateway named above; this looks a password up, it cannot discover which gateways you have. Also accepted as a `HOMEBRIDGE_TYDOM_EMAIL` environment variable."
27
+ },
23
28
  "password": {
24
29
  "title": "Password",
25
30
  "type": "string",
26
31
  "required": true,
27
32
  "x-schema-form": { "type": "password" },
28
- "description": "The password of your Tydom account. Can be supplied out of band instead, as a base64-encoded `HOMEBRIDGE_TYDOM_PASSWORD` environment variable, which takes precedence over this field."
33
+ "description": "Your Delta Dore account password if an e-mail is set above, otherwise the gateway's own password. Can be supplied out of band instead, as a base64-encoded `HOMEBRIDGE_TYDOM_PASSWORD` environment variable, which takes precedence over this field."
29
34
  },
30
35
  "pin": {
31
36
  "title": "Alarm PIN",
@@ -121,7 +126,11 @@
121
126
  }
122
127
  },
123
128
  "layout": [
124
- { "type": "fieldset", "title": "Connection", "items": ["hostname", "username", "password"] },
129
+ {
130
+ "type": "fieldset",
131
+ "title": "Connection",
132
+ "items": ["hostname", "username", "email", "password"]
133
+ },
125
134
  {
126
135
  "type": "fieldset",
127
136
  "title": "Alarm",
package/dist/index.mjs CHANGED
@@ -409,9 +409,10 @@ const parseConfig = (config, env = process.env) => {
409
409
  const username = asString(config["username"]);
410
410
  const password = env.HOMEBRIDGE_TYDOM_PASSWORD ? decode(env.HOMEBRIDGE_TYDOM_PASSWORD) : asString(config["password"]);
411
411
  const pin = env.HOMEBRIDGE_TYDOM_PIN ? decode(env.HOMEBRIDGE_TYDOM_PIN) : asString(config["pin"]) || void 0;
412
+ const email = asString(env.HOMEBRIDGE_TYDOM_EMAIL) || asString(config["email"]) || void 0;
412
413
  if (!hostname) throw new ConfigError("Missing \"hostname\" — use \"mediation.tydom.com\" for remote access, or your gateway's local address.");
413
414
  if (!username) throw new ConfigError("Missing \"username\" — this is your Tydom gateway MAC address.");
414
- if (!password) throw new ConfigError("Missing \"password\" — set it in the platform config, or as a base64-encoded HOMEBRIDGE_TYDOM_PASSWORD.");
415
+ if (!password) throw new ConfigError(`Missing "password" — ${email ? `the password of the Delta Dore account ${email}.` : "your gateway's password, or set \"email\" to sign in with your Delta Dore account instead."} Can also be supplied as a base64-encoded HOMEBRIDGE_TYDOM_PASSWORD.`);
415
416
  const locale = (asString(env.HOMEBRIDGE_TYDOM_LOCALE) || asString(config["locale"])) === "en" ? "en" : "fr";
416
417
  const refreshSeconds = Number(config["refreshInterval"] ?? 144e5 / 1e3);
417
418
  const refreshIntervalMs = Number.isFinite(refreshSeconds) ? Math.max(MIN_REFRESH_INTERVAL_MS, refreshSeconds * 1e3) : DEFAULT_REFRESH_INTERVAL_MS;
@@ -422,6 +423,7 @@ const parseConfig = (config, env = process.env) => {
422
423
  hostname,
423
424
  username,
424
425
  password,
426
+ email,
425
427
  pin,
426
428
  locale,
427
429
  debug: asBoolean(config["debug"], false),
@@ -2901,6 +2903,161 @@ const errorReplacer = (_key, value) => {
2901
2903
  };
2902
2904
  const stringifyError = (err) => JSON.stringify(err, errorReplacer);
2903
2905
  //#endregion
2906
+ //#region src/util/deltadore.ts
2907
+ /**
2908
+ * Delta Dore account API — turns account credentials into gateway credentials.
2909
+ *
2910
+ * The gateway's own digest handshake (`mediation/client`) authenticates with the
2911
+ * gateway MAC as the username and a *gateway* password that is neither the
2912
+ * account password nor anything printed on the box. Delta Dore's own apps fetch
2913
+ * it at runtime from the account API, which is what this module does.
2914
+ *
2915
+ * The flow is three hops:
2916
+ *
2917
+ * 1. OpenID discovery against Delta Dore's Azure AD B2C tenant, to find the
2918
+ * token endpoint. Hard-coding it would break the day they rotate it, and
2919
+ * discovery is the documented way to avoid that.
2920
+ * 2. A ROPC (`grant_type=password`) token grant — B2C's non-interactive flow,
2921
+ * the only one usable from a headless plugin.
2922
+ * 3. `GET /sites?gateway_mac=…`, which returns the site and its gateway
2923
+ * credentials.
2924
+ *
2925
+ * Note that step 3 *validates* a MAC rather than enumerating: there is no
2926
+ * public endpoint that lists an account's gateways, so the MAC stays a required
2927
+ * piece of configuration. Only the password is derived here.
2928
+ */
2929
+ const AUTH_CONFIG_URL = "https://deltadoreadb2ciot.b2clogin.com/deltadoreadb2ciot.onmicrosoft.com/v2.0/.well-known/openid-configuration?p=B2C_1_AccountProviderROPC_SignIn";
2930
+ /** The Tydom mobile app's public client id. Not a secret. */
2931
+ const CLIENT_ID = "8782839f-3264-472a-ab87-4d4e23524da4";
2932
+ const SITES_URL = "https://prod.iotdeltadore.com/sitesmanagement/api/v1/sites";
2933
+ /**
2934
+ * Only the two scopes this actually needs.
2935
+ *
2936
+ * The app requests twenty-odd (video, metering, orchestration...); asking for
2937
+ * scopes we never exercise would hand the token far more authority than reading
2938
+ * one gateway password warrants.
2939
+ */
2940
+ const SCOPE = [
2941
+ "openid",
2942
+ "profile",
2943
+ "offline_access",
2944
+ "https://deltadoreadb2ciot.onmicrosoft.com/iotapi/sites_management_allowed",
2945
+ "https://deltadoreadb2ciot.onmicrosoft.com/iotapi/sites_management_gateway_credentials"
2946
+ ].join(" ");
2947
+ /** How long any one hop may take before we stop waiting on Delta Dore. */
2948
+ const REQUEST_TIMEOUT_MS = 15e3;
2949
+ /**
2950
+ * A credential problem the user has to fix, as opposed to a transport blip.
2951
+ *
2952
+ * Kept distinct so the platform can say "your account password is wrong" rather
2953
+ * than burning ten connection retries on something no retry will fix.
2954
+ */
2955
+ var DeltaDoreAuthError = class extends Error {
2956
+ name = "DeltaDoreAuthError";
2957
+ };
2958
+ const openIdConfigSchema = z.object({ token_endpoint: z.string() }).loose();
2959
+ const tokenResponseSchema = z.object({ access_token: z.string() }).loose();
2960
+ const tokenErrorSchema = z.object({
2961
+ error: z.string().optional(),
2962
+ error_description: z.string().optional()
2963
+ }).loose();
2964
+ const siteSchema = z.object({
2965
+ id: z.string().optional(),
2966
+ gateway: z.object({
2967
+ mac: z.string().optional(),
2968
+ password: z.string().optional()
2969
+ }).loose().optional()
2970
+ }).loose();
2971
+ const sitesResponseSchema = z.object({ sites: z.array(siteSchema).default([]) }).loose();
2972
+ /**
2973
+ * Uppercase, separators stripped — the form the sites API matches on.
2974
+ *
2975
+ * Users copy the MAC off the label underneath the box, which prints it with
2976
+ * colons, so accepting only the bare form would reject a correct answer.
2977
+ */
2978
+ const normalizeMacAddress = (mac) => mac.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
2979
+ const requestJson = async (fetchImpl, url, init) => {
2980
+ const { what, ...requestInit } = init;
2981
+ let response;
2982
+ try {
2983
+ response = await fetchImpl(url, {
2984
+ ...requestInit,
2985
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
2986
+ });
2987
+ } catch (err) {
2988
+ throw new Error(`Failed to reach Delta Dore while ${what}: ${String(err)}`, { cause: err });
2989
+ }
2990
+ const text = await response.text();
2991
+ let body;
2992
+ try {
2993
+ body = text ? JSON.parse(text) : {};
2994
+ } catch {
2995
+ throw new Error(`Delta Dore returned a non-JSON response while ${what} (status=${response.status}): ${text.slice(0, 120)}`);
2996
+ }
2997
+ return {
2998
+ status: response.status,
2999
+ body
3000
+ };
3001
+ };
3002
+ /** Exchange account credentials for a bearer token. */
3003
+ const requestAccessToken = async ({ email, password, fetch: fetchImpl = globalThis.fetch }) => {
3004
+ const discovery = await requestJson(fetchImpl, AUTH_CONFIG_URL, { what: "discovering the account API" });
3005
+ const openIdConfig = openIdConfigSchema.safeParse(discovery.body);
3006
+ if (!openIdConfig.success) throw new Error("Delta Dore's OpenID configuration is missing a token endpoint.");
3007
+ const form = new FormData();
3008
+ form.set("username", email);
3009
+ form.set("password", password);
3010
+ form.set("grant_type", "password");
3011
+ form.set("client_id", CLIENT_ID);
3012
+ form.set("scope", SCOPE);
3013
+ form.set("response_type", "token");
3014
+ const { status, body } = await requestJson(fetchImpl, openIdConfig.data.token_endpoint, {
3015
+ method: "POST",
3016
+ body: form,
3017
+ what: "signing in to your Delta Dore account"
3018
+ });
3019
+ const token = tokenResponseSchema.safeParse(body);
3020
+ if (!token.success) {
3021
+ const error = tokenErrorSchema.safeParse(body);
3022
+ const detail = error.success ? error.data.error_description?.split("\n")[0] ?? error.data.error : void 0;
3023
+ throw new DeltaDoreAuthError(`Delta Dore rejected the account credentials (status=${status})${detail ? `: ${detail}` : "."}`);
3024
+ }
3025
+ return token.data.access_token;
3026
+ };
3027
+ /**
3028
+ * Look up the gateway password for `mac` on the signed-in account.
3029
+ *
3030
+ * Returns the password, or throws if the account does not own that gateway.
3031
+ */
3032
+ const fetchGatewayPassword = async ({ accessToken, mac, fetch: fetchImpl = globalThis.fetch }) => {
3033
+ const normalizedMac = normalizeMacAddress(mac);
3034
+ const url = `${SITES_URL}?gateway_mac=${encodeURIComponent(normalizedMac)}`;
3035
+ const { status, body } = await requestJson(fetchImpl, url, {
3036
+ headers: { Authorization: `Bearer ${accessToken}` },
3037
+ what: "looking up your gateway"
3038
+ });
3039
+ if (status === 401 || status === 403) throw new DeltaDoreAuthError(`Your Delta Dore account is not allowed to read the credentials of gateway ${normalizedMac}.`);
3040
+ const parsed = sitesResponseSchema.safeParse(body);
3041
+ if (!parsed.success) throw new Error(`Delta Dore returned an unexpected site list (status=${status}) for gateway ${normalizedMac}.`);
3042
+ const site = parsed.data.sites.find((candidate) => normalizeMacAddress(candidate.gateway?.mac ?? "") === normalizedMac);
3043
+ if (!site) throw new DeltaDoreAuthError(`No gateway with MAC ${normalizedMac} is attached to this Delta Dore account. Check "username" against the MAC printed underneath your Tydom box.`);
3044
+ if (!site.gateway?.password) throw new DeltaDoreAuthError(`Delta Dore returned no password for gateway ${normalizedMac}. The account can see that house but does not hold its credentials — sign in with the account that registered the gateway, or set "password" to the gateway password directly and leave "email" out.`);
3045
+ return site.gateway.password;
3046
+ };
3047
+ /** Sign in, then read the gateway password. The whole flow, in one call. */
3048
+ const resolveGatewayPassword = async ({ email, password, mac, fetch: fetchImpl = globalThis.fetch }) => {
3049
+ const accessToken = await requestAccessToken({
3050
+ email,
3051
+ password,
3052
+ fetch: fetchImpl
3053
+ });
3054
+ return fetchGatewayPassword({
3055
+ accessToken,
3056
+ mac,
3057
+ fetch: fetchImpl
3058
+ });
3059
+ };
3060
+ //#endregion
2904
3061
  //#region src/controller.ts
2905
3062
  var TydomController = class extends EventEmitter {
2906
3063
  client;
@@ -2925,6 +3082,14 @@ var TydomController = class extends EventEmitter {
2925
3082
  emitted = /* @__PURE__ */ new Set();
2926
3083
  refreshInterval;
2927
3084
  hasConnectedOnce = false;
3085
+ /**
3086
+ * The gateway password actually in use.
3087
+ *
3088
+ * Empty until `connect` derives it, when the platform was configured with an
3089
+ * account e-mail — in which case `config.password` is the account's, not the
3090
+ * gateway's, and must never reach the gateway.
3091
+ */
3092
+ gatewayPassword;
2928
3093
  /** Delta Dore label lookups, bound to the configured locale. */
2929
3094
  t;
2930
3095
  constructor(log, config) {
@@ -2932,22 +3097,34 @@ var TydomController = class extends EventEmitter {
2932
3097
  this.config = config;
2933
3098
  this.log = log;
2934
3099
  this.t = createTranslator(config.locale);
2935
- const { hostname, username, password } = config;
3100
+ const { hostname, username, password, email } = config;
2936
3101
  this.log.info(`Creating tydom client with username=${styleString(username)} and hostname=${styleString(hostname)}`);
2937
- this.client = createClient({
3102
+ this.gatewayPassword = email ? "" : password;
3103
+ this.client = this.createClient(this.gatewayPassword);
3104
+ }
3105
+ /**
3106
+ * Build a client for `password` and wire its events up.
3107
+ *
3108
+ * Split out of the constructor because account-derived credentials only
3109
+ * arrive once `connect` has been able to await the account API, and the
3110
+ * client takes its password at construction time.
3111
+ */
3112
+ createClient(password) {
3113
+ const { hostname, username } = this.config;
3114
+ const client = createClient({
2938
3115
  username,
2939
3116
  password,
2940
3117
  hostname,
2941
3118
  followUpDebounce: 500
2942
3119
  });
2943
- this.client.on("message", (message) => {
3120
+ client.on("message", (message) => {
2944
3121
  try {
2945
3122
  this.handleMessage(message);
2946
3123
  } catch (err) {
2947
3124
  this.log.error(`Encountered an uncaught error=${stringifyError(err)} while processing message=${styleJson(message)}"`);
2948
3125
  }
2949
3126
  });
2950
- this.client.on("connect", () => {
3127
+ client.on("connect", () => {
2951
3128
  this.log.info(`Successfully connected to Tydom hostname=${styleString(hostname)} with username=${styleString(username)}`);
2952
3129
  if (this.hasConnectedOnce) {
2953
3130
  this.log.warn(`Reconnected to Tydom hostname=${styleString(hostname)}, re-syncing state...`);
@@ -2957,7 +3134,7 @@ var TydomController = class extends EventEmitter {
2957
3134
  }
2958
3135
  this.emit("connect");
2959
3136
  });
2960
- this.client.on("disconnect", () => {
3137
+ client.on("disconnect", () => {
2961
3138
  this.log.warn(`Disconnected from Tydom hostname=${styleString(hostname)}"`);
2962
3139
  if (this.refreshInterval) {
2963
3140
  clearInterval(this.refreshInterval);
@@ -2965,6 +3142,29 @@ var TydomController = class extends EventEmitter {
2965
3142
  }
2966
3143
  this.emit("disconnect");
2967
3144
  });
3145
+ return client;
3146
+ }
3147
+ /**
3148
+ * Fetch the gateway password from the Delta Dore account, once.
3149
+ *
3150
+ * No-op unless an account e-mail was configured. The result is kept for the
3151
+ * process lifetime: the gateway password is stable, and `connect` is retried
3152
+ * with backoff on startup — re-running the whole OAuth flow on every attempt
3153
+ * would turn a flaky socket into repeated sign-ins.
3154
+ */
3155
+ async resolveGatewayPassword() {
3156
+ const { email, password: accountPassword, username } = this.config;
3157
+ if (this.gatewayPassword || !email) return;
3158
+ this.log.info(`Resolving the gateway password from the Delta Dore account of ${styleString(email)}...`);
3159
+ const password = await resolveGatewayPassword({
3160
+ email,
3161
+ password: accountPassword,
3162
+ mac: username
3163
+ });
3164
+ this.gatewayPassword = password;
3165
+ this.client.removeAllListeners();
3166
+ this.client = this.createClient(password);
3167
+ this.log.info(`Resolved the gateway password for username=${styleString(username)}`);
2968
3168
  }
2969
3169
  getUniqueId(deviceId, endpointId) {
2970
3170
  return deviceId === endpointId ? `${deviceId}` : `${deviceId}:${endpointId}`;
@@ -2977,6 +3177,7 @@ var TydomController = class extends EventEmitter {
2977
3177
  const { hostname, username } = this.config;
2978
3178
  debug(`Connecting to hostname=${styleString(hostname)}...`);
2979
3179
  try {
3180
+ await this.resolveGatewayPassword();
2980
3181
  await this.client.connect();
2981
3182
  await asyncWait(250);
2982
3183
  await this.client.get("/ping");
@@ -4903,6 +5104,16 @@ var TydomPlatform = class {
4903
5104
  * that made per-accessory teardown impossible. The platform fans out instead.
4904
5105
  */
4905
5106
  companions = /* @__PURE__ */ new Map();
5107
+ /**
5108
+ * The `device` handlers still in flight.
5109
+ *
5110
+ * The controller announces every device synchronously from `scan`, but the
5111
+ * handler is async and only reaches `accessories.set` after two awaits, so
5112
+ * nothing it registers has landed when `scan` resolves. The startup sweep and
5113
+ * the count used to run right there — which is why a fully populated gateway
5114
+ * reported `Properly loaded 0-accessories`.
5115
+ */
5116
+ pendingDevices = /* @__PURE__ */ new Set();
4906
5117
  controller;
4907
5118
  /**
4908
5119
  * The endpoint-facing API, handed to every accessory.
@@ -4937,8 +5148,14 @@ var TydomPlatform = class {
4937
5148
  this.t = createTranslator(this.config.locale);
4938
5149
  this.pluginLog = createPluginLogger(log, this.config.debug);
4939
5150
  this.controller = new TydomController(log, this.config);
5151
+ const controller = this.controller;
4940
5152
  this.apiClient = new TydomApiClient({
4941
- transport: this.controller.client,
5153
+ transport: {
5154
+ get: (uri) => controller.client.get(uri),
5155
+ put: (uri, body) => controller.client.put(uri, body),
5156
+ post: (uri, body) => controller.client.post(uri, body),
5157
+ command: (uri) => controller.client.command(uri)
5158
+ },
4942
5159
  logger: this.pluginLog
4943
5160
  });
4944
5161
  this.api.on("didFinishLaunching", () => {
@@ -4950,9 +5167,12 @@ var TydomPlatform = class {
4950
5167
  this.stop();
4951
5168
  });
4952
5169
  this.controller.on("device", (context) => {
4953
- this.handleControllerDevice(context).catch((err) => {
5170
+ const pending = this.handleControllerDevice(context).catch((err) => {
4954
5171
  this.log.error(`Failed to handle device ${context.deviceId}: ${stringifyError(err)}`);
5172
+ }).finally(() => {
5173
+ this.pendingDevices.delete(pending);
4955
5174
  });
5175
+ this.pendingDevices.add(pending);
4956
5176
  });
4957
5177
  this.controller.on("update", this.handleControllerDataUpdate.bind(this));
4958
5178
  this.controller.on("notification", this.handleControllerNotification.bind(this));
@@ -4992,14 +5212,51 @@ var TydomPlatform = class {
4992
5212
  }
4993
5213
  }
4994
5214
  await this.controller.scan();
5215
+ await this.settlePendingDevices();
4995
5216
  this.cleanupAccessoriesIds.forEach((accessoryId) => {
4996
5217
  const accessory = this.accessories.get(accessoryId);
4997
5218
  if (!accessory) return;
4998
5219
  this.log.warn(`Deleting missing accessory with id=${styleNumber(accessoryId)}`);
4999
5220
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
5221
+ this.forgetAccessory(accessoryId);
5000
5222
  });
5001
5223
  this.log.info(`Properly loaded ${this.accessories.size}-accessories`);
5002
5224
  }
5225
+ /**
5226
+ * Drain the announced-device handlers.
5227
+ *
5228
+ * Looping rather than awaiting once covers a handler that itself announces
5229
+ * more work; the `finally` that empties the set is attached before
5230
+ * `allSettled` subscribes, so the loop always terminates.
5231
+ */
5232
+ async settlePendingDevices() {
5233
+ while (this.pendingDevices.size > 0) await Promise.allSettled(this.pendingDevices);
5234
+ }
5235
+ /**
5236
+ * Drop every trace of an accessory the platform no longer owns.
5237
+ *
5238
+ * `unregisterPlatformAccessories` only tells Homebridge; the platform's own
5239
+ * tables used to keep the entry. That inflated the `Properly loaded` count by
5240
+ * every accessory the sweep had just removed, and left the handler holding
5241
+ * its timers and the companion links pointing at an accessory that is gone.
5242
+ *
5243
+ * `pruneCompanions` is false when the accessory is about to be rebuilt under
5244
+ * the same UUID: the links are keyed by the primary but written by the
5245
+ * companion's own pass, and the controller announces each accessory once.
5246
+ */
5247
+ forgetAccessory(id, { pruneCompanions = true } = {}) {
5248
+ this.accessories.delete(id);
5249
+ this.handlers.get(id)?.dispose();
5250
+ this.handlers.delete(id);
5251
+ if (!pruneCompanions) return;
5252
+ this.companions.delete(id);
5253
+ for (const [primaryId, companionIds] of this.companions) {
5254
+ if (!companionIds.includes(id)) continue;
5255
+ const remaining = companionIds.filter((companionId) => companionId !== id);
5256
+ if (remaining.length > 0) this.companions.set(primaryId, remaining);
5257
+ else this.companions.delete(primaryId);
5258
+ }
5259
+ }
5003
5260
  async handleControllerDevice(context) {
5004
5261
  const { name, deviceId, category, accessoryId } = context;
5005
5262
  const id = this.api.hap.uuid.generate(accessoryId);
@@ -5015,8 +5272,10 @@ var TydomPlatform = class {
5015
5272
  } else {
5016
5273
  this.log.warn(`Deleting accessory with new category with id=${styleNumber(accessoryId)}`);
5017
5274
  this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
5275
+ this.forgetAccessory(id, { pruneCompanions: false });
5018
5276
  }
5019
5277
  const accessory = await this.createAccessory(name, id, category, context);
5278
+ await this.configureHandler(accessory, context);
5020
5279
  this.accessories.set(id, accessory);
5021
5280
  this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
5022
5281
  }
@@ -5063,18 +5322,37 @@ var TydomPlatform = class {
5063
5322
  this.log.info(`Creating accessory named=${styleString(accessoryName)}, deviceId="${styleNumber(context.deviceId)} (id=${styleKeyword(id)})"`);
5064
5323
  const accessory = new PlatformAccessory(accessoryName, id, category);
5065
5324
  Object.assign(accessory.context, context);
5066
- await this.updateAccessory(accessory, context);
5067
5325
  return accessory;
5068
5326
  }
5327
+ /**
5328
+ * Re-configure an accessory Homebridge already knows about, and persist it.
5329
+ *
5330
+ * Only ever call this on an accessory that came back from `configureAccessory`
5331
+ * or that has already been through `registerPlatformAccessories` — those are
5332
+ * the only two ways an accessory gets its plugin association, and without one
5333
+ * the cache write below throws and takes every other accessory down with it.
5334
+ */
5069
5335
  async updateAccessory(accessory, context) {
5070
5336
  const { displayName: accessoryName, UUID: id } = accessory;
5071
5337
  this.log.info(`Updating accessory named=${styleString(accessoryName)}, deviceId=${styleNumber(context.deviceId)} (id=${styleKeyword(id)})"`);
5338
+ if (!await this.configureHandler(accessory, context)) return;
5339
+ this.api.updatePlatformAccessories([accessory]);
5340
+ }
5341
+ /**
5342
+ * Attach (or re-attach) the handler and its companion links.
5343
+ *
5344
+ * Deliberately touches no Homebridge persistence, so it is safe to run on an
5345
+ * accessory that has not been registered yet. Returns whether a handler was
5346
+ * actually wired, so callers can skip persisting a category they cannot serve.
5347
+ */
5348
+ async configureHandler(accessory, context) {
5349
+ const { displayName: accessoryName, UUID: id } = accessory;
5072
5350
  Object.assign(accessory.context, context);
5073
5351
  assert(this.apiClient);
5074
5352
  const deviceType = this.resolveDeviceType(context, accessory.category);
5075
5353
  if (!deviceType) {
5076
5354
  this.log.warn(`Skipping accessory named=${styleString(accessoryName)}: no handler for category=${styleNumber(accessory.category)}`);
5077
- return;
5355
+ return false;
5078
5356
  }
5079
5357
  if (context.companionOf) {
5080
5358
  const primaryId = this.api.hap.uuid.generate(context.companionOf);
@@ -5094,7 +5372,7 @@ var TydomPlatform = class {
5094
5372
  });
5095
5373
  }
5096
5374
  }));
5097
- this.api.updatePlatformAccessories([accessory]);
5375
+ return true;
5098
5376
  }
5099
5377
  configureAccessory(accessory) {
5100
5378
  this.log.debug(`Found cached accessory with id="${accessory.UUID}"`);