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 +84 -5
- package/config.schema.json +11 -2
- package/dist/index.mjs +289 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
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
|
|
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
|
-
| `
|
|
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` |
|
|
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
|
package/config.schema.json
CHANGED
|
@@ -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": "
|
|
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
|
-
{
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
5375
|
+
return true;
|
|
5098
5376
|
}
|
|
5099
5377
|
configureAccessory(accessory) {
|
|
5100
5378
|
this.log.debug(`Found cached accessory with id="${accessory.UUID}"`);
|