flaks-node-hon 1.0.0

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +144 -0
  3. package/bin/node-hon.js +94 -0
  4. package/cli/ac_apply_preset.js +36 -0
  5. package/cli/ac_generate_preset.js +80 -0
  6. package/cli/ac_turn_off.js +20 -0
  7. package/cli/ac_turn_on.js +21 -0
  8. package/cli/config.js +84 -0
  9. package/cli/purge_cache.js +16 -0
  10. package/cli/show_my_ac_capabilities.js +36 -0
  11. package/cli/show_my_ac_devices.js +19 -0
  12. package/config_example.js +10 -0
  13. package/package.json +41 -0
  14. package/presets/preset_auto.json +7 -0
  15. package/presets/preset_cool.json +8 -0
  16. package/presets/preset_dry.json +6 -0
  17. package/presets/preset_fan.json +8 -0
  18. package/src/ac.js +330 -0
  19. package/src/api.js +123 -0
  20. package/src/appliance-identity.js +71 -0
  21. package/src/appliance.js +282 -0
  22. package/src/auth.js +424 -0
  23. package/src/caching/appliance-cache.js +71 -0
  24. package/src/caching/session-store.js +47 -0
  25. package/src/client.js +253 -0
  26. package/src/command.js +314 -0
  27. package/src/connection.js +73 -0
  28. package/src/constants.js +17 -0
  29. package/src/device.js +29 -0
  30. package/src/errors.js +38 -0
  31. package/src/index.js +25 -0
  32. package/src/lib/config.js +22 -0
  33. package/src/lib/cookie-jar.js +36 -0
  34. package/src/lib/logger.js +56 -0
  35. package/src/lib-cli/_format.js +33 -0
  36. package/src/lib-cli/_get-ac-client.js +29 -0
  37. package/src/lib-cli/_get-client.js +25 -0
  38. package/src/lib-cli/_prompt.js +61 -0
  39. package/src/lib-cli/_run.js +18 -0
  40. package/src/lib-cli/_select-ac.js +36 -0
  41. package/src/parameters.js +261 -0
  42. package/src/preset-generator.js +171 -0
  43. package/types/global.ts +19 -0
