mysa-js-sdk 1.3.2 → 1.4.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
4
4
 
5
- [![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-)
5
+ [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-)
6
6
 
7
7
  <!-- ALL-CONTRIBUTORS-BADGE:END -->
8
8
 
@@ -247,6 +247,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
247
247
  <tbody>
248
248
  <tr>
249
249
  <td align="center" valign="top" width="14.28%"><a href="https://github.com/jagmandan"><img src="https://avatars.githubusercontent.com/u/227265405?v=4?s=100" width="100px;" alt="jagmandan"/><br /><sub><b>jagmandan</b></sub></a><br /><a href="https://github.com/bourquep/mysa-js-sdk/commits?author=jagmandan" title="Code">💻</a></td>
250
+ <td align="center" valign="top" width="14.28%"><a href="https://github.com/remiolivier"><img src="https://avatars.githubusercontent.com/u/1379047?v=4?s=100" width="100px;" alt="remiolivier"/><br /><sub><b>remiolivier</b></sub></a><br /><a href="https://github.com/bourquep/mysa-js-sdk/commits?author=remiolivier" title="Code">💻</a></td>
250
251
  </tr>
251
252
  </tbody>
252
253
  </table>
package/dist/index.cjs CHANGED
@@ -54,6 +54,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
54
54
  var index_exports = {};
55
55
  __export(index_exports, {
56
56
  InMessageType: () => InMessageType,
57
+ MqttPublishError: () => MqttPublishError,
57
58
  MysaApiClient: () => MysaApiClient,
58
59
  MysaApiError: () => MysaApiError,
59
60
  OutMessageType: () => OutMessageType,
@@ -93,6 +94,21 @@ var MysaApiError = class extends Error {
93
94
  this.statusText = apiResponse.statusText;
94
95
  }
95
96
  };
97
+ var MqttPublishError = class extends Error {
98
+ /**
99
+ * Creates a new MqttPublishError instance.
100
+ *
101
+ * @param message - A human-readable description of the publish failure.
102
+ * @param attempts - The number of attempts that were made before giving up.
103
+ * @param original - The original error object thrown by the underlying MQTT library (optional).
104
+ */
105
+ constructor(message, attempts, original) {
106
+ super(message);
107
+ this.attempts = attempts;
108
+ this.original = original;
109
+ this.name = "MqttPublishError";
110
+ }
111
+ };
96
112
 
97
113
  // src/api/Logger.ts
98
114
  var VoidLogger = class {
@@ -235,8 +251,8 @@ var MysaApiClient = class {
235
251
  _logger;
236
252
  /** The fetcher function used by the client. */
237
253
  _fetcher;
238
- /** The MQTT connection used for real-time updates. */
239
- _mqttConnection;
254
+ /** A promise that resolves to the MQTT connection used for real-time updates. */
255
+ _mqttConnectionPromise;
240
256
  /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
241
257
  _realtimeDeviceIds = /* @__PURE__ */ new Map();
242
258
  /** The cached devices object, if any. */
@@ -356,7 +372,7 @@ var MysaApiClient = class {
356
372
  */
357
373
  async getDevices() {
358
374
  this._logger.debug(`Fetching devices...`);
359
- const session = await this.getFreshSession();
375
+ const session = await this._getFreshSession();
360
376
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
361
377
  headers: {
362
378
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -391,7 +407,7 @@ var MysaApiClient = class {
391
407
  async getDeviceSerialNumber(deviceId) {
392
408
  var _a;
393
409
  this._logger.debug(`Fetching serial number for device ${deviceId}...`);
394
- const session = await this.getFreshSession();
410
+ const session = await this._getFreshSession();
395
411
  const credentialsProvider = (0, import_credential_providers.fromCognitoIdentityPool)({
396
412
  clientConfig: {
397
413
  region: AwsRegion
@@ -428,7 +444,7 @@ var MysaApiClient = class {
428
444
  */
429
445
  async getDeviceFirmwares() {
430
446
  this._logger.debug(`Fetching device firmwares...`);
431
- const session = await this.getFreshSession();
447
+ const session = await this._getFreshSession();
432
448
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, {
433
449
  headers: {
434
450
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -448,7 +464,7 @@ var MysaApiClient = class {
448
464
  */
449
465
  async getDeviceStates() {
450
466
  this._logger.debug(`Fetching device states...`);
451
- const session = await this.getFreshSession();
467
+ const session = await this._getFreshSession();
452
468
  const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, {
453
469
  headers: {
454
470
  Authorization: `${session.getIdToken().getJwtToken()}`
@@ -476,31 +492,39 @@ var MysaApiClient = class {
476
492
  *
477
493
  * // Set temperature and mode
478
494
  * await client.setDeviceState('device123', 20, 'heat');
495
+ *
496
+ * // Set fan speed
497
+ * await client.setDeviceState('device123', undefined, undefined, 'auto');
479
498
  * ```
480
499
  *
481
500
  * @param deviceId - The ID of the device to control.
482
501
  * @param setPoint - The target temperature set point (optional).
483
- * @param mode - The operating mode to set ('off', 'heat', or undefined to leave unchanged).
502
+ * @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
503
+ * @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
504
+ * unchanged).
484
505
  * @throws {@link UnauthenticatedError} When the user is not authenticated.
485
506
  * @throws {@link Error} When MQTT connection or command sending fails.
486
507
  */
487
- async setDeviceState(deviceId, setPoint, mode) {
508
+ async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
509
+ var _a;
488
510
  this._logger.debug(`Setting device state for '${deviceId}'`);
489
511
  if (!this._cachedDevices) {
490
512
  this._cachedDevices = await this.getDevices();
491
513
  }
492
514
  const device = this._cachedDevices.DevicesObj[deviceId];
493
515
  this._logger.debug(`Initializing MQTT connection...`);
494
- const mqttConnection = await this.getMqttConnection();
516
+ const mqttConnection = await this._getMqttConnection();
495
517
  const now = (0, import_dayjs.default)();
496
518
  this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
519
+ const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
520
+ const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
497
521
  const payload = serializeMqttPayload({
498
522
  msg: 44 /* CHANGE_DEVICE_STATE */,
499
523
  id: now.valueOf(),
500
524
  time: now.unix(),
501
525
  ver: "1.0",
502
526
  src: {
503
- ref: this.session.username,
527
+ ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
504
528
  type: 100
505
529
  },
506
530
  dest: {
@@ -510,17 +534,24 @@ var MysaApiClient = class {
510
534
  resp: 2,
511
535
  body: {
512
536
  ver: 1,
513
- type: device.Model.startsWith("BB-V1") ? 1 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
537
+ type: device.Model.startsWith("BB-V1") ? 1 : device.Model.startsWith("AC-V1") ? 2 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
514
538
  cmd: [
515
539
  {
516
540
  tm: -1,
517
541
  sp: setPoint,
518
- md: mode === "off" ? 1 : mode === "heat" ? 3 : void 0
542
+ md: mode ? modeMap[mode] : void 0,
543
+ fn: fanSpeed ? fanSpeedMap[fanSpeed] : void 0
519
544
  }
520
545
  ]
521
546
  }
522
547
  });
523
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
548
+ try {
549
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
550
+ this._logger.debug(`Device state publish succeeded for '${deviceId}'`);
551
+ } catch (error) {
552
+ this._logger.error(`Failed to set device state for '${deviceId}'`, error);
553
+ throw error;
554
+ }
524
555
  }
525
556
  /**
526
557
  * Starts receiving real-time updates for the specified device.
@@ -550,10 +581,10 @@ var MysaApiClient = class {
550
581
  return;
551
582
  }
552
583
  this._logger.debug(`Initializing MQTT connection...`);
553
- const mqttConnection = await this.getMqttConnection();
584
+ const mqttConnection = await this._getMqttConnection();
554
585
  this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
555
586
  await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_, payload2) => {
556
- this.processMqttMessage(payload2);
587
+ this._processMqttMessage(payload2);
557
588
  });
558
589
  this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`);
559
590
  const payload = serializeMqttPayload({
@@ -562,7 +593,7 @@ var MysaApiClient = class {
562
593
  Timestamp: (0, import_dayjs.default)().unix(),
563
594
  Timeout: RealtimeKeepAliveInterval.asSeconds()
564
595
  });
565
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
596
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
566
597
  const timer = setInterval(async () => {
567
598
  this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
568
599
  const payload2 = serializeMqttPayload({
@@ -571,7 +602,7 @@ var MysaApiClient = class {
571
602
  Timestamp: (0, import_dayjs.default)().unix(),
572
603
  Timeout: RealtimeKeepAliveInterval.asSeconds()
573
604
  });
574
- await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload2, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
605
+ await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload2, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce);
575
606
  }, RealtimeKeepAliveInterval.subtract(10, "seconds").asMilliseconds());
576
607
  this._realtimeDeviceIds.set(deviceId, timer);
577
608
  }
@@ -592,7 +623,7 @@ var MysaApiClient = class {
592
623
  return;
593
624
  }
594
625
  this._logger.debug(`Initializing MQTT connection...`);
595
- const mqttConnection = await this.getMqttConnection();
626
+ const mqttConnection = await this._getMqttConnection();
596
627
  this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
597
628
  await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`);
598
629
  clearInterval(timer);
@@ -607,7 +638,7 @@ var MysaApiClient = class {
607
638
  * @returns A promise that resolves to a valid CognitoUserSession.
608
639
  * @throws {@link UnauthenticatedError} When no session exists or refresh fails.
609
640
  */
610
- async getFreshSession() {
641
+ async _getFreshSession() {
611
642
  if (!this._cognitoUser || !this._cognitoUserSession) {
612
643
  throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
613
644
  }
@@ -639,11 +670,98 @@ var MysaApiClient = class {
639
670
  * @returns A promise that resolves to an active MQTT connection.
640
671
  * @throws {@link Error} When connection establishment fails.
641
672
  */
642
- async getMqttConnection() {
643
- if (this._mqttConnection) {
644
- return this._mqttConnection;
673
+ _getMqttConnection() {
674
+ if (!this._mqttConnectionPromise) {
675
+ this._mqttConnectionPromise = this._createMqttConnection().catch((err) => {
676
+ this._mqttConnectionPromise = void 0;
677
+ throw err;
678
+ });
679
+ }
680
+ return this._mqttConnectionPromise;
681
+ }
682
+ /**
683
+ * Determines whether an MQTT-related error is considered transient and worth retrying.
684
+ *
685
+ * Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
686
+ * other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
687
+ * layer.
688
+ *
689
+ * @param err - The error object thrown by the underlying MQTT operation.
690
+ * @returns True if the error appears transient and a retry should be attempted; false otherwise.
691
+ */
692
+ _isTransientMqttError(err) {
693
+ if (!err || typeof err !== "object") {
694
+ return false;
645
695
  }
646
- const session = await this.getFreshSession();
696
+ const anyErr = err;
697
+ const code = anyErr.error_code || anyErr.error_name || anyErr.error;
698
+ const msg = (anyErr.message || anyErr.error || "").toString();
699
+ const transientMarkers = [
700
+ "AWS_ERROR_MQTT_TIMEOUT",
701
+ "AWS_ERROR_MQTT_NO_CONNECTION",
702
+ "Time limit between request and response",
703
+ "timeout"
704
+ ];
705
+ return transientMarkers.some((m) => code && String(code).includes(m) || msg.includes(m));
706
+ }
707
+ /**
708
+ * Publishes an MQTT message with exponential backoff retries for transient failures.
709
+ *
710
+ * Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
711
+ * jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
712
+ * reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
713
+ *
714
+ * On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
715
+ * original error for higher-level handling.
716
+ *
717
+ * @remarks
718
+ * Retry options fields:
719
+ *
720
+ * - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
721
+ * - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
722
+ *
723
+ * @param connection - The active MQTT client connection used to send the publish.
724
+ * @param topic - The MQTT topic to publish to.
725
+ * @param payload - The serialized payload (binary buffer or Uint8Array).
726
+ * @param qos - The desired MQTT QoS level for the publish.
727
+ * @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
728
+ * @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
729
+ */
730
+ async _publishWithRetry(connection, topic, payload, qos, opts = {}) {
731
+ const maxAttempts = opts.maxAttempts ?? 5;
732
+ const baseDelayMs = opts.baseDelayMs ?? 500;
733
+ let attempt = 0;
734
+ while (true) {
735
+ attempt++;
736
+ try {
737
+ await connection.publish(topic, payload, qos);
738
+ return;
739
+ } catch (err) {
740
+ const isTransient = this._isTransientMqttError(err);
741
+ if (!isTransient || attempt >= maxAttempts) {
742
+ throw new MqttPublishError(`MQTT publish failed after ${attempt} attempts`, attempt, err);
743
+ }
744
+ const JITTER_MIN_FACTOR = 0.75;
745
+ const JITTER_RANGE = 0.5;
746
+ const delay = baseDelayMs * Math.pow(2, attempt - 1) * (JITTER_MIN_FACTOR + Math.random() * JITTER_RANGE);
747
+ this._logger.warn(
748
+ `Transient MQTT publish error on '${topic}' (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.round(
749
+ delay
750
+ )}ms`
751
+ );
752
+ await new Promise((r) => setTimeout(r, delay));
753
+ }
754
+ }
755
+ }
756
+ /**
757
+ * Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
758
+ *
759
+ * @returns A promise that resolves to an active MQTT connection.
760
+ * @throws {@link Error} When connection establishment fails.
761
+ */
762
+ async _createMqttConnection() {
763
+ var _a;
764
+ const session = await this._getFreshSession();
647
765
  const credentialsProvider = (0, import_credential_providers.fromCognitoIdentityPool)({
648
766
  clientConfig: {
649
767
  region: AwsRegion
@@ -655,16 +773,49 @@ var MysaApiClient = class {
655
773
  logger: this._logger
656
774
  });
657
775
  const credentials = await credentialsProvider();
658
- 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(`mysa-js-sdk-${(0, import_dayjs.default)().unix()}`).with_clean_session(true).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4);
776
+ const stableClientId = `mysa-js-sdk-${((_a = this.session) == null ? void 0 : _a.username) ?? ""}`;
777
+ 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(stableClientId).with_clean_session(false).with_keep_alive_seconds(30).with_ping_timeout_ms(3e3).with_protocol_operation_timeout_ms(6e4);
659
778
  const config = builder.build();
660
779
  const client = new import_aws_iot_device_sdk_v2.mqtt.MqttClient();
661
- this._mqttConnection = client.new_connection(config);
662
- this._mqttConnection.on("closed", () => {
780
+ const connection = client.new_connection(config);
781
+ connection.on("connect", () => {
782
+ this._logger.debug("MQTT connect");
783
+ });
784
+ connection.on("connection_success", () => {
785
+ this._logger.debug("MQTT connection_success");
786
+ });
787
+ connection.on("connection_failure", (e) => {
788
+ this._logger.error("MQTT connection_failure", e);
789
+ });
790
+ connection.on("interrupt", (e) => {
791
+ this._logger.warn("MQTT interrupt", e);
792
+ });
793
+ connection.on("resume", async (returnCode, sessionPresent) => {
794
+ this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`);
795
+ if (!sessionPresent) {
796
+ this._logger.info("No session present, re-subscribing each device");
797
+ try {
798
+ for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
799
+ const topic = `/v1/dev/${deviceId}/out`;
800
+ this._logger.debug(`Re-subscribing to ${topic}`);
801
+ await connection.subscribe(topic, import_aws_iot_device_sdk_v2.mqtt.QoS.AtLeastOnce, (_topic, payload) => {
802
+ this._processMqttMessage(payload);
803
+ });
804
+ }
805
+ } catch (err) {
806
+ this._logger.error("Failed to re-subscribe after resume", err);
807
+ }
808
+ }
809
+ });
810
+ connection.on("error", (e) => {
811
+ this._logger.error("MQTT error", e);
812
+ });
813
+ connection.on("closed", () => {
663
814
  this._logger.info("MQTT connection closed");
664
- this._mqttConnection = void 0;
815
+ this._mqttConnectionPromise = void 0;
665
816
  });
666
- await this._mqttConnection.connect();
667
- return this._mqttConnection;
817
+ await connection.connect();
818
+ return connection;
668
819
  }
669
820
  /**
670
821
  * Processes incoming MQTT messages and emits appropriate events.
@@ -675,7 +826,7 @@ var MysaApiClient = class {
675
826
  *
676
827
  * @param payload - The raw MQTT message payload to process.
677
828
  */
678
- processMqttMessage(payload) {
829
+ _processMqttMessage(payload) {
679
830
  try {
680
831
  const parsedPayload = parseMqttPayload(payload);
681
832
  this.emitter.emit("rawRealtimeMessageReceived", parsedPayload);
@@ -709,13 +860,30 @@ var MysaApiClient = class {
709
860
  dutyCycle: parsedPayload.body.dtyCycle
710
861
  });
711
862
  break;
712
- case 44 /* DEVICE_STATE_CHANGE */:
863
+ case 44 /* DEVICE_STATE_CHANGE */: {
864
+ const modeMap = {
865
+ 1: "off",
866
+ 2: "auto",
867
+ 3: "heat",
868
+ 4: "cool",
869
+ 5: "fan_only",
870
+ 6: "dry"
871
+ };
872
+ const fanSpeedMap = {
873
+ 1: "auto",
874
+ 3: "low",
875
+ 5: "medium",
876
+ 7: "high",
877
+ 8: "max"
878
+ };
713
879
  this.emitter.emit("stateChanged", {
714
880
  deviceId: parsedPayload.src.ref,
715
- mode: parsedPayload.body.state.md === 1 ? "off" : parsedPayload.body.state.md === 3 ? "heat" : void 0,
716
- setPoint: parsedPayload.body.state.sp
881
+ mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
882
+ setPoint: parsedPayload.body.state.sp,
883
+ fanSpeed: parsedPayload.body.state.fn !== void 0 ? fanSpeedMap[parsedPayload.body.state.fn] : void 0
717
884
  });
718
885
  break;
886
+ }
719
887
  }
720
888
  }
721
889
  } catch (error) {
@@ -726,6 +894,7 @@ var MysaApiClient = class {
726
894
  // Annotate the CommonJS export names for ESM import in node:
727
895
  0 && (module.exports = {
728
896
  InMessageType,
897
+ MqttPublishError,
729
898
  MysaApiClient,
730
899
  MysaApiError,
731
900
  OutMessageType,