mysa-js-sdk 2.1.2 → 3.1.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.
package/dist/index.cjs CHANGED
@@ -59,20 +59,32 @@ __export(index_exports, {
59
59
  MysaApiError: () => MysaApiError,
60
60
  OutMessageType: () => OutMessageType,
61
61
  UnauthenticatedError: () => UnauthenticatedError,
62
+ UnknownDeviceError: () => UnknownDeviceError,
63
+ UnsupportedFanSpeedError: () => UnsupportedFanSpeedError,
62
64
  VoidLogger: () => VoidLogger
63
65
  });
64
66
  module.exports = __toCommonJS(index_exports);
65
67
 
66
68
  // src/api/Errors.ts
67
69
  var UnauthenticatedError = class extends Error {
70
+ /**
71
+ * The underlying failure that prevented authentication.
72
+ *
73
+ * Callers need it to tell a credential rejection apart from a transport or service failure, since both surface as
74
+ * this error. Declared explicitly rather than passed to `super` so the SDK keeps compiling against its current lib,
75
+ * which predates the two-argument `Error` constructor.
76
+ */
77
+ cause;
68
78
  /**
69
79
  * Creates a new UnauthenticatedError instance.
70
80
  *
71
81
  * @param message - The error message
82
+ * @param cause - The underlying failure that prevented authentication, if any.
72
83
  */
73
- constructor(message) {
84
+ constructor(message, cause) {
74
85
  super(message);
75
86
  this.name = "UnauthenticatedError";
87
+ this.cause = cause;
76
88
  }
77
89
  };
78
90
  var MysaApiError = class extends Error {
@@ -94,6 +106,40 @@ var MysaApiError = class extends Error {
94
106
  this.statusText = apiResponse.statusText;
95
107
  }
96
108
  };
109
+ var UnknownDeviceError = class extends Error {
110
+ /**
111
+ * Creates a new UnknownDeviceError instance.
112
+ *
113
+ * @param deviceId - The device id that could not be resolved
114
+ */
115
+ constructor(deviceId) {
116
+ super(`Unknown device id '${deviceId}': no such device on this account.`);
117
+ this.deviceId = deviceId;
118
+ this.name = "UnknownDeviceError";
119
+ }
120
+ deviceId;
121
+ };
122
+ var UnsupportedFanSpeedError = class extends Error {
123
+ /**
124
+ * Creates a new UnsupportedFanSpeedError instance.
125
+ *
126
+ * @param deviceId - The id of the device the command was aimed at.
127
+ * @param fanSpeed - The requested fan speed that the device does not support.
128
+ * @param supportedFanSpeeds - The fan speeds the device does support.
129
+ */
130
+ constructor(deviceId, fanSpeed, supportedFanSpeeds) {
131
+ super(
132
+ `Device '${deviceId}' does not support the '${fanSpeed}' fan speed. Supported fan speeds: ${supportedFanSpeeds.join(", ") || "(none)"}.`
133
+ );
134
+ this.deviceId = deviceId;
135
+ this.fanSpeed = fanSpeed;
136
+ this.supportedFanSpeeds = supportedFanSpeeds;
137
+ this.name = "UnsupportedFanSpeedError";
138
+ }
139
+ deviceId;
140
+ fanSpeed;
141
+ supportedFanSpeeds;
142
+ };
97
143
  var MqttPublishError = class extends Error {
98
144
  /**
99
145
  * Creates a new MqttPublishError instance.
@@ -191,8 +237,9 @@ function parseMqttPayload(payload) {
191
237
  const jsonString = decoder.decode(payload);
192
238
  return JSON.parse(jsonString);
193
239
  } catch (error) {
194
- console.error("Error parsing MQTT payload:", error);
195
- throw new Error("Failed to parse MQTT payload");
240
+ const parseError = new Error("Failed to parse MQTT payload");
241
+ parseError.cause = error;
242
+ throw parseError;
196
243
  }
197
244
  }
198
245
  function serializeMqttPayload(payload) {
@@ -249,11 +296,52 @@ var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
249
296
  var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
250
297
  var RealtimeKeepAliveInterval = import_dayjs.default.duration(5, "minutes");
251
298
  var PublishAckTimeout = import_dayjs.default.duration(30, "seconds");
299
+ var MqttInterruptWindow = import_dayjs.default.duration(30, "seconds");
300
+ var MqttInterruptThreshold = 3;
301
+ var MqttResetBaseDelay = import_dayjs.default.duration(1, "second");
302
+ var MqttResetMaxDelay = import_dayjs.default.duration(30, "seconds");
303
+ var MqttStabilityWindow = import_dayjs.default.duration(60, "seconds");
304
+ var CanonicalFanSpeedOrder = ["auto", "low", "medium", "high", "max"];
305
+ var LegacyFanSpeedSendMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
306
+ var FanSpeedReceiveMap = {
307
+ 1: "auto",
308
+ 2: "low",
309
+ // CodeNum=1117 canonical low
310
+ 3: "low",
311
+ // legacy
312
+ 4: "medium",
313
+ // CodeNum=1117 canonical medium
314
+ 5: "medium",
315
+ // legacy
316
+ 6: "high",
317
+ // CodeNum=1117 canonical high
318
+ 7: "high",
319
+ // legacy
320
+ 8: "max"
321
+ };
322
+ function buildFanSpeedSendMap(device) {
323
+ var _a;
324
+ const fanSpeeds = (_a = device.SupportedCaps) == null ? void 0 : _a.fanSpeeds;
325
+ if (!fanSpeeds || fanSpeeds.length === 0) {
326
+ return LegacyFanSpeedSendMap;
327
+ }
328
+ const map = {};
329
+ CanonicalFanSpeedOrder.forEach((name, index) => {
330
+ if (index < fanSpeeds.length) {
331
+ map[name] = fanSpeeds[index];
332
+ }
333
+ });
334
+ return map;
335
+ }
252
336
  var MysaApiClient = class {
337
+ /** The credentials of the Mysa account this client authenticates as. */
338
+ _credentials;
253
339
  /** The current session object, if any. */
254
340
  _cognitoUserSession;
255
341
  /** The current user object, if any. */
256
342
  _cognitoUser;
343
+ /** The in-flight session acquisition, if any, so that concurrent callers share a single refresh or login. */
344
+ _freshSessionPromise;
257
345
  /** The logger instance used by the client. */
258
346
  _logger;
259
347
  /** The fetcher function used by the client. */
@@ -268,8 +356,21 @@ var MysaApiClient = class {
268
356
  _mqttInterrupts = [];
269
357
  /** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
270
358
  _mqttResetInProgress = false;
359
+ /** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
360
+ _mqttGeneration = 0;
361
+ /** Consecutive forced resets without an intervening stable period; drives reset backoff. */
362
+ _mqttConsecutiveResets = 0;
363
+ /** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
364
+ _mqttLastConnectionSuccessAt;
365
+ /** Timer that clears the consecutive-reset counter once a connection stays healthy. */
366
+ _mqttStabilityTimer;
271
367
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
272
368
  _realtimeDeviceIds = /* @__PURE__ */ new Map();
369
+ /**
370
+ * Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
371
+ * every reconnect so a debug capture survives connection resets.
372
+ */
373
+ _rawTopicCaptures = /* @__PURE__ */ new Map();
273
374
  /** The cached devices object, if any. */
274
375
  _cachedDevices;
275
376
  /**
@@ -278,89 +379,63 @@ var MysaApiClient = class {
278
379
  * @see {@link MysaApiClientEventTypes} for the possible events and their payloads.
279
380
  */
280
381
  emitter = new EventEmitter();
281
- /**
282
- * Gets the persistable session object.
283
- *
284
- * @returns The current persistable session object, if any.
285
- */
286
- get session() {
287
- if (!this._cognitoUserSession || !this._cognitoUser) {
288
- return void 0;
289
- }
290
- return {
291
- username: this._cognitoUser.getUsername(),
292
- idToken: this._cognitoUserSession.getIdToken().getJwtToken(),
293
- accessToken: this._cognitoUserSession.getAccessToken().getJwtToken(),
294
- refreshToken: this._cognitoUserSession.getRefreshToken().getToken()
295
- };
296
- }
297
- /**
298
- * Returns whether the client currently has an active session.
299
- *
300
- * @returns True if the client has an active session, false otherwise.
301
- */
302
- get isAuthenticated() {
303
- return !!this.session;
304
- }
305
382
  /**
306
383
  * Constructs a new instance of the MysaApiClient.
307
384
  *
308
- * @param session - The persistable session object, if any.
385
+ * @param credentials - The credentials of the Mysa account to authenticate as.
309
386
  * @param options - The options for the client.
310
387
  */
311
- constructor(session, options) {
388
+ constructor(credentials, options) {
389
+ this._credentials = credentials;
312
390
  this._logger = (options == null ? void 0 : options.logger) || new VoidLogger();
313
391
  this._fetcher = (options == null ? void 0 : options.fetcher) || fetch;
314
- if (session) {
315
- this._cognitoUser = new import_amazon_cognito_identity_js.CognitoUser({
316
- Username: session.username,
317
- Pool: new import_amazon_cognito_identity_js.CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
318
- });
319
- this._cognitoUserSession = new import_amazon_cognito_identity_js.CognitoUserSession({
320
- IdToken: new import_amazon_cognito_identity_js.CognitoIdToken({ IdToken: session.idToken }),
321
- AccessToken: new import_amazon_cognito_identity_js.CognitoAccessToken({ AccessToken: session.accessToken }),
322
- RefreshToken: new import_amazon_cognito_identity_js.CognitoRefreshToken({ RefreshToken: session.refreshToken })
323
- });
324
- }
325
392
  }
326
393
  /**
327
- * Logs in the user with the given email address and password.
394
+ * Ensures the client has a usable session, logging in with the credentials it was constructed with if needed.
328
395
  *
329
- * This method authenticates the user with Mysa's Cognito user pool and establishes a session that can be used for
330
- * subsequent API calls. Upon successful login, a 'sessionChanged' event is emitted.
396
+ * Calling this method is optional: the client authenticates on demand before its first API call, and re-authenticates
397
+ * on its own whenever its session can no longer be refreshed. Call it explicitly at startup to fail fast on invalid
398
+ * credentials instead of on the first API call. It is a no-op when the current session is still usable.
331
399
  *
332
400
  * @example
333
401
  *
334
402
  * ```typescript
335
403
  * try {
336
- * await client.login('user@example.com', 'password123');
404
+ * await client.login();
337
405
  * console.log('Login successful!');
338
406
  * } catch (error) {
339
407
  * console.error('Login failed:', error.message);
340
408
  * }
341
409
  * ```
342
410
  *
343
- * @param emailAddress - The email address of the user.
344
- * @param password - The password of the user.
411
+ * @throws {@link UnauthenticatedError} When authentication fails due to invalid credentials or network issues.
412
+ */
413
+ async login() {
414
+ await this._getFreshSession();
415
+ }
416
+ /**
417
+ * Authenticates with Mysa's Cognito user pool and replaces the current session, if any.
418
+ *
419
+ * @returns A promise that resolves to the newly established session.
345
420
  * @throws {@link Error} When authentication fails due to invalid credentials or network issues.
346
421
  */
347
- async login(emailAddress, password) {
422
+ _login() {
348
423
  this._cognitoUser = void 0;
349
424
  this._cognitoUserSession = void 0;
350
425
  this._mqttClientId = void 0;
351
426
  this._mqttInterrupts = [];
352
- this.emitter.emit("sessionChanged", this.session);
427
+ this._mqttConsecutiveResets = 0;
428
+ const { username, password } = this._credentials;
353
429
  return new Promise((resolve, reject) => {
354
430
  const user = new import_amazon_cognito_identity_js.CognitoUser({
355
- Username: emailAddress,
431
+ Username: username,
356
432
  Pool: new import_amazon_cognito_identity_js.CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
357
433
  });
358
- user.authenticateUser(new import_amazon_cognito_identity_js.AuthenticationDetails({ Username: emailAddress, Password: password }), {
434
+ user.authenticateUser(new import_amazon_cognito_identity_js.AuthenticationDetails({ Username: username, Password: password }), {
359
435
  onSuccess: (session) => {
360
436
  this._cognitoUser = user;
361
437
  this._cognitoUserSession = session;
362
- this.emitter.emit("sessionChanged", this.session);
363
- resolve();
438
+ resolve(session);
364
439
  },
365
440
  onFailure: (err) => {
366
441
  reject(err);
@@ -385,7 +460,7 @@ var MysaApiClient = class {
385
460
  *
386
461
  * @returns A promise that resolves to the list of devices.
387
462
  * @throws {@link MysaApiError} When the API request fails.
388
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
463
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
389
464
  */
390
465
  async getDevices() {
391
466
  this._logger.debug(`Fetching devices...`);
@@ -419,7 +494,7 @@ var MysaApiClient = class {
419
494
  *
420
495
  * @param deviceId - The ID of the device to get the serial number for.
421
496
  * @returns A promise that resolves to the serial number, or undefined if not found.
422
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
497
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
423
498
  */
424
499
  async getDeviceSerialNumber(deviceId) {
425
500
  var _a;
@@ -457,7 +532,7 @@ var MysaApiClient = class {
457
532
  *
458
533
  * @returns A promise that resolves to the firmware information for all devices.
459
534
  * @throws {@link MysaApiError} When the API request fails.
460
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
535
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
461
536
  */
462
537
  async getDeviceFirmwares() {
463
538
  this._logger.debug(`Fetching device firmwares...`);
@@ -477,7 +552,7 @@ var MysaApiClient = class {
477
552
  *
478
553
  * @returns A promise that resolves to the current state of all devices.
479
554
  * @throws {@link MysaApiError} When the API request fails.
480
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
555
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
481
556
  */
482
557
  async getDeviceStates() {
483
558
  this._logger.debug(`Fetching device states...`);
@@ -497,7 +572,7 @@ var MysaApiClient = class {
497
572
  *
498
573
  * @returns A promise that resolves to the homes information.
499
574
  * @throws {@link MysaApiError} When the API request fails.
500
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
575
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
501
576
  */
502
577
  async getHomes() {
503
578
  this._logger.debug(`Fetching homes...`);
@@ -539,29 +614,37 @@ var MysaApiClient = class {
539
614
  * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
540
615
  * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
541
616
  * unchanged).
542
- * @throws {@link UnauthenticatedError} When the user is not authenticated.
617
+ * @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
618
+ * @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
619
+ * @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
543
620
  * @throws {@link Error} When MQTT connection or command sending fails.
544
621
  */
545
622
  async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
546
- var _a;
547
623
  this._logger.debug(`Setting device state for '${deviceId}'`);
548
624
  if (!this._cachedDevices) {
549
625
  this._cachedDevices = await this.getDevices();
550
626
  }
627
+ if (!Object.prototype.hasOwnProperty.call(this._cachedDevices.DevicesObj, deviceId)) {
628
+ throw new UnknownDeviceError(deviceId);
629
+ }
551
630
  const device = this._cachedDevices.DevicesObj[deviceId];
631
+ await this._getFreshSession();
552
632
  this._logger.debug(`Initializing MQTT connection...`);
553
633
  const mqttConnection = await this._getMqttConnection();
554
634
  const now = (0, import_dayjs.default)();
555
635
  this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
556
636
  const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
557
- const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
637
+ const fanSpeedMap = buildFanSpeedSendMap(device);
638
+ if (fanSpeed !== void 0 && fanSpeedMap[fanSpeed] === void 0) {
639
+ throw new UnsupportedFanSpeedError(deviceId, fanSpeed, Object.keys(fanSpeedMap));
640
+ }
558
641
  const payload = serializeMqttPayload({
559
642
  msg: 44 /* CHANGE_DEVICE_STATE */,
560
643
  id: now.valueOf(),
561
644
  time: now.unix(),
562
645
  ver: "1.0",
563
646
  src: {
564
- ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
647
+ ref: this._cognitoUser.getUsername(),
565
648
  type: 100
566
649
  },
567
650
  dest: {
@@ -571,7 +654,7 @@ var MysaApiClient = class {
571
654
  resp: 2,
572
655
  body: {
573
656
  ver: 1,
574
- type: device.Model.startsWith("BB-V1") || device.Model.startsWith("v1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
657
+ type: device.Model.startsWith("BB-V1") || device.Model.startsWith("v1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("INF-V1") ? 3 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
575
658
  cmd: [
576
659
  {
577
660
  tm: -1,
@@ -675,37 +758,102 @@ var MysaApiClient = class {
675
758
  clearInterval(timer);
676
759
  this._realtimeDeviceIds.delete(deviceId);
677
760
  }
761
+ /**
762
+ * Subscribes to raw MQTT topic filters and relays every message verbatim.
763
+ *
764
+ * Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
765
+ * request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
766
+ * arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
767
+ * notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
768
+ * shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
769
+ * implementation needs.
770
+ *
771
+ * The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
772
+ * mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
773
+ *
774
+ * Registered filters are re-subscribed automatically after a reconnect.
775
+ *
776
+ * @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
777
+ * policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
778
+ * `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
779
+ * @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
780
+ * @throws {@link Error} When the MQTT connection cannot be established.
781
+ */
782
+ async startRawTopicCapture(topicFilters, handler) {
783
+ this._logger.info(`Starting raw topic capture for ${topicFilters.length} filter(s)`);
784
+ const connection = await this._getMqttConnection();
785
+ const decoder = new TextDecoder("utf-8");
786
+ for (const filter of topicFilters) {
787
+ this._rawTopicCaptures.set(filter, handler);
788
+ this._logger.debug(`Subscribing to raw topic filter '${filter}'...`);
789
+ try {
790
+ const result = await connection.subscribe(filter, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (topic, payload) => {
791
+ handler(topic, decoder.decode(payload));
792
+ });
793
+ this._logger.debug(
794
+ `Raw subscribe to '${filter}' granted (topic='${result.topic}', qos=${result.qos}, error_code=${result.error_code ?? 0})`
795
+ );
796
+ } catch (error) {
797
+ this._logger.warn(`Failed to subscribe to raw topic filter '${filter}'`, error);
798
+ }
799
+ }
800
+ }
678
801
  /**
679
802
  * Ensures a valid, non-expired session is available.
680
803
  *
681
804
  * This method checks if the current session is valid and not expired. If the session is expired, it automatically
682
- * refreshes it using the refresh token.
805
+ * refreshes it using the refresh token. If there is no session yet, or if the refresh token has itself expired or
806
+ * been revoked, the client logs back in with its credentials.
807
+ *
808
+ * Concurrent callers share a single in-flight acquisition, so a burst of API calls never triggers more than one
809
+ * refresh or login.
683
810
  *
684
811
  * @returns A promise that resolves to a valid CognitoUserSession.
685
- * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
812
+ * @throws {@link UnauthenticatedError} When neither refreshing nor logging back in succeeds.
686
813
  */
687
814
  async _getFreshSession() {
688
- if (!this._cognitoUser || !this._cognitoUserSession) {
689
- throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
690
- }
691
- if (this._cognitoUserSession.isValid() && import_dayjs.default.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
815
+ var _a;
816
+ if (this._cognitoUser && ((_a = this._cognitoUserSession) == null ? void 0 : _a.isValid()) && import_dayjs.default.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()) {
692
817
  this._logger.debug("Session is valid, no need to refresh");
693
- return Promise.resolve(this._cognitoUserSession);
818
+ return this._cognitoUserSession;
694
819
  }
695
- this._logger.debug("Session is not valid or expired, refreshing...");
696
- return new Promise((resolve, reject) => {
697
- this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
698
- if (error) {
699
- this._logger.error("Failed to refresh session:", error);
700
- reject(new UnauthenticatedError("Unable to refresh the authentication session."));
701
- } else {
702
- this._logger.debug("Session refreshed successfully");
703
- this._cognitoUserSession = session;
704
- this.emitter.emit("sessionChanged", this.session);
705
- resolve(session);
706
- }
707
- });
820
+ this._freshSessionPromise ??= this._acquireFreshSession().finally(() => {
821
+ this._freshSessionPromise = void 0;
708
822
  });
823
+ return this._freshSessionPromise;
824
+ }
825
+ /**
826
+ * Refreshes the current session, falling back to a full login when it cannot be refreshed.
827
+ *
828
+ * @returns A promise that resolves to a valid CognitoUserSession.
829
+ * @throws {@link UnauthenticatedError} When logging back in fails.
830
+ */
831
+ async _acquireFreshSession() {
832
+ if (this._cognitoUser && this._cognitoUserSession) {
833
+ this._logger.debug("Session is not valid or expired, refreshing...");
834
+ try {
835
+ return await new Promise((resolve, reject) => {
836
+ this._cognitoUser.refreshSession(this._cognitoUserSession.getRefreshToken(), (error, session) => {
837
+ if (error) {
838
+ reject(error);
839
+ } else {
840
+ this._logger.debug("Session refreshed successfully");
841
+ this._cognitoUserSession = session;
842
+ resolve(session);
843
+ }
844
+ });
845
+ });
846
+ } catch (error) {
847
+ this._logger.warn("Failed to refresh session, logging back in:", error);
848
+ }
849
+ }
850
+ try {
851
+ this._logger.info("Logging in...");
852
+ return await this._login();
853
+ } catch (error) {
854
+ this._logger.error("Failed to log in:", error);
855
+ throw new UnauthenticatedError("Unable to establish an authentication session.", error);
856
+ }
709
857
  }
710
858
  /**
711
859
  * Establishes and returns an MQTT connection for real-time communication.
@@ -847,11 +995,11 @@ var MysaApiClient = class {
847
995
  throw new Error("MQTT credentials are already expired.");
848
996
  }
849
997
  if (!this._mqttClientId) {
850
- const username = ((_a = this.session) == null ? void 0 : _a.username) ?? "anon";
998
+ const username = ((_a = this._cognitoUser) == null ? void 0 : _a.getUsername()) ?? "anon";
851
999
  const usernameHash = (0, import_crypto.hash)("sha1", username);
852
1000
  this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
853
1001
  }
854
- const builder = import_aws_iot_device_sdk_v2.iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(this._mqttClientId).with_clean_session(false).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4).with_reconnect_min_sec(1).with_reconnect_max_sec(30);
1002
+ const builder = import_aws_iot_device_sdk_v2.iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets().with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken).with_endpoint(MqttEndpoint).with_client_id(this._mqttClientId).with_clean_session(true).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4).with_reconnect_min_sec(3).with_reconnect_max_sec(30);
855
1003
  const config = builder.build();
856
1004
  config.websocket_handshake_transform = async (request, done) => {
857
1005
  try {
@@ -894,89 +1042,174 @@ var MysaApiClient = class {
894
1042
  };
895
1043
  const client = new import_aws_iot_device_sdk_v2.mqtt.MqttClient();
896
1044
  const connection = client.new_connection(config);
897
- connection.on("connect", () => {
898
- this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
1045
+ const generation = ++this._mqttGeneration;
1046
+ connection.on("connect", (sessionPresent) => {
1047
+ if (generation !== this._mqttGeneration) return;
1048
+ this._logger.debug(`MQTT connect (clientId=${this._mqttClientId}, sessionPresent=${sessionPresent})`);
899
1049
  });
900
- connection.on("connection_success", () => {
901
- this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
1050
+ connection.on("connection_success", (result) => {
1051
+ if (generation !== this._mqttGeneration) return;
1052
+ this._mqttLastConnectionSuccessAt = Date.now();
1053
+ this._logger.debug(
1054
+ `MQTT connection_success (clientId=${this._mqttClientId}, sessionPresent=${result == null ? void 0 : result.session_present}, reasonCode=${result == null ? void 0 : result.reason_code})`
1055
+ );
1056
+ this._armMqttStabilityTimer(generation);
902
1057
  });
903
1058
  connection.on("connection_failure", (e) => {
1059
+ if (generation !== this._mqttGeneration) return;
904
1060
  this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
905
1061
  });
906
- connection.on("interrupt", async (e) => {
1062
+ connection.on("interrupt", (e) => {
907
1063
  var _a2;
908
- this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
1064
+ if (generation !== this._mqttGeneration) return;
1065
+ const dwellMs = this._mqttLastConnectionSuccessAt !== void 0 ? Date.now() - this._mqttLastConnectionSuccessAt : void 0;
1066
+ this._logger.warn(
1067
+ `MQTT interrupt (clientId=${this._mqttClientId}, dwellMs=${dwellMs ?? "n/a"}, generation=${generation})`,
1068
+ e
1069
+ );
1070
+ this._clearMqttStabilityTimer();
909
1071
  const now = Date.now();
910
- this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 6e4);
1072
+ this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < MqttInterruptWindow.asMilliseconds());
911
1073
  this._mqttInterrupts.push(now);
912
1074
  const areCredentialsExpired = !(((_a2 = this._mqttCredentialsExpiration) == null ? void 0 : _a2.isAfter((0, import_dayjs.default)())) ?? false);
913
- if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
914
- this._mqttResetInProgress = true;
915
- if (this._mqttInterrupts.length > 5) {
916
- this._logger.warn(
917
- `High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
918
- );
919
- } else {
920
- this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
921
- }
922
- this._mqttClientId = void 0;
923
- this._mqttCredentialsExpiration = void 0;
924
- this._mqttInterrupts = [];
925
- this._mqttConnectionPromise = void 0;
926
- try {
927
- connection.removeAllListeners();
928
- await connection.disconnect();
929
- try {
930
- this._logger.debug("Old MQTT connection disconnected; establishing new connection...");
931
- const newConnection = await this._getMqttConnection();
932
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
933
- const topic = `/v1/dev/${deviceId}/out`;
934
- this._logger.debug(`Re-subscribing to ${topic}`);
935
- await newConnection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
936
- this._processMqttMessage(payload);
937
- });
938
- }
939
- this._logger.info("MQTT connection rebuilt successfully after interrupt storm or credentials expiration");
940
- } catch (err) {
941
- this._logger.error("Failed to re-subscribe after interrupt storm or credentials expiration", err);
942
- }
943
- } catch (error) {
944
- this._logger.error("Error during MQTT reset", error);
945
- } finally {
946
- this._mqttResetInProgress = false;
947
- }
1075
+ const isStorm = this._mqttInterrupts.length >= MqttInterruptThreshold;
1076
+ if (isStorm || areCredentialsExpired) {
1077
+ const reason = isStorm ? `High interrupt rate (${this._mqttInterrupts.length} in ${MqttInterruptWindow.asSeconds()}s)` : "Credentials expired";
1078
+ void this._resetMqttConnection(reason);
948
1079
  }
949
1080
  });
950
1081
  connection.on("resume", async (returnCode, sessionPresent) => {
1082
+ if (generation !== this._mqttGeneration) return;
951
1083
  this._logger.info(
952
1084
  `MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
953
1085
  );
954
1086
  if (!sessionPresent) {
955
- this._logger.info("No session present, re-subscribing each device");
956
1087
  try {
957
- for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
958
- const topic = `/v1/dev/${deviceId}/out`;
959
- this._logger.debug(`Re-subscribing to ${topic}`);
960
- await connection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
961
- this._processMqttMessage(payload);
962
- });
963
- }
1088
+ await this._resubscribeAll(connection);
964
1089
  } catch (err) {
965
1090
  this._logger.error("Failed to re-subscribe after resume", err);
966
1091
  }
967
1092
  }
968
1093
  });
969
1094
  connection.on("error", (e) => {
1095
+ if (generation !== this._mqttGeneration) return;
970
1096
  this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
971
1097
  });
972
1098
  connection.on("closed", () => {
1099
+ if (generation !== this._mqttGeneration) return;
973
1100
  this._logger.info("MQTT connection closed");
1101
+ this._clearMqttStabilityTimer();
974
1102
  this._mqttConnectionPromise = void 0;
975
1103
  this._mqttCredentialsExpiration = void 0;
976
1104
  });
977
1105
  await connection.connect();
978
1106
  return connection;
979
1107
  }
1108
+ /**
1109
+ * Re-subscribes every device currently receiving real-time updates on the given connection.
1110
+ *
1111
+ * Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
1112
+ * registers a message handler more than once.
1113
+ *
1114
+ * @param connection - The connection to (re-)establish subscriptions on.
1115
+ */
1116
+ async _resubscribeAll(connection) {
1117
+ for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
1118
+ const topic = `/v1/dev/${deviceId}/out`;
1119
+ this._logger.debug(`Re-subscribing to ${topic}`);
1120
+ await connection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
1121
+ this._processMqttMessage(payload);
1122
+ });
1123
+ }
1124
+ const decoder = new TextDecoder("utf-8");
1125
+ for (const [filter, handler] of Array.from(this._rawTopicCaptures.entries())) {
1126
+ this._logger.debug(`Re-subscribing to raw topic filter '${filter}'`);
1127
+ try {
1128
+ await connection.subscribe(filter, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (topic, payload) => {
1129
+ handler(topic, decoder.decode(payload));
1130
+ });
1131
+ } catch (error) {
1132
+ this._logger.warn(`Failed to re-subscribe to raw topic filter '${filter}'`, error);
1133
+ }
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
1138
+ * consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
1139
+ *
1140
+ * @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
1141
+ * since been replaced.
1142
+ */
1143
+ _armMqttStabilityTimer(generation) {
1144
+ var _a, _b;
1145
+ this._clearMqttStabilityTimer();
1146
+ this._mqttStabilityTimer = setTimeout(() => {
1147
+ if (generation !== this._mqttGeneration) return;
1148
+ if (this._mqttConsecutiveResets > 0) {
1149
+ this._logger.debug("MQTT connection stable; clearing consecutive-reset counter");
1150
+ this._mqttConsecutiveResets = 0;
1151
+ }
1152
+ }, MqttStabilityWindow.asMilliseconds());
1153
+ (_b = (_a = this._mqttStabilityTimer).unref) == null ? void 0 : _b.call(_a);
1154
+ }
1155
+ /** Clears the stability timer, if armed. */
1156
+ _clearMqttStabilityTimer() {
1157
+ if (this._mqttStabilityTimer) {
1158
+ clearTimeout(this._mqttStabilityTimer);
1159
+ this._mqttStabilityTimer = void 0;
1160
+ }
1161
+ }
1162
+ /**
1163
+ * Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
1164
+ * escaping interrupt storms that a plain native reconnect cannot.
1165
+ *
1166
+ * Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
1167
+ * period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
1168
+ * fire-and-forget from an event handler.
1169
+ *
1170
+ * @param reason - Human-readable reason for the reset, used in logs.
1171
+ */
1172
+ async _resetMqttConnection(reason) {
1173
+ if (this._mqttResetInProgress) {
1174
+ return;
1175
+ }
1176
+ this._mqttResetInProgress = true;
1177
+ const connectionToClose = this._mqttConnectionPromise;
1178
+ try {
1179
+ this._mqttConsecutiveResets++;
1180
+ const delayMs = Math.min(
1181
+ MqttResetBaseDelay.asMilliseconds() * Math.pow(2, this._mqttConsecutiveResets - 1),
1182
+ MqttResetMaxDelay.asMilliseconds()
1183
+ );
1184
+ this._logger.warn(
1185
+ `${reason}. Forcing MQTT reset #${this._mqttConsecutiveResets} (new clientId, fresh credentials) after ${Math.round(delayMs)}ms...`
1186
+ );
1187
+ this._mqttClientId = void 0;
1188
+ this._mqttCredentialsExpiration = void 0;
1189
+ this._mqttInterrupts = [];
1190
+ this._clearMqttStabilityTimer();
1191
+ this._mqttConnectionPromise = void 0;
1192
+ if (connectionToClose) {
1193
+ try {
1194
+ const connection = await connectionToClose;
1195
+ connection.removeAllListeners();
1196
+ await connection.disconnect();
1197
+ } catch (err) {
1198
+ this._logger.debug("Error tearing down old MQTT connection during reset (ignored)", err);
1199
+ }
1200
+ }
1201
+ if (delayMs > 0) {
1202
+ await new Promise((r) => setTimeout(r, delayMs));
1203
+ }
1204
+ const newConnection = await this._getMqttConnection();
1205
+ await this._resubscribeAll(newConnection);
1206
+ this._logger.info(`MQTT connection rebuilt successfully after reset #${this._mqttConsecutiveResets} (${reason})`);
1207
+ } catch (err) {
1208
+ this._logger.error("Failed to rebuild MQTT connection after reset", err);
1209
+ } finally {
1210
+ this._mqttResetInProgress = false;
1211
+ }
1212
+ }
980
1213
  /**
981
1214
  * Processes incoming MQTT messages and emits appropriate events.
982
1215
  *
@@ -1012,7 +1245,6 @@ var MysaApiClient = class {
1012
1245
  } else if (isMsgOutPayload(parsedPayload)) {
1013
1246
  switch (parsedPayload.msg) {
1014
1247
  case 30 /* DEVICE_AC_STATUS */:
1015
- case 40 /* DEVICE_V2_STATUS */:
1016
1248
  this.emitter.emit("statusChanged", {
1017
1249
  deviceId: parsedPayload.src.ref,
1018
1250
  temperature: parsedPayload.body.ambTemp,
@@ -1021,6 +1253,16 @@ var MysaApiClient = class {
1021
1253
  dutyCycle: parsedPayload.body.dtyCycle
1022
1254
  });
1023
1255
  break;
1256
+ case 40 /* DEVICE_V2_STATUS */:
1257
+ this.emitter.emit("statusChanged", {
1258
+ deviceId: parsedPayload.src.ref,
1259
+ temperature: parsedPayload.body.ambTemp,
1260
+ humidity: parsedPayload.body.hum,
1261
+ setPoint: parsedPayload.body.stpt,
1262
+ dutyCycle: parsedPayload.body.dtyCycle ?? parsedPayload.body.heatStat,
1263
+ floorTemperature: parsedPayload.body.flrSnsrTemp
1264
+ });
1265
+ break;
1024
1266
  case 44 /* DEVICE_STATE_CHANGE */: {
1025
1267
  const modeMap = {
1026
1268
  1: "off",
@@ -1030,18 +1272,11 @@ var MysaApiClient = class {
1030
1272
  5: "fan_only",
1031
1273
  6: "dry"
1032
1274
  };
1033
- const fanSpeedMap = {
1034
- 1: "auto",
1035
- 3: "low",
1036
- 5: "medium",
1037
- 7: "high",
1038
- 8: "max"
1039
- };
1040
1275
  this.emitter.emit("stateChanged", {
1041
1276
  deviceId: parsedPayload.src.ref,
1042
1277
  mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
1043
1278
  setPoint: parsedPayload.body.state.sp,
1044
- fanSpeed: parsedPayload.body.state.fn !== void 0 ? fanSpeedMap[parsedPayload.body.state.fn] : void 0
1279
+ fanSpeed: parsedPayload.body.state.fn !== void 0 ? FanSpeedReceiveMap[parsedPayload.body.state.fn] : void 0
1045
1280
  });
1046
1281
  break;
1047
1282
  }
@@ -1060,6 +1295,8 @@ var MysaApiClient = class {
1060
1295
  MysaApiError,
1061
1296
  OutMessageType,
1062
1297
  UnauthenticatedError,
1298
+ UnknownDeviceError,
1299
+ UnsupportedFanSpeedError,
1063
1300
  VoidLogger
1064
1301
  });
1065
1302
  //# sourceMappingURL=index.cjs.map