@@ -0,0 +1,282 @@
1
+ const { HonCommandLoader } = require("./command");
2
+ const { HonParameter, HonParameterRange, HonParameterEnum } = require("./parameters");
3
+
4
+ class HonAppliance {
5
+ constructor(api, info, zone = 0) {
6
+ if (Array.isArray(info.attributes)) {
7
+ info = {
8
+ ...info,
9
+ attributes: Object.fromEntries(info.attributes.map((value) => [value.parName, value.parValue]))
10
+ };
11
+ }
12
+ this.api = api;
13
+ this.info = info || {};
14
+ this.zone = zone;
15
+ this.applianceModel = {};
16
+ this.commands = {};
17
+ this.statistics = {};
18
+ this.attributes = {};
19
+ this.additionalData = {};
20
+ this.rawCommands = {};
21
+ this.lastUpdate = null;
22
+ this.defaultSetting = new HonParameter("", {}, "");
23
+ this.connection = this.attributes?.lastConnEvent?.category !== "DISCONNECTED";
24
+ }
25
+
26
+ get applianceModelId() {
27
+ return String(this.info.applianceModelId || "");
28
+ }
29
+
30
+ get applianceType() {
31
+ return String(this.info.applianceTypeName || "");
32
+ }
33
+
34
+ get macAddress() {
35
+ return String(this.info.macAddress || "");
36
+ }
37
+
38
+ get uniqueId() {
39
+ const defaultMac = "xx-xx-xx-xx-xx-xx";
40
+ const importName = `${this.applianceType.toLowerCase()}_${this.applianceModelId}`;
41
+ return this.checkNameZone("macAddress", false).replace(defaultMac, importName);
42
+ }
43
+
44
+ get modelName() {
45
+ return this.checkNameZone("modelName");
46
+ }
47
+
48
+ get brand() {
49
+ const brand = this.checkNameZone("brand");
50
+ return brand ? brand[0].toUpperCase() + brand.slice(1) : "";
51
+ }
52
+
53
+ get nickName() {
54
+ const result =
55
+ this.checkNameZone("nickName") ||
56
+ this.checkNameZone("applianceName") ||
57
+ this.checkNameZone("applianceNickName") ||
58
+ this.checkNameZone("applianceNickname") ||
59
+ this.checkNameZone("name");
60
+ if (!result || /^[xX1\s-]+$/.test(result)) {
61
+ return this.modelName;
62
+ }
63
+ return result;
64
+ }
65
+
66
+ get code() {
67
+ if (this.info.code) {
68
+ return this.info.code;
69
+ }
70
+ const serialNumber = String(this.info.serialNumber || "");
71
+ return serialNumber.length < 18 ? serialNumber.slice(0, 8) : serialNumber.slice(0, 11);
72
+ }
73
+
74
+ get modelId() {
75
+ return Number(this.info.applianceModelId || 0);
76
+ }
77
+
78
+ get options() {
79
+ return { ...(this.applianceModel.options || {}) };
80
+ }
81
+
82
+ get commandParameters() {
83
+ return Object.fromEntries(Object.entries(this.commands).map(([name, command]) => [name, command.parameterValue]));
84
+ }
85
+
86
+ get settings() {
87
+ const result = {};
88
+ for (const [name, command] of Object.entries(this.commands)) {
89
+ for (const key of command.settingKeys) {
90
+ result[`${name}.${key}`] = command.settings[key] || this.defaultSetting;
91
+ }
92
+ }
93
+ return result;
94
+ }
95
+
96
+ get data() {
97
+ return {
98
+ attributes: this.attributes,
99
+ appliance: this.info,
100
+ statistics: this.statistics,
101
+ additional_data: this.additionalData,
102
+ ...this.commandParameters,
103
+ ...this.attributes
104
+ };
105
+ }
106
+
107
+ checkNameZone(name, frontend = true) {
108
+ const zone = frontend ? " Z" : "_z";
109
+ const attribute = String(this.info[name] || "");
110
+ if (attribute && this.zone) {
111
+ return `${attribute}${zone}${this.zone}`;
112
+ }
113
+ return attribute;
114
+ }
115
+
116
+ async loadCommands() {
117
+ const loader = new HonCommandLoader(this.api, this);
118
+ await loader.loadCommands();
119
+ this.commands = loader.commands;
120
+ this.additionalData = loader.additionalData;
121
+ this.applianceModel = loader.applianceData;
122
+ this.rawCommands = loader.rawCommands;
123
+ this.syncParamsToCommand("settings");
124
+ }
125
+
126
+ loadCommandsFromCache(cacheData) {
127
+ const loader = new HonCommandLoader(this.api, this);
128
+ loader.loadFromCache(cacheData);
129
+ this.commands = loader.commands;
130
+ this.additionalData = loader.additionalData;
131
+ this.applianceModel = loader.applianceData;
132
+ this.rawCommands = loader.rawCommands;
133
+ }
134
+
135
+ toCacheRecord() {
136
+ return {
137
+ cachedAt: new Date().toISOString(),
138
+ info: this.info,
139
+ zone: this.zone,
140
+ macAddress: this.macAddress,
141
+ uniqueId: this.uniqueId,
142
+ nickName: this.nickName,
143
+ commandData: {
144
+ commands: this.rawCommands || {},
145
+ applianceModel: this.applianceModel,
146
+ additionalData: this.additionalData
147
+ }
148
+ };
149
+ }
150
+
151
+ static fromCacheRecord(api, record) {
152
+ const appliance = new HonAppliance(api, record.info, record.zone || 0);
153
+ appliance.loadCommandsFromCache(record.commandData || {});
154
+ return appliance;
155
+ }
156
+
157
+ async loadAttributes() {
158
+ const attributes = await this.api.loadAttributes(this);
159
+ const shadow = attributes?.shadow?.parameters || {};
160
+ delete attributes.shadow;
161
+ for (const [name, values] of Object.entries(shadow)) {
162
+ if (this.attributes.parameters?.[name]) {
163
+ Object.assign(this.attributes.parameters[name], values);
164
+ } else {
165
+ if (!this.attributes.parameters) {
166
+ this.attributes.parameters = {};
167
+ }
168
+ this.attributes.parameters[name] = values;
169
+ }
170
+ }
171
+ Object.assign(this.attributes, attributes || {});
172
+ }
173
+
174
+ async loadStatistics() {
175
+ this.statistics = {
176
+ ...(await this.api.loadStatistics(this)),
177
+ ...(await this.api.loadMaintenance(this))
178
+ };
179
+ }
180
+
181
+ async update(force = false) {
182
+ const now = Date.now();
183
+ if (force || !this.lastUpdate || this.lastUpdate + 5000 < now) {
184
+ this.lastUpdate = now;
185
+ await this.loadAttributes();
186
+ this.syncParamsToCommand("settings");
187
+ }
188
+ }
189
+
190
+ syncCommandToParams(commandName) {
191
+ const command = this.commands[commandName];
192
+ if (!command || !this.attributes.parameters) {
193
+ return;
194
+ }
195
+ for (const key of Object.keys(this.attributes.parameters)) {
196
+ const next = command.parameters[key];
197
+ if (next) {
198
+ this.attributes.parameters[key].value = String(next.internValue);
199
+ }
200
+ }
201
+ }
202
+
203
+ syncParamsToCommand(commandName) {
204
+ const command = this.commands[commandName];
205
+ if (!command || !this.attributes.parameters) {
206
+ return;
207
+ }
208
+ for (const key of command.settingKeys) {
209
+ const next = this.attributes.parameters[key];
210
+ if (!next || next.value === "") {
211
+ continue;
212
+ }
213
+ const setting = command.settings[key];
214
+ if (!setting) {
215
+ continue;
216
+ }
217
+ try {
218
+ setting.value = setting instanceof HonParameterRange ? Number(next.value) : String(next.value);
219
+ } catch {
220
+ // Keep server-provided default if current attribute is outside command constraints.
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {string} main
227
+ * @param {string | string[] | null} [target]
228
+ * @param {string | string[] | null} [toSync]
229
+ */
230
+ syncCommand(main, target = null, toSync = null) {
231
+ const base = this.commands[main];
232
+ if (!base) {
233
+ return;
234
+ }
235
+ const targets = Array.isArray(target) ? target : target ? [target] : null;
236
+ for (const [name, command] of Object.entries(this.commands)) {
237
+ if (name === main || (targets && !targets.includes(name))) {
238
+ continue;
239
+ }
240
+ for (const [paramName, targetParam] of Object.entries(command.parameters)) {
241
+ const baseParam = base.parameters[paramName];
242
+ if (!baseParam) {
243
+ continue;
244
+ }
245
+ if (toSync && ((Array.isArray(toSync) && !toSync.includes(paramName)) || !baseParam.mandatory)) {
246
+ continue;
247
+ }
248
+ this.syncParameter(baseParam, targetParam);
249
+ }
250
+ }
251
+ }
252
+
253
+ syncParameter(main, target) {
254
+ if (main instanceof HonParameterRange && target instanceof HonParameterRange) {
255
+ target.max = main.max;
256
+ target.min = main.min;
257
+ target.step = main.step;
258
+ } else if (target instanceof HonParameterRange) {
259
+ target.max = Number(main.value);
260
+ target.min = Number(main.value);
261
+ target.step = 1;
262
+ } else if (target instanceof HonParameterEnum) {
263
+ target.values = main.values;
264
+ }
265
+ target.value = main.value;
266
+ }
267
+ }
268
+
269
+ function isAirConditioner(appliance) {
270
+ const values = [
271
+ appliance.applianceType,
272
+ appliance.nickName,
273
+ appliance.modelName,
274
+ appliance.info.applianceType,
275
+ appliance.info.applianceTypeCode
276
+ ]
277
+ .filter(Boolean)
278
+ .map((value) => String(value).toLowerCase());
279
+ return values.some((value) => value === "ac" || value.includes("air condition") || value.includes("conditioner"));
280
+ }
281
+
282
+ module.exports = { HonAppliance, isAirConditioner };
package/src/auth.js ADDED
@@ -0,0 +1,424 @@
1
+ const crypto = require("node:crypto");
2
+ const { URLSearchParams } = require("node:url");
3
+ const constants = require("./constants");
4
+ const { CookieJar } = require("./lib/cookie-jar");
5
+ const { HonAuthError } = require("./errors");
6
+ const { isExpired } = require("./caching/session-store");
7
+
8
+ const TOKEN_EXPIRES_AFTER_MS = 8 * 60 * 60 * 1000;
9
+ const TOKEN_EXPIRE_WARNING_MS = 60 * 60 * 1000;
10
+
11
+ class HonAuth {
12
+ /**
13
+ * @param {{
14
+ * email?: string,
15
+ * password?: string,
16
+ * device: any,
17
+ * fetchImpl?: typeof fetch,
18
+ * sessionStore?: { read: () => Promise<any>, write: (data: any) => Promise<void> } | null,
19
+ * debug?: boolean,
20
+ * logger?: any
21
+ * }} options
22
+ */
23
+ constructor({ email, password, device, fetchImpl = globalThis.fetch, sessionStore = null, debug = false, logger = null }) {
24
+ if (!fetchImpl) {
25
+ throw new HonAuthError("A fetch implementation is required");
26
+ }
27
+ this.email = email || "";
28
+ this.password = password || "";
29
+ this.device = device;
30
+ this.fetch = fetchImpl;
31
+ this.sessionStore = sessionStore;
32
+ this.debug = debug;
33
+ this.logger = logger;
34
+ this.cookieJar = new CookieJar();
35
+ this.calledUrls = [];
36
+ this.auth = {
37
+ accessToken: "",
38
+ refreshToken: "",
39
+ sessionToken: "",
40
+ cognitoToken: "",
41
+ idToken: "",
42
+ expiresAt: ""
43
+ };
44
+ }
45
+
46
+ get accessToken() {
47
+ return this.auth.accessToken;
48
+ }
49
+
50
+ get refreshToken() {
51
+ return this.auth.refreshToken;
52
+ }
53
+
54
+ get sessionToken() {
55
+ return this.auth.sessionToken || this.auth.cognitoToken;
56
+ }
57
+
58
+ get cognitoToken() {
59
+ return this.sessionToken;
60
+ }
61
+
62
+ get idToken() {
63
+ return this.auth.idToken;
64
+ }
65
+
66
+ tokenExpiresSoon() {
67
+ return isExpired(this.auth.expiresAt, TOKEN_EXPIRE_WARNING_MS);
68
+ }
69
+
70
+ tokenIsExpired() {
71
+ return isExpired(this.auth.expiresAt);
72
+ }
73
+
74
+ async initialize() {
75
+ this.logger?.log("Trying to reuse saved session...");
76
+ if (await this.tryLoadSession()) {
77
+ this.logger?.log("Saved session reused");
78
+ return;
79
+ }
80
+ this.logger?.log("Saved session unavailable, using full login");
81
+ await this.authenticate();
82
+ }
83
+
84
+ async tryLoadSession() {
85
+ if (!this.sessionStore) {
86
+ return false;
87
+ }
88
+ const session = await this.sessionStore.read();
89
+ if (!session || !session.refreshToken) {
90
+ return false;
91
+ }
92
+ this.auth.refreshToken = session.refreshToken || "";
93
+ this.auth.sessionToken = session.sessionToken || session.cognitoToken || "";
94
+ this.auth.cognitoToken = this.auth.sessionToken;
95
+ this.auth.idToken = session.idToken || "";
96
+ this.auth.accessToken = session.accessToken || "";
97
+ this.auth.expiresAt = session.expiresAt || "";
98
+
99
+ if (this.auth.sessionToken && this.auth.idToken && !this.tokenExpiresSoon()) {
100
+ return true;
101
+ }
102
+ return this.refresh();
103
+ }
104
+
105
+ async authenticate() {
106
+ this.clear(false);
107
+ if (!this.email) {
108
+ throw new HonAuthError("An email address must be specified");
109
+ }
110
+ if (!this.password) {
111
+ throw new HonAuthError("A password must be specified");
112
+ }
113
+ try {
114
+ const loginUrl = await this.loadLogin();
115
+ const tokenUrl = await this.login(loginUrl);
116
+ await this.getToken(tokenUrl);
117
+ await this.apiAuth();
118
+ await this.saveSession();
119
+ } catch (error) {
120
+ if (error instanceof NoLoginNeeded) {
121
+ return;
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ async refresh(refreshToken = "") {
128
+ const operation = this.logger?.start("Refreshing auth tokens...");
129
+ if (refreshToken) {
130
+ this.auth.refreshToken = refreshToken;
131
+ }
132
+ if (!this.auth.refreshToken) {
133
+ operation?.success("Refresh skipped");
134
+ return false;
135
+ }
136
+ const params = new URLSearchParams({
137
+ client_id: constants.CLIENT_ID,
138
+ refresh_token: this.auth.refreshToken,
139
+ grant_type: "refresh_token"
140
+ });
141
+ const response = await this.request(`${constants.AUTH_API}/services/oauth2/token?${params}`, {
142
+ method: "POST"
143
+ });
144
+ if (response.status >= 400) {
145
+ if (this.debug) {
146
+ await this.logAuthError(response, false);
147
+ }
148
+ operation?.failure("Refresh failed");
149
+ return false;
150
+ }
151
+ const data = /** @type {{ id_token?: string, access_token?: string }} */ (await response.json());
152
+ this.auth.idToken = data.id_token || "";
153
+ this.auth.accessToken = data.access_token || "";
154
+ this.auth.expiresAt = new Date(Date.now() + TOKEN_EXPIRES_AFTER_MS).toISOString();
155
+ await this.apiAuth();
156
+ await this.saveSession();
157
+ operation?.success("Refresh success");
158
+ return true;
159
+ }
160
+
161
+ async loadLogin() {
162
+ const loginUrl = await this.introduce();
163
+ const redirected = await this.handleRedirects(loginUrl);
164
+ return this.loginUrl(redirected);
165
+ }
166
+
167
+ async introduce() {
168
+ const redirectUri = encodeURIComponent(`${constants.APP}://mobilesdk/detect/oauth/done`);
169
+ const params = {
170
+ response_type: "token+id_token",
171
+ client_id: constants.CLIENT_ID,
172
+ redirect_uri: redirectUri,
173
+ display: "touch",
174
+ scope: "api openid refresh_token web",
175
+ nonce: generateNonce()
176
+ };
177
+ const paramsText = Object.entries(params).map(([key, value]) => `${key}=${value}`).join("&");
178
+ const response = await this.request(`${constants.AUTH_API}/services/oauth2/authorize/expid_Login?${paramsText}`);
179
+ const text = await response.text();
180
+ this.auth.expiresAt = new Date(Date.now() + TOKEN_EXPIRES_AFTER_MS).toISOString();
181
+ const loginUrl = text.match(/(?:url|href) ?= ?'(.+?)'/);
182
+ if (!loginUrl) {
183
+ if (text.includes("oauth/done#access_token=") && this.parseTokenData(text)) {
184
+ await this.apiAuth();
185
+ await this.saveSession();
186
+ throw new NoLoginNeeded();
187
+ }
188
+ await this.logAuthError(response);
189
+ throw new HonAuthError("Missing login URL");
190
+ }
191
+ const nextLoginUrl = loginUrl[1] || "";
192
+ if (nextLoginUrl.startsWith("/NewhOnLogin")) {
193
+ return `${constants.AUTH_API}/s/login${nextLoginUrl}`;
194
+ }
195
+ return nextLoginUrl;
196
+ }
197
+
198
+ async manualRedirect(url) {
199
+ const response = await this.request(url, { redirect: "manual" });
200
+ return response.headers.get("location") || url;
201
+ }
202
+
203
+ async handleRedirects(loginUrl) {
204
+ const redirect1 = await this.manualRedirect(loginUrl);
205
+ const redirect2 = await this.manualRedirect(redirect1);
206
+ return `${redirect2}&System=IoT_Mobile_App&RegistrationSubChannel=hOn`;
207
+ }
208
+
209
+ async loginUrl(loginUrl) {
210
+ try {
211
+ const response = await this.request(loginUrl, { headers: { "user-agent": constants.USER_AGENT } });
212
+ const text = await response.text();
213
+ const context = text.match(/"fwuid":"(.*?)","loaded":(\{.*?\})/);
214
+ if (!context) {
215
+ await this.logAuthError(response);
216
+ throw new HonAuthError("Missing login context");
217
+ }
218
+ return {
219
+ loginUrl,
220
+ urlPath: loginUrl.replace(constants.AUTH_API, ""),
221
+ fwUid: context[1] || "",
222
+ loaded: JSON.parse(context[2] || "{}")
223
+ };
224
+ } catch (error) {
225
+ if (error instanceof NoLoginNeeded) {
226
+ return null;
227
+ }
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ async login(loginData) {
233
+ if (!loginData) {
234
+ throw new NoLoginNeeded();
235
+ }
236
+ const startUrl = decodeURIComponent(loginData.urlPath.split("startURL=").pop()).split("%3D")[0];
237
+ const action = {
238
+ id: "79;a",
239
+ descriptor: "apex://LightningLoginCustomController/ACTION$login",
240
+ callingDescriptor: "markup://c:loginForm",
241
+ params: {
242
+ username: this.email,
243
+ password: this.password,
244
+ startUrl
245
+ }
246
+ };
247
+ const data = {
248
+ message: { actions: [action] },
249
+ "aura.context": {
250
+ mode: "PROD",
251
+ fwuid: loginData.fwUid,
252
+ app: "siteforce:loginApp2",
253
+ loaded: loginData.loaded,
254
+ dn: [],
255
+ globals: {},
256
+ uad: false
257
+ },
258
+ "aura.pageURI": loginData.urlPath,
259
+ "aura.token": null
260
+ };
261
+ const body = Object.entries(data)
262
+ .map(([key, value]) => `${key}=${encodeURIComponent(JSON.stringify(value))}`)
263
+ .join("&");
264
+ const params = new URLSearchParams({ r: "3", "other.LightningLoginCustom.login": "1" });
265
+ const response = await this.request(`${constants.AUTH_API}/s/sfsites/aura?${params}`, {
266
+ method: "POST",
267
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
268
+ body
269
+ });
270
+ if (response.status === 200) {
271
+ try {
272
+ const result = /** @type {{ events?: Array<{ attributes?: { values?: { url?: string } } }> }} */ (await response.json());
273
+ const url = result.events?.[0]?.attributes?.values?.url;
274
+ if (url) {
275
+ return url;
276
+ }
277
+ } catch {
278
+ // handled below
279
+ }
280
+ }
281
+ await this.logAuthError(response);
282
+ }
283
+
284
+ async getToken(url) {
285
+ let response = await this.request(url);
286
+ if (response.status !== 200) {
287
+ await this.logAuthError(response);
288
+ }
289
+ let text = await response.text();
290
+ let href = firstHref(text);
291
+ if (!href) {
292
+ await this.logAuthError(response);
293
+ }
294
+ if (href.includes("ProgressiveLogin")) {
295
+ response = await this.request(href.startsWith("http") ? href : constants.AUTH_API + href);
296
+ if (response.status !== 200) {
297
+ await this.logAuthError(response);
298
+ }
299
+ text = await response.text();
300
+ href = firstHref(text);
301
+ }
302
+ const finalUrl = href.startsWith("http") ? href : constants.AUTH_API + href;
303
+ response = await this.request(finalUrl);
304
+ if (response.status !== 200 || !this.parseTokenData(await response.text())) {
305
+ await this.logAuthError(response);
306
+ }
307
+ }
308
+
309
+ parseTokenData(text) {
310
+ const accessToken = text.match(/access_token=(.*?)&/);
311
+ const refreshToken = text.match(/refresh_token=(.*?)&/);
312
+ const idToken = text.match(/id_token=(.*?)&/);
313
+ if (accessToken) {
314
+ this.auth.accessToken = accessToken[1];
315
+ }
316
+ if (refreshToken) {
317
+ this.auth.refreshToken = decodeURIComponent(refreshToken[1]);
318
+ }
319
+ if (idToken) {
320
+ this.auth.idToken = idToken[1];
321
+ }
322
+ return Boolean(accessToken && refreshToken && idToken);
323
+ }
324
+
325
+ async apiAuth() {
326
+ if (!this.auth.idToken) {
327
+ throw new HonAuthError("Missing id token");
328
+ }
329
+ const response = await this.request(`${constants.API_URL}/auth/v1/login`, {
330
+ method: "POST",
331
+ headers: { "id-token": this.auth.idToken, "Content-Type": "application/json" },
332
+ body: JSON.stringify(this.device.get())
333
+ });
334
+ /** @type {{ cognitoUser?: { Token?: string } } | undefined} */
335
+ let data;
336
+ try {
337
+ data = /** @type {{ cognitoUser?: { Token?: string } }} */ (await response.json());
338
+ } catch (error) {
339
+ await this.logAuthError(response);
340
+ }
341
+ this.auth.sessionToken = data?.cognitoUser?.Token || "";
342
+ this.auth.cognitoToken = this.auth.sessionToken;
343
+ if (!this.auth.sessionToken) {
344
+ throw new HonAuthError("Can't get API session token", data);
345
+ }
346
+ this.auth.expiresAt = new Date(Date.now() + TOKEN_EXPIRES_AFTER_MS).toISOString();
347
+ return true;
348
+ }
349
+
350
+ async request(url, options = {}) {
351
+ const headers = new Headers(options.headers || {});
352
+ if (!headers.has("user-agent")) {
353
+ headers.set("user-agent", constants.USER_AGENT);
354
+ }
355
+ const cookie = this.cookieJar.header();
356
+ if (cookie && !headers.has("cookie")) {
357
+ headers.set("cookie", cookie);
358
+ }
359
+ const response = await this.fetch(url, { ...options, headers });
360
+ this.cookieJar.addFromResponse(response.headers);
361
+ this.calledUrls.push([response.status, response.url || String(url)]);
362
+ return response;
363
+ }
364
+
365
+ async logAuthError(response, fail = true) {
366
+ let body = "";
367
+ try {
368
+ body = await response.text();
369
+ } catch {
370
+ body = "";
371
+ }
372
+ const details = {
373
+ calledUrls: this.calledUrls,
374
+ status: response.status,
375
+ url: response.url,
376
+ body
377
+ };
378
+ if (fail) {
379
+ throw new HonAuthError("hOn authentication failed", details);
380
+ }
381
+ return details;
382
+ }
383
+
384
+ async saveSession() {
385
+ if (!this.sessionStore) {
386
+ return;
387
+ }
388
+ await this.sessionStore.write({
389
+ refreshToken: this.auth.refreshToken,
390
+ sessionToken: this.auth.sessionToken,
391
+ idToken: this.auth.idToken,
392
+ accessToken: this.auth.accessToken,
393
+ expiresAt: this.auth.expiresAt,
394
+ updatedAt: new Date().toISOString()
395
+ });
396
+ }
397
+
398
+ clear(clearSession = true) {
399
+ this.cookieJar.clear();
400
+ this.calledUrls = [];
401
+ this.auth.accessToken = "";
402
+ this.auth.idToken = "";
403
+ this.auth.sessionToken = "";
404
+ this.auth.cognitoToken = "";
405
+ if (clearSession) {
406
+ this.auth.refreshToken = "";
407
+ this.auth.expiresAt = "";
408
+ }
409
+ }
410
+ }
411
+
412
+ class NoLoginNeeded extends Error {}
413
+
414
+ function generateNonce() {
415
+ const nonce = crypto.randomBytes(16).toString("hex");
416
+ return `${nonce.slice(0, 8)}-${nonce.slice(8, 12)}-${nonce.slice(12, 16)}-${nonce.slice(16, 20)}-${nonce.slice(20)}`;
417
+ }
418
+
419
+ function firstHref(text) {
420
+ const match = text.match(/href\s*=\s*["'](.+?)["']/);
421
+ return match ? match[1] : "";
422
+ }
423
+
424
+ module.exports = { HonAuth, TOKEN_EXPIRES_AFTER_MS, TOKEN_EXPIRE_WARNING_MS };