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 +2 -1
- package/dist/index.cjs +202 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +91 -10
- package/dist/index.d.ts +91 -10
- package/dist/index.js +201 -33
- package/dist/index.js.map +1 -1
- package/package.json +14 -14
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,19 @@ declare class MysaApiError extends Error {
|
|
|
20
20
|
*/
|
|
21
21
|
constructor(apiResponse: Response);
|
|
22
22
|
}
|
|
23
|
+
/** Error thrown when an MQTT publish ultimately fails after retry attempts. */
|
|
24
|
+
declare class MqttPublishError extends Error {
|
|
25
|
+
attempts: number;
|
|
26
|
+
original?: unknown | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Creates a new MqttPublishError instance.
|
|
29
|
+
*
|
|
30
|
+
* @param message - A human-readable description of the publish failure.
|
|
31
|
+
* @param attempts - The number of attempts that were made before giving up.
|
|
32
|
+
* @param original - The original error object thrown by the underlying MQTT library (optional).
|
|
33
|
+
*/
|
|
34
|
+
constructor(message: string, attempts: number, original?: unknown | undefined);
|
|
35
|
+
}
|
|
23
36
|
|
|
24
37
|
/**
|
|
25
38
|
* Interface representing a temperature setpoint change event for a Mysa device.
|
|
@@ -43,7 +56,13 @@ interface SetPointChange {
|
|
|
43
56
|
* Defines the possible operational states that a Mysa thermostat or heating device can be set to. These modes control
|
|
44
57
|
* the device's heating behavior and power consumption.
|
|
45
58
|
*/
|
|
46
|
-
type MysaDeviceMode = 'off' | 'heat';
|
|
59
|
+
type MysaDeviceMode = 'off' | 'heat' | 'cool' | 'dry' | 'fan_only' | 'auto';
|
|
60
|
+
/**
|
|
61
|
+
* Union type representing the available fan speed modes for Mysa devices.
|
|
62
|
+
*
|
|
63
|
+
* Defines the possible fan speed states that a Mysa thermostat device can be set to.
|
|
64
|
+
*/
|
|
65
|
+
type MysaFanSpeedMode = 'auto' | 'low' | 'medium' | 'high' | 'max';
|
|
47
66
|
|
|
48
67
|
/**
|
|
49
68
|
* Interface representing a device state change event for a Mysa device.
|
|
@@ -59,6 +78,8 @@ interface StateChange {
|
|
|
59
78
|
mode?: MysaDeviceMode;
|
|
60
79
|
/** Current temperature setpoint after the state change */
|
|
61
80
|
setPoint: number;
|
|
81
|
+
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
|
|
82
|
+
fanSpeed?: MysaFanSpeedMode;
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
/**
|
|
@@ -357,6 +378,8 @@ interface DeviceState {
|
|
|
357
378
|
Humidity: TimestampedValue<number>;
|
|
358
379
|
/** Lock status */
|
|
359
380
|
Lock: TimestampedValue<number>;
|
|
381
|
+
/** Fan speed */
|
|
382
|
+
FanSpeed?: TimestampedValue<number>;
|
|
360
383
|
}
|
|
361
384
|
/**
|
|
362
385
|
* Collection of device states indexed by device ID
|
|
@@ -446,10 +469,12 @@ interface DeviceStateChange extends MsgPayload<OutMessageType.DEVICE_STATE_CHANG
|
|
|
446
469
|
ho: number;
|
|
447
470
|
/** Unknown */
|
|
448
471
|
lk: number;
|
|
449
|
-
/** Device mode (1 = OFF, 3 = HEAT) */
|
|
472
|
+
/** Device mode (1 = OFF, 2 = AUTO, 3 = HEAT, 4 = COOL, 5 = FAN_ONLY, 6 = DRY) */
|
|
450
473
|
md: number;
|
|
451
474
|
/** Temperature setpoint */
|
|
452
475
|
sp: number;
|
|
476
|
+
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
|
|
477
|
+
fn?: number;
|
|
453
478
|
};
|
|
454
479
|
/** Success indicator for the state change operation (1 = success, 0 = failure) */
|
|
455
480
|
success: number;
|
|
@@ -670,6 +695,13 @@ interface MysaApiClientOptions {
|
|
|
670
695
|
fetcher?: typeof fetch;
|
|
671
696
|
}
|
|
672
697
|
|
|
698
|
+
/** Options for MQTT publish operations. */
|
|
699
|
+
interface MqttPublishOptions {
|
|
700
|
+
/** Maximum number of publish attempts before failing (default: 5). */
|
|
701
|
+
maxAttempts?: number;
|
|
702
|
+
/** Base delay in milliseconds used for exponential backoff calculation (default: 500). */
|
|
703
|
+
baseDelayMs?: number;
|
|
704
|
+
}
|
|
673
705
|
/**
|
|
674
706
|
* Main client for interacting with the Mysa API and real-time device communication.
|
|
675
707
|
*
|
|
@@ -703,8 +735,8 @@ declare class MysaApiClient {
|
|
|
703
735
|
private _logger;
|
|
704
736
|
/** The fetcher function used by the client. */
|
|
705
737
|
private _fetcher;
|
|
706
|
-
/**
|
|
707
|
-
private
|
|
738
|
+
/** A promise that resolves to the MQTT connection used for real-time updates. */
|
|
739
|
+
private _mqttConnectionPromise?;
|
|
708
740
|
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
|
709
741
|
private _realtimeDeviceIds;
|
|
710
742
|
/** The cached devices object, if any. */
|
|
@@ -831,15 +863,20 @@ declare class MysaApiClient {
|
|
|
831
863
|
*
|
|
832
864
|
* // Set temperature and mode
|
|
833
865
|
* await client.setDeviceState('device123', 20, 'heat');
|
|
866
|
+
*
|
|
867
|
+
* // Set fan speed
|
|
868
|
+
* await client.setDeviceState('device123', undefined, undefined, 'auto');
|
|
834
869
|
* ```
|
|
835
870
|
*
|
|
836
871
|
* @param deviceId - The ID of the device to control.
|
|
837
872
|
* @param setPoint - The target temperature set point (optional).
|
|
838
|
-
* @param mode - The operating mode to set (
|
|
873
|
+
* @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
|
|
874
|
+
* @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
|
|
875
|
+
* unchanged).
|
|
839
876
|
* @throws {@link UnauthenticatedError} When the user is not authenticated.
|
|
840
877
|
* @throws {@link Error} When MQTT connection or command sending fails.
|
|
841
878
|
*/
|
|
842
|
-
setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode): Promise<void>;
|
|
879
|
+
setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode, fanSpeed?: MysaFanSpeedMode): Promise<void>;
|
|
843
880
|
/**
|
|
844
881
|
* Starts receiving real-time updates for the specified device.
|
|
845
882
|
*
|
|
@@ -881,7 +918,7 @@ declare class MysaApiClient {
|
|
|
881
918
|
* @returns A promise that resolves to a valid CognitoUserSession.
|
|
882
919
|
* @throws {@link UnauthenticatedError} When no session exists or refresh fails.
|
|
883
920
|
*/
|
|
884
|
-
private
|
|
921
|
+
private _getFreshSession;
|
|
885
922
|
/**
|
|
886
923
|
* Establishes and returns an MQTT connection for real-time communication.
|
|
887
924
|
*
|
|
@@ -891,7 +928,49 @@ declare class MysaApiClient {
|
|
|
891
928
|
* @returns A promise that resolves to an active MQTT connection.
|
|
892
929
|
* @throws {@link Error} When connection establishment fails.
|
|
893
930
|
*/
|
|
894
|
-
private
|
|
931
|
+
private _getMqttConnection;
|
|
932
|
+
/**
|
|
933
|
+
* Determines whether an MQTT-related error is considered transient and worth retrying.
|
|
934
|
+
*
|
|
935
|
+
* Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
|
|
936
|
+
* other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
|
|
937
|
+
* layer.
|
|
938
|
+
*
|
|
939
|
+
* @param err - The error object thrown by the underlying MQTT operation.
|
|
940
|
+
* @returns True if the error appears transient and a retry should be attempted; false otherwise.
|
|
941
|
+
*/
|
|
942
|
+
private _isTransientMqttError;
|
|
943
|
+
/**
|
|
944
|
+
* Publishes an MQTT message with exponential backoff retries for transient failures.
|
|
945
|
+
*
|
|
946
|
+
* Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
|
|
947
|
+
* jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
|
|
948
|
+
* reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
|
|
949
|
+
*
|
|
950
|
+
* On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
|
|
951
|
+
* original error for higher-level handling.
|
|
952
|
+
*
|
|
953
|
+
* @remarks
|
|
954
|
+
* Retry options fields:
|
|
955
|
+
*
|
|
956
|
+
* - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
|
|
957
|
+
* - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
|
|
958
|
+
*
|
|
959
|
+
* @param connection - The active MQTT client connection used to send the publish.
|
|
960
|
+
* @param topic - The MQTT topic to publish to.
|
|
961
|
+
* @param payload - The serialized payload (binary buffer or Uint8Array).
|
|
962
|
+
* @param qos - The desired MQTT QoS level for the publish.
|
|
963
|
+
* @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
|
|
964
|
+
* @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
|
|
965
|
+
*/
|
|
966
|
+
private _publishWithRetry;
|
|
967
|
+
/**
|
|
968
|
+
* Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
|
|
969
|
+
*
|
|
970
|
+
* @returns A promise that resolves to an active MQTT connection.
|
|
971
|
+
* @throws {@link Error} When connection establishment fails.
|
|
972
|
+
*/
|
|
973
|
+
private _createMqttConnection;
|
|
895
974
|
/**
|
|
896
975
|
* Processes incoming MQTT messages and emits appropriate events.
|
|
897
976
|
*
|
|
@@ -901,7 +980,7 @@ declare class MysaApiClient {
|
|
|
901
980
|
*
|
|
902
981
|
* @param payload - The raw MQTT message payload to process.
|
|
903
982
|
*/
|
|
904
|
-
private
|
|
983
|
+
private _processMqttMessage;
|
|
905
984
|
}
|
|
906
985
|
|
|
907
986
|
/**
|
|
@@ -954,6 +1033,8 @@ interface ChangeDeviceState extends MsgPayload<InMessageType.CHANGE_DEVICE_STATE
|
|
|
954
1033
|
md?: number;
|
|
955
1034
|
/** Unknown, should always be -1 */
|
|
956
1035
|
tm: number;
|
|
1036
|
+
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
|
|
1037
|
+
fn?: number;
|
|
957
1038
|
}
|
|
958
1039
|
];
|
|
959
1040
|
/**
|
|
@@ -1012,4 +1093,4 @@ type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;
|
|
|
1012
1093
|
*/
|
|
1013
1094
|
type InPayload = MsgTypeInPayload | MsgInPayload;
|
|
1014
1095
|
|
|
1015
|
-
export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceBase, type DeviceLog, type DevicePostBoot, type DeviceSetpointChange, type DeviceState, type DeviceStateChange, type DeviceStates, type DeviceStatesObj, type DeviceV1Status, type DeviceV2Status, type Devices, type DevicesObj, type FirmwareDevice, type Firmwares, InMessageType, type InPayload, type Logger, type ModeObj, type MsgBasePayload, type MsgInPayload, type MsgOutPayload, type MsgPayload, type MsgTypeBasePayload, type MsgTypeInPayload, type MsgTypeOutPayload, type MsgTypePayload, MysaApiClient, type MysaApiClientEventTypes, type MysaApiClientOptions, MysaApiError, type MysaDeviceMode, type MysaSession, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, VoidLogger };
|
|
1096
|
+
export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceBase, type DeviceLog, type DevicePostBoot, type DeviceSetpointChange, type DeviceState, type DeviceStateChange, type DeviceStates, type DeviceStatesObj, type DeviceV1Status, type DeviceV2Status, type Devices, type DevicesObj, type FirmwareDevice, type Firmwares, InMessageType, type InPayload, type Logger, type ModeObj, MqttPublishError, type MqttPublishOptions, type MsgBasePayload, type MsgInPayload, type MsgOutPayload, type MsgPayload, type MsgTypeBasePayload, type MsgTypeInPayload, type MsgTypeOutPayload, type MsgTypePayload, MysaApiClient, type MysaApiClientEventTypes, type MysaApiClientOptions, MysaApiError, type MysaDeviceMode, type MysaFanSpeedMode, type MysaSession, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, VoidLogger };
|
package/dist/index.js
CHANGED
|
@@ -53,6 +53,21 @@ var MysaApiError = class extends Error {
|
|
|
53
53
|
this.statusText = apiResponse.statusText;
|
|
54
54
|
}
|
|
55
55
|
};
|
|
56
|
+
var MqttPublishError = class extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* Creates a new MqttPublishError instance.
|
|
59
|
+
*
|
|
60
|
+
* @param message - A human-readable description of the publish failure.
|
|
61
|
+
* @param attempts - The number of attempts that were made before giving up.
|
|
62
|
+
* @param original - The original error object thrown by the underlying MQTT library (optional).
|
|
63
|
+
*/
|
|
64
|
+
constructor(message, attempts, original) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.attempts = attempts;
|
|
67
|
+
this.original = original;
|
|
68
|
+
this.name = "MqttPublishError";
|
|
69
|
+
}
|
|
70
|
+
};
|
|
56
71
|
|
|
57
72
|
// src/api/Logger.ts
|
|
58
73
|
var VoidLogger = class {
|
|
@@ -203,8 +218,8 @@ var MysaApiClient = class {
|
|
|
203
218
|
_logger;
|
|
204
219
|
/** The fetcher function used by the client. */
|
|
205
220
|
_fetcher;
|
|
206
|
-
/**
|
|
207
|
-
|
|
221
|
+
/** A promise that resolves to the MQTT connection used for real-time updates. */
|
|
222
|
+
_mqttConnectionPromise;
|
|
208
223
|
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
|
209
224
|
_realtimeDeviceIds = /* @__PURE__ */ new Map();
|
|
210
225
|
/** The cached devices object, if any. */
|
|
@@ -324,7 +339,7 @@ var MysaApiClient = class {
|
|
|
324
339
|
*/
|
|
325
340
|
async getDevices() {
|
|
326
341
|
this._logger.debug(`Fetching devices...`);
|
|
327
|
-
const session = await this.
|
|
342
|
+
const session = await this._getFreshSession();
|
|
328
343
|
const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
|
|
329
344
|
headers: {
|
|
330
345
|
Authorization: `${session.getIdToken().getJwtToken()}`
|
|
@@ -359,7 +374,7 @@ var MysaApiClient = class {
|
|
|
359
374
|
async getDeviceSerialNumber(deviceId) {
|
|
360
375
|
var _a;
|
|
361
376
|
this._logger.debug(`Fetching serial number for device ${deviceId}...`);
|
|
362
|
-
const session = await this.
|
|
377
|
+
const session = await this._getFreshSession();
|
|
363
378
|
const credentialsProvider = fromCognitoIdentityPool({
|
|
364
379
|
clientConfig: {
|
|
365
380
|
region: AwsRegion
|
|
@@ -396,7 +411,7 @@ var MysaApiClient = class {
|
|
|
396
411
|
*/
|
|
397
412
|
async getDeviceFirmwares() {
|
|
398
413
|
this._logger.debug(`Fetching device firmwares...`);
|
|
399
|
-
const session = await this.
|
|
414
|
+
const session = await this._getFreshSession();
|
|
400
415
|
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, {
|
|
401
416
|
headers: {
|
|
402
417
|
Authorization: `${session.getIdToken().getJwtToken()}`
|
|
@@ -416,7 +431,7 @@ var MysaApiClient = class {
|
|
|
416
431
|
*/
|
|
417
432
|
async getDeviceStates() {
|
|
418
433
|
this._logger.debug(`Fetching device states...`);
|
|
419
|
-
const session = await this.
|
|
434
|
+
const session = await this._getFreshSession();
|
|
420
435
|
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, {
|
|
421
436
|
headers: {
|
|
422
437
|
Authorization: `${session.getIdToken().getJwtToken()}`
|
|
@@ -444,31 +459,39 @@ var MysaApiClient = class {
|
|
|
444
459
|
*
|
|
445
460
|
* // Set temperature and mode
|
|
446
461
|
* await client.setDeviceState('device123', 20, 'heat');
|
|
462
|
+
*
|
|
463
|
+
* // Set fan speed
|
|
464
|
+
* await client.setDeviceState('device123', undefined, undefined, 'auto');
|
|
447
465
|
* ```
|
|
448
466
|
*
|
|
449
467
|
* @param deviceId - The ID of the device to control.
|
|
450
468
|
* @param setPoint - The target temperature set point (optional).
|
|
451
|
-
* @param mode - The operating mode to set (
|
|
469
|
+
* @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
|
|
470
|
+
* @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', or undefined to leave
|
|
471
|
+
* unchanged).
|
|
452
472
|
* @throws {@link UnauthenticatedError} When the user is not authenticated.
|
|
453
473
|
* @throws {@link Error} When MQTT connection or command sending fails.
|
|
454
474
|
*/
|
|
455
|
-
async setDeviceState(deviceId, setPoint, mode) {
|
|
475
|
+
async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
|
|
476
|
+
var _a;
|
|
456
477
|
this._logger.debug(`Setting device state for '${deviceId}'`);
|
|
457
478
|
if (!this._cachedDevices) {
|
|
458
479
|
this._cachedDevices = await this.getDevices();
|
|
459
480
|
}
|
|
460
481
|
const device = this._cachedDevices.DevicesObj[deviceId];
|
|
461
482
|
this._logger.debug(`Initializing MQTT connection...`);
|
|
462
|
-
const mqttConnection = await this.
|
|
483
|
+
const mqttConnection = await this._getMqttConnection();
|
|
463
484
|
const now = dayjs();
|
|
464
485
|
this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
|
|
486
|
+
const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
|
|
487
|
+
const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
|
|
465
488
|
const payload = serializeMqttPayload({
|
|
466
489
|
msg: 44 /* CHANGE_DEVICE_STATE */,
|
|
467
490
|
id: now.valueOf(),
|
|
468
491
|
time: now.unix(),
|
|
469
492
|
ver: "1.0",
|
|
470
493
|
src: {
|
|
471
|
-
ref: this.session.username,
|
|
494
|
+
ref: ((_a = this.session) == null ? void 0 : _a.username) ?? "",
|
|
472
495
|
type: 100
|
|
473
496
|
},
|
|
474
497
|
dest: {
|
|
@@ -478,17 +501,24 @@ var MysaApiClient = class {
|
|
|
478
501
|
resp: 2,
|
|
479
502
|
body: {
|
|
480
503
|
ver: 1,
|
|
481
|
-
type: device.Model.startsWith("BB-V1") ? 1 : device.Model.startsWith("BB-V2") ? device.Model.endsWith("-L") ? 5 : 4 : 0,
|
|
504
|
+
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,
|
|
482
505
|
cmd: [
|
|
483
506
|
{
|
|
484
507
|
tm: -1,
|
|
485
508
|
sp: setPoint,
|
|
486
|
-
md: mode
|
|
509
|
+
md: mode ? modeMap[mode] : void 0,
|
|
510
|
+
fn: fanSpeed ? fanSpeedMap[fanSpeed] : void 0
|
|
487
511
|
}
|
|
488
512
|
]
|
|
489
513
|
}
|
|
490
514
|
});
|
|
491
|
-
|
|
515
|
+
try {
|
|
516
|
+
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
|
517
|
+
this._logger.debug(`Device state publish succeeded for '${deviceId}'`);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
this._logger.error(`Failed to set device state for '${deviceId}'`, error);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
492
522
|
}
|
|
493
523
|
/**
|
|
494
524
|
* Starts receiving real-time updates for the specified device.
|
|
@@ -518,10 +548,10 @@ var MysaApiClient = class {
|
|
|
518
548
|
return;
|
|
519
549
|
}
|
|
520
550
|
this._logger.debug(`Initializing MQTT connection...`);
|
|
521
|
-
const mqttConnection = await this.
|
|
551
|
+
const mqttConnection = await this._getMqttConnection();
|
|
522
552
|
this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
|
|
523
553
|
await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, mqtt.QoS.AtLeastOnce, (_, payload2) => {
|
|
524
|
-
this.
|
|
554
|
+
this._processMqttMessage(payload2);
|
|
525
555
|
});
|
|
526
556
|
this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`);
|
|
527
557
|
const payload = serializeMqttPayload({
|
|
@@ -530,7 +560,7 @@ var MysaApiClient = class {
|
|
|
530
560
|
Timestamp: dayjs().unix(),
|
|
531
561
|
Timeout: RealtimeKeepAliveInterval.asSeconds()
|
|
532
562
|
});
|
|
533
|
-
await
|
|
563
|
+
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
|
534
564
|
const timer = setInterval(async () => {
|
|
535
565
|
this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
|
|
536
566
|
const payload2 = serializeMqttPayload({
|
|
@@ -539,7 +569,7 @@ var MysaApiClient = class {
|
|
|
539
569
|
Timestamp: dayjs().unix(),
|
|
540
570
|
Timeout: RealtimeKeepAliveInterval.asSeconds()
|
|
541
571
|
});
|
|
542
|
-
await
|
|
572
|
+
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload2, mqtt.QoS.AtLeastOnce);
|
|
543
573
|
}, RealtimeKeepAliveInterval.subtract(10, "seconds").asMilliseconds());
|
|
544
574
|
this._realtimeDeviceIds.set(deviceId, timer);
|
|
545
575
|
}
|
|
@@ -560,7 +590,7 @@ var MysaApiClient = class {
|
|
|
560
590
|
return;
|
|
561
591
|
}
|
|
562
592
|
this._logger.debug(`Initializing MQTT connection...`);
|
|
563
|
-
const mqttConnection = await this.
|
|
593
|
+
const mqttConnection = await this._getMqttConnection();
|
|
564
594
|
this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
|
|
565
595
|
await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`);
|
|
566
596
|
clearInterval(timer);
|
|
@@ -575,7 +605,7 @@ var MysaApiClient = class {
|
|
|
575
605
|
* @returns A promise that resolves to a valid CognitoUserSession.
|
|
576
606
|
* @throws {@link UnauthenticatedError} When no session exists or refresh fails.
|
|
577
607
|
*/
|
|
578
|
-
async
|
|
608
|
+
async _getFreshSession() {
|
|
579
609
|
if (!this._cognitoUser || !this._cognitoUserSession) {
|
|
580
610
|
throw new UnauthenticatedError("An attempt was made to access a resource without a valid session.");
|
|
581
611
|
}
|
|
@@ -607,11 +637,98 @@ var MysaApiClient = class {
|
|
|
607
637
|
* @returns A promise that resolves to an active MQTT connection.
|
|
608
638
|
* @throws {@link Error} When connection establishment fails.
|
|
609
639
|
*/
|
|
610
|
-
|
|
611
|
-
if (this.
|
|
612
|
-
|
|
640
|
+
_getMqttConnection() {
|
|
641
|
+
if (!this._mqttConnectionPromise) {
|
|
642
|
+
this._mqttConnectionPromise = this._createMqttConnection().catch((err) => {
|
|
643
|
+
this._mqttConnectionPromise = void 0;
|
|
644
|
+
throw err;
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return this._mqttConnectionPromise;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Determines whether an MQTT-related error is considered transient and worth retrying.
|
|
651
|
+
*
|
|
652
|
+
* Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
|
|
653
|
+
* other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
|
|
654
|
+
* layer.
|
|
655
|
+
*
|
|
656
|
+
* @param err - The error object thrown by the underlying MQTT operation.
|
|
657
|
+
* @returns True if the error appears transient and a retry should be attempted; false otherwise.
|
|
658
|
+
*/
|
|
659
|
+
_isTransientMqttError(err) {
|
|
660
|
+
if (!err || typeof err !== "object") {
|
|
661
|
+
return false;
|
|
613
662
|
}
|
|
614
|
-
const
|
|
663
|
+
const anyErr = err;
|
|
664
|
+
const code = anyErr.error_code || anyErr.error_name || anyErr.error;
|
|
665
|
+
const msg = (anyErr.message || anyErr.error || "").toString();
|
|
666
|
+
const transientMarkers = [
|
|
667
|
+
"AWS_ERROR_MQTT_TIMEOUT",
|
|
668
|
+
"AWS_ERROR_MQTT_NO_CONNECTION",
|
|
669
|
+
"Time limit between request and response",
|
|
670
|
+
"timeout"
|
|
671
|
+
];
|
|
672
|
+
return transientMarkers.some((m) => code && String(code).includes(m) || msg.includes(m));
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Publishes an MQTT message with exponential backoff retries for transient failures.
|
|
676
|
+
*
|
|
677
|
+
* Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
|
|
678
|
+
* jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
|
|
679
|
+
* reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
|
|
680
|
+
*
|
|
681
|
+
* On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
|
|
682
|
+
* original error for higher-level handling.
|
|
683
|
+
*
|
|
684
|
+
* @remarks
|
|
685
|
+
* Retry options fields:
|
|
686
|
+
*
|
|
687
|
+
* - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
|
|
688
|
+
* - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
|
|
689
|
+
*
|
|
690
|
+
* @param connection - The active MQTT client connection used to send the publish.
|
|
691
|
+
* @param topic - The MQTT topic to publish to.
|
|
692
|
+
* @param payload - The serialized payload (binary buffer or Uint8Array).
|
|
693
|
+
* @param qos - The desired MQTT QoS level for the publish.
|
|
694
|
+
* @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
|
|
695
|
+
* @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
|
|
696
|
+
*/
|
|
697
|
+
async _publishWithRetry(connection, topic, payload, qos, opts = {}) {
|
|
698
|
+
const maxAttempts = opts.maxAttempts ?? 5;
|
|
699
|
+
const baseDelayMs = opts.baseDelayMs ?? 500;
|
|
700
|
+
let attempt = 0;
|
|
701
|
+
while (true) {
|
|
702
|
+
attempt++;
|
|
703
|
+
try {
|
|
704
|
+
await connection.publish(topic, payload, qos);
|
|
705
|
+
return;
|
|
706
|
+
} catch (err) {
|
|
707
|
+
const isTransient = this._isTransientMqttError(err);
|
|
708
|
+
if (!isTransient || attempt >= maxAttempts) {
|
|
709
|
+
throw new MqttPublishError(`MQTT publish failed after ${attempt} attempts`, attempt, err);
|
|
710
|
+
}
|
|
711
|
+
const JITTER_MIN_FACTOR = 0.75;
|
|
712
|
+
const JITTER_RANGE = 0.5;
|
|
713
|
+
const delay = baseDelayMs * Math.pow(2, attempt - 1) * (JITTER_MIN_FACTOR + Math.random() * JITTER_RANGE);
|
|
714
|
+
this._logger.warn(
|
|
715
|
+
`Transient MQTT publish error on '${topic}' (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.round(
|
|
716
|
+
delay
|
|
717
|
+
)}ms`
|
|
718
|
+
);
|
|
719
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
|
|
725
|
+
*
|
|
726
|
+
* @returns A promise that resolves to an active MQTT connection.
|
|
727
|
+
* @throws {@link Error} When connection establishment fails.
|
|
728
|
+
*/
|
|
729
|
+
async _createMqttConnection() {
|
|
730
|
+
var _a;
|
|
731
|
+
const session = await this._getFreshSession();
|
|
615
732
|
const credentialsProvider = fromCognitoIdentityPool({
|
|
616
733
|
clientConfig: {
|
|
617
734
|
region: AwsRegion
|
|
@@ -623,16 +740,49 @@ var MysaApiClient = class {
|
|
|
623
740
|
logger: this._logger
|
|
624
741
|
});
|
|
625
742
|
const credentials = await credentialsProvider();
|
|
626
|
-
const
|
|
743
|
+
const stableClientId = `mysa-js-sdk-${((_a = this.session) == null ? void 0 : _a.username) ?? ""}`;
|
|
744
|
+
const builder = 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);
|
|
627
745
|
const config = builder.build();
|
|
628
746
|
const client = new mqtt.MqttClient();
|
|
629
|
-
|
|
630
|
-
|
|
747
|
+
const connection = client.new_connection(config);
|
|
748
|
+
connection.on("connect", () => {
|
|
749
|
+
this._logger.debug("MQTT connect");
|
|
750
|
+
});
|
|
751
|
+
connection.on("connection_success", () => {
|
|
752
|
+
this._logger.debug("MQTT connection_success");
|
|
753
|
+
});
|
|
754
|
+
connection.on("connection_failure", (e) => {
|
|
755
|
+
this._logger.error("MQTT connection_failure", e);
|
|
756
|
+
});
|
|
757
|
+
connection.on("interrupt", (e) => {
|
|
758
|
+
this._logger.warn("MQTT interrupt", e);
|
|
759
|
+
});
|
|
760
|
+
connection.on("resume", async (returnCode, sessionPresent) => {
|
|
761
|
+
this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`);
|
|
762
|
+
if (!sessionPresent) {
|
|
763
|
+
this._logger.info("No session present, re-subscribing each device");
|
|
764
|
+
try {
|
|
765
|
+
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
|
766
|
+
const topic = `/v1/dev/${deviceId}/out`;
|
|
767
|
+
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
768
|
+
await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
769
|
+
this._processMqttMessage(payload);
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
} catch (err) {
|
|
773
|
+
this._logger.error("Failed to re-subscribe after resume", err);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
connection.on("error", (e) => {
|
|
778
|
+
this._logger.error("MQTT error", e);
|
|
779
|
+
});
|
|
780
|
+
connection.on("closed", () => {
|
|
631
781
|
this._logger.info("MQTT connection closed");
|
|
632
|
-
this.
|
|
782
|
+
this._mqttConnectionPromise = void 0;
|
|
633
783
|
});
|
|
634
|
-
await
|
|
635
|
-
return
|
|
784
|
+
await connection.connect();
|
|
785
|
+
return connection;
|
|
636
786
|
}
|
|
637
787
|
/**
|
|
638
788
|
* Processes incoming MQTT messages and emits appropriate events.
|
|
@@ -643,7 +793,7 @@ var MysaApiClient = class {
|
|
|
643
793
|
*
|
|
644
794
|
* @param payload - The raw MQTT message payload to process.
|
|
645
795
|
*/
|
|
646
|
-
|
|
796
|
+
_processMqttMessage(payload) {
|
|
647
797
|
try {
|
|
648
798
|
const parsedPayload = parseMqttPayload(payload);
|
|
649
799
|
this.emitter.emit("rawRealtimeMessageReceived", parsedPayload);
|
|
@@ -677,13 +827,30 @@ var MysaApiClient = class {
|
|
|
677
827
|
dutyCycle: parsedPayload.body.dtyCycle
|
|
678
828
|
});
|
|
679
829
|
break;
|
|
680
|
-
case 44 /* DEVICE_STATE_CHANGE */:
|
|
830
|
+
case 44 /* DEVICE_STATE_CHANGE */: {
|
|
831
|
+
const modeMap = {
|
|
832
|
+
1: "off",
|
|
833
|
+
2: "auto",
|
|
834
|
+
3: "heat",
|
|
835
|
+
4: "cool",
|
|
836
|
+
5: "fan_only",
|
|
837
|
+
6: "dry"
|
|
838
|
+
};
|
|
839
|
+
const fanSpeedMap = {
|
|
840
|
+
1: "auto",
|
|
841
|
+
3: "low",
|
|
842
|
+
5: "medium",
|
|
843
|
+
7: "high",
|
|
844
|
+
8: "max"
|
|
845
|
+
};
|
|
681
846
|
this.emitter.emit("stateChanged", {
|
|
682
847
|
deviceId: parsedPayload.src.ref,
|
|
683
|
-
mode: parsedPayload.body.state.md
|
|
684
|
-
setPoint: parsedPayload.body.state.sp
|
|
848
|
+
mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
|
|
849
|
+
setPoint: parsedPayload.body.state.sp,
|
|
850
|
+
fanSpeed: parsedPayload.body.state.fn !== void 0 ? fanSpeedMap[parsedPayload.body.state.fn] : void 0
|
|
685
851
|
});
|
|
686
852
|
break;
|
|
853
|
+
}
|
|
687
854
|
}
|
|
688
855
|
}
|
|
689
856
|
} catch (error) {
|
|
@@ -693,6 +860,7 @@ var MysaApiClient = class {
|
|
|
693
860
|
};
|
|
694
861
|
export {
|
|
695
862
|
InMessageType,
|
|
863
|
+
MqttPublishError,
|
|
696
864
|
MysaApiClient,
|
|
697
865
|
MysaApiError,
|
|
698
866
|
OutMessageType,
|