mysa-js-sdk 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +4 -5
- package/dist/index.cjs +267 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +112 -5
- package/dist/index.d.ts +112 -5
- package/dist/index.js +266 -62
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -39,6 +39,20 @@ declare class UnknownDeviceError extends Error {
|
|
|
39
39
|
*/
|
|
40
40
|
constructor(deviceId: string);
|
|
41
41
|
}
|
|
42
|
+
/** Error thrown when a requested fan speed is not supported by the target device. */
|
|
43
|
+
declare class UnsupportedFanSpeedError extends Error {
|
|
44
|
+
readonly deviceId: string;
|
|
45
|
+
readonly fanSpeed: string;
|
|
46
|
+
readonly supportedFanSpeeds: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Creates a new UnsupportedFanSpeedError instance.
|
|
49
|
+
*
|
|
50
|
+
* @param deviceId - The id of the device the command was aimed at.
|
|
51
|
+
* @param fanSpeed - The requested fan speed that the device does not support.
|
|
52
|
+
* @param supportedFanSpeeds - The fan speeds the device does support.
|
|
53
|
+
*/
|
|
54
|
+
constructor(deviceId: string, fanSpeed: string, supportedFanSpeeds: string[]);
|
|
55
|
+
}
|
|
42
56
|
/** Error thrown when an MQTT publish ultimately fails after retry attempts. */
|
|
43
57
|
declare class MqttPublishError extends Error {
|
|
44
58
|
attempts: number;
|
|
@@ -120,6 +134,8 @@ interface Status {
|
|
|
120
134
|
current?: number;
|
|
121
135
|
/** Optional heating element duty cycle, as a fraction between 0.0 and 1.0 */
|
|
122
136
|
dutyCycle?: number;
|
|
137
|
+
/** Optional floor-probe temperature reading, reported only by in-floor heating thermostats (INF-V1-0) */
|
|
138
|
+
floorTemperature?: number;
|
|
123
139
|
}
|
|
124
140
|
|
|
125
141
|
/** Interface for logging operations at different severity levels */
|
|
@@ -171,8 +187,8 @@ declare class EventEmitter<TEvents extends Record<string, any>> implements NodeJ
|
|
|
171
187
|
removeAllListeners<TEventName extends keyof TEvents & string>(eventName?: TEventName | undefined): this;
|
|
172
188
|
setMaxListeners(n: number): this;
|
|
173
189
|
getMaxListeners(): number;
|
|
174
|
-
listeners<TEventName extends keyof TEvents & string>(eventName: TEventName):
|
|
175
|
-
rawListeners<TEventName extends keyof TEvents & string>(eventName: TEventName):
|
|
190
|
+
listeners<TEventName extends keyof TEvents & string>(eventName: TEventName): ((...args: any[]) => void)[];
|
|
191
|
+
rawListeners<TEventName extends keyof TEvents & string>(eventName: TEventName): ((...args: any[]) => void)[];
|
|
176
192
|
listenerCount<TEventName extends keyof TEvents & string>(eventName: TEventName, listener?: (...args: TEvents[TEventName]) => void): number;
|
|
177
193
|
prependListener<TEventName extends keyof TEvents & string>(eventName: TEventName, listener: (...args: TEvents[TEventName]) => void): this;
|
|
178
194
|
prependOnceListener<TEventName extends keyof TEvents & string>(eventName: TEventName, listener: (...args: TEvents[TEventName]) => void): this;
|
|
@@ -209,12 +225,20 @@ interface SupportedCaps {
|
|
|
209
225
|
[modeId: string]: {
|
|
210
226
|
/** Array of available temperature setpoints for this mode */
|
|
211
227
|
temperatures: number[];
|
|
228
|
+
/** Array of supported raw fan speed (fn) values for this mode, if any */
|
|
229
|
+
fanSpeeds?: number[];
|
|
212
230
|
};
|
|
213
231
|
};
|
|
214
232
|
/** Version string of the capability definition */
|
|
215
233
|
version: string;
|
|
216
234
|
/** Array of supported remote control key codes */
|
|
217
235
|
keys: number[];
|
|
236
|
+
/**
|
|
237
|
+
* Optional device-specific fan-speed `fn` values, ordered by canonical fan speed (`[auto, low, medium, high, max]`).
|
|
238
|
+
* E.g. `[1, 2, 4, 6]` for CodeNum=1117 devices means auto=1, low=2, medium=4, high=6. When absent, a legacy universal
|
|
239
|
+
* mapping is assumed.
|
|
240
|
+
*/
|
|
241
|
+
fanSpeeds?: number[];
|
|
218
242
|
}
|
|
219
243
|
/**
|
|
220
244
|
* Device operating mode information.
|
|
@@ -568,12 +592,29 @@ interface DeviceV2Status extends MsgPayload<OutMessageType.DEVICE_V2_STATUS> {
|
|
|
568
592
|
body: {
|
|
569
593
|
/** Ambient temperature reading from the device sensor */
|
|
570
594
|
ambTemp: number;
|
|
571
|
-
/**
|
|
572
|
-
|
|
595
|
+
/**
|
|
596
|
+
* Current duty cycle of the heating element, as a fraction between 0.0 and 1.0. Reported by baseboard V2 devices;
|
|
597
|
+
* absent on in-floor heating thermostats (INF-V1-0), which report {@link heatStat} instead.
|
|
598
|
+
*/
|
|
599
|
+
dtyCycle?: number;
|
|
573
600
|
/** Relative humidity percentage reading from the device sensor */
|
|
574
601
|
hum: number;
|
|
575
602
|
/** Current temperature setpoint setting */
|
|
576
603
|
stpt: number;
|
|
604
|
+
/**
|
|
605
|
+
* Binary heating-relay state (0 = off, 1 = energized) reported by in-floor heating thermostats (INF-V1-0) in place
|
|
606
|
+
* of {@link dtyCycle}. Absent on baseboard V2 devices.
|
|
607
|
+
*/
|
|
608
|
+
heatStat?: number;
|
|
609
|
+
/** Floor-probe temperature reading (°C) from in-floor heating thermostats. Absent on baseboard V2 devices. */
|
|
610
|
+
flrSnsrTemp?: number;
|
|
611
|
+
/**
|
|
612
|
+
* Which sensor the in-floor thermostat regulates against (3 = floor probe, 5 = ambient air). Absent on baseboard V2
|
|
613
|
+
* devices.
|
|
614
|
+
*/
|
|
615
|
+
trackedSnsr?: number;
|
|
616
|
+
/** Line voltage (V) reported by in-floor heating thermostats. Absent on baseboard V2 devices. */
|
|
617
|
+
lineVtg?: number;
|
|
577
618
|
};
|
|
578
619
|
}
|
|
579
620
|
|
|
@@ -805,8 +846,21 @@ declare class MysaApiClient {
|
|
|
805
846
|
private _mqttInterrupts;
|
|
806
847
|
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
|
|
807
848
|
private _mqttResetInProgress;
|
|
849
|
+
/** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
|
|
850
|
+
private _mqttGeneration;
|
|
851
|
+
/** Consecutive forced resets without an intervening stable period; drives reset backoff. */
|
|
852
|
+
private _mqttConsecutiveResets;
|
|
853
|
+
/** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
|
|
854
|
+
private _mqttLastConnectionSuccessAt?;
|
|
855
|
+
/** Timer that clears the consecutive-reset counter once a connection stays healthy. */
|
|
856
|
+
private _mqttStabilityTimer?;
|
|
808
857
|
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
|
809
858
|
private _realtimeDeviceIds;
|
|
859
|
+
/**
|
|
860
|
+
* Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
|
|
861
|
+
* every reconnect so a debug capture survives connection resets.
|
|
862
|
+
*/
|
|
863
|
+
private _rawTopicCaptures;
|
|
810
864
|
/** The cached devices object, if any. */
|
|
811
865
|
private _cachedDevices?;
|
|
812
866
|
/**
|
|
@@ -945,6 +999,7 @@ declare class MysaApiClient {
|
|
|
945
999
|
* unchanged).
|
|
946
1000
|
* @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
|
|
947
1001
|
* @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
|
|
1002
|
+
* @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
|
|
948
1003
|
* @throws {@link Error} When MQTT connection or command sending fails.
|
|
949
1004
|
*/
|
|
950
1005
|
setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode, fanSpeed?: MysaFanSpeedMode): Promise<void>;
|
|
@@ -980,6 +1035,28 @@ declare class MysaApiClient {
|
|
|
980
1035
|
* @throws {@link Error} When MQTT unsubscription fails.
|
|
981
1036
|
*/
|
|
982
1037
|
stopRealtimeUpdates(deviceId: string): Promise<void>;
|
|
1038
|
+
/**
|
|
1039
|
+
* Subscribes to raw MQTT topic filters and relays every message verbatim.
|
|
1040
|
+
*
|
|
1041
|
+
* Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
|
|
1042
|
+
* request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
|
|
1043
|
+
* arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
|
|
1044
|
+
* notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
|
|
1045
|
+
* shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
|
|
1046
|
+
* implementation needs.
|
|
1047
|
+
*
|
|
1048
|
+
* The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
|
|
1049
|
+
* mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
|
|
1050
|
+
*
|
|
1051
|
+
* Registered filters are re-subscribed automatically after a reconnect.
|
|
1052
|
+
*
|
|
1053
|
+
* @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
|
|
1054
|
+
* policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
|
|
1055
|
+
* `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
|
|
1056
|
+
* @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
|
|
1057
|
+
* @throws {@link Error} When the MQTT connection cannot be established.
|
|
1058
|
+
*/
|
|
1059
|
+
startRawTopicCapture(topicFilters: string[], handler: (topic: string, payload: string) => void): Promise<void>;
|
|
983
1060
|
/**
|
|
984
1061
|
* Ensures a valid, non-expired session is available.
|
|
985
1062
|
*
|
|
@@ -1053,6 +1130,36 @@ declare class MysaApiClient {
|
|
|
1053
1130
|
* @throws {@link Error} When connection establishment fails.
|
|
1054
1131
|
*/
|
|
1055
1132
|
private _createMqttConnection;
|
|
1133
|
+
/**
|
|
1134
|
+
* Re-subscribes every device currently receiving real-time updates on the given connection.
|
|
1135
|
+
*
|
|
1136
|
+
* Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
|
|
1137
|
+
* registers a message handler more than once.
|
|
1138
|
+
*
|
|
1139
|
+
* @param connection - The connection to (re-)establish subscriptions on.
|
|
1140
|
+
*/
|
|
1141
|
+
private _resubscribeAll;
|
|
1142
|
+
/**
|
|
1143
|
+
* Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
|
|
1144
|
+
* consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
|
|
1145
|
+
*
|
|
1146
|
+
* @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
|
|
1147
|
+
* since been replaced.
|
|
1148
|
+
*/
|
|
1149
|
+
private _armMqttStabilityTimer;
|
|
1150
|
+
/** Clears the stability timer, if armed. */
|
|
1151
|
+
private _clearMqttStabilityTimer;
|
|
1152
|
+
/**
|
|
1153
|
+
* Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
|
|
1154
|
+
* escaping interrupt storms that a plain native reconnect cannot.
|
|
1155
|
+
*
|
|
1156
|
+
* Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
|
|
1157
|
+
* period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
|
|
1158
|
+
* fire-and-forget from an event handler.
|
|
1159
|
+
*
|
|
1160
|
+
* @param reason - Human-readable reason for the reset, used in logs.
|
|
1161
|
+
*/
|
|
1162
|
+
private _resetMqttConnection;
|
|
1056
1163
|
/**
|
|
1057
1164
|
* Processes incoming MQTT messages and emits appropriate events.
|
|
1058
1165
|
*
|
|
@@ -1175,4 +1282,4 @@ type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;
|
|
|
1175
1282
|
*/
|
|
1176
1283
|
type InPayload = MsgTypeInPayload | MsgInPayload;
|
|
1177
1284
|
|
|
1178
|
-
export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceAcStatus, 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, type HomeBase, type Homes, 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 MysaCredentials, type MysaDeviceMode, type MysaFanSpeedMode, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, UnknownDeviceError, VoidLogger };
|
|
1285
|
+
export { type BrandInfo, type ChangeDeviceState, type CheckDeviceSettings, type DeviceAcStatus, 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, type HomeBase, type Homes, 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 MysaCredentials, type MysaDeviceMode, type MysaFanSpeedMode, OutMessageType, type OutPayload, type SetPointChange, type StartPublishingDeviceStatus, type StateChange, type Status, type SupportedCaps, type TimestampedValue, UnauthenticatedError, UnknownDeviceError, UnsupportedFanSpeedError, VoidLogger };
|
package/dist/index.js
CHANGED
|
@@ -76,6 +76,27 @@ var UnknownDeviceError = class extends Error {
|
|
|
76
76
|
}
|
|
77
77
|
deviceId;
|
|
78
78
|
};
|
|
79
|
+
var UnsupportedFanSpeedError = class extends Error {
|
|
80
|
+
/**
|
|
81
|
+
* Creates a new UnsupportedFanSpeedError instance.
|
|
82
|
+
*
|
|
83
|
+
* @param deviceId - The id of the device the command was aimed at.
|
|
84
|
+
* @param fanSpeed - The requested fan speed that the device does not support.
|
|
85
|
+
* @param supportedFanSpeeds - The fan speeds the device does support.
|
|
86
|
+
*/
|
|
87
|
+
constructor(deviceId, fanSpeed, supportedFanSpeeds) {
|
|
88
|
+
super(
|
|
89
|
+
`Device '${deviceId}' does not support the '${fanSpeed}' fan speed. Supported fan speeds: ${supportedFanSpeeds.join(", ") || "(none)"}.`
|
|
90
|
+
);
|
|
91
|
+
this.deviceId = deviceId;
|
|
92
|
+
this.fanSpeed = fanSpeed;
|
|
93
|
+
this.supportedFanSpeeds = supportedFanSpeeds;
|
|
94
|
+
this.name = "UnsupportedFanSpeedError";
|
|
95
|
+
}
|
|
96
|
+
deviceId;
|
|
97
|
+
fanSpeed;
|
|
98
|
+
supportedFanSpeeds;
|
|
99
|
+
};
|
|
79
100
|
var MqttPublishError = class extends Error {
|
|
80
101
|
/**
|
|
81
102
|
* Creates a new MqttPublishError instance.
|
|
@@ -232,6 +253,43 @@ var MqttEndpoint = "a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com";
|
|
|
232
253
|
var MysaApiBaseUrl = "https://app-prod.mysa.cloud";
|
|
233
254
|
var RealtimeKeepAliveInterval = dayjs.duration(5, "minutes");
|
|
234
255
|
var PublishAckTimeout = dayjs.duration(30, "seconds");
|
|
256
|
+
var MqttInterruptWindow = dayjs.duration(30, "seconds");
|
|
257
|
+
var MqttInterruptThreshold = 3;
|
|
258
|
+
var MqttResetBaseDelay = dayjs.duration(1, "second");
|
|
259
|
+
var MqttResetMaxDelay = dayjs.duration(30, "seconds");
|
|
260
|
+
var MqttStabilityWindow = dayjs.duration(60, "seconds");
|
|
261
|
+
var CanonicalFanSpeedOrder = ["auto", "low", "medium", "high", "max"];
|
|
262
|
+
var LegacyFanSpeedSendMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
|
|
263
|
+
var FanSpeedReceiveMap = {
|
|
264
|
+
1: "auto",
|
|
265
|
+
2: "low",
|
|
266
|
+
// CodeNum=1117 canonical low
|
|
267
|
+
3: "low",
|
|
268
|
+
// legacy
|
|
269
|
+
4: "medium",
|
|
270
|
+
// CodeNum=1117 canonical medium
|
|
271
|
+
5: "medium",
|
|
272
|
+
// legacy
|
|
273
|
+
6: "high",
|
|
274
|
+
// CodeNum=1117 canonical high
|
|
275
|
+
7: "high",
|
|
276
|
+
// legacy
|
|
277
|
+
8: "max"
|
|
278
|
+
};
|
|
279
|
+
function buildFanSpeedSendMap(device) {
|
|
280
|
+
var _a;
|
|
281
|
+
const fanSpeeds = (_a = device.SupportedCaps) == null ? void 0 : _a.fanSpeeds;
|
|
282
|
+
if (!fanSpeeds || fanSpeeds.length === 0) {
|
|
283
|
+
return LegacyFanSpeedSendMap;
|
|
284
|
+
}
|
|
285
|
+
const map = {};
|
|
286
|
+
CanonicalFanSpeedOrder.forEach((name, index) => {
|
|
287
|
+
if (index < fanSpeeds.length) {
|
|
288
|
+
map[name] = fanSpeeds[index];
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
return map;
|
|
292
|
+
}
|
|
235
293
|
var MysaApiClient = class {
|
|
236
294
|
/** The credentials of the Mysa account this client authenticates as. */
|
|
237
295
|
_credentials;
|
|
@@ -255,8 +313,21 @@ var MysaApiClient = class {
|
|
|
255
313
|
_mqttInterrupts = [];
|
|
256
314
|
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
|
|
257
315
|
_mqttResetInProgress = false;
|
|
316
|
+
/** Monotonic id of the current MQTT connection; used to ignore events from discarded connections. */
|
|
317
|
+
_mqttGeneration = 0;
|
|
318
|
+
/** Consecutive forced resets without an intervening stable period; drives reset backoff. */
|
|
319
|
+
_mqttConsecutiveResets = 0;
|
|
320
|
+
/** Timestamp (ms) of the most recent successful MQTT (re)connect, for interrupt dwell-time diagnostics. */
|
|
321
|
+
_mqttLastConnectionSuccessAt;
|
|
322
|
+
/** Timer that clears the consecutive-reset counter once a connection stays healthy. */
|
|
323
|
+
_mqttStabilityTimer;
|
|
258
324
|
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
|
259
325
|
_realtimeDeviceIds = /* @__PURE__ */ new Map();
|
|
326
|
+
/**
|
|
327
|
+
* Raw topic filters registered via {@link startRawTopicCapture}, mapped to their message handlers. Re-subscribed on
|
|
328
|
+
* every reconnect so a debug capture survives connection resets.
|
|
329
|
+
*/
|
|
330
|
+
_rawTopicCaptures = /* @__PURE__ */ new Map();
|
|
260
331
|
/** The cached devices object, if any. */
|
|
261
332
|
_cachedDevices;
|
|
262
333
|
/**
|
|
@@ -310,6 +381,7 @@ var MysaApiClient = class {
|
|
|
310
381
|
this._cognitoUserSession = void 0;
|
|
311
382
|
this._mqttClientId = void 0;
|
|
312
383
|
this._mqttInterrupts = [];
|
|
384
|
+
this._mqttConsecutiveResets = 0;
|
|
313
385
|
const { username, password } = this._credentials;
|
|
314
386
|
return new Promise((resolve, reject) => {
|
|
315
387
|
const user = new CognitoUser({
|
|
@@ -501,6 +573,7 @@ var MysaApiClient = class {
|
|
|
501
573
|
* unchanged).
|
|
502
574
|
* @throws {@link UnauthenticatedError} When the client cannot authenticate with its credentials.
|
|
503
575
|
* @throws {@link UnknownDeviceError} When the device id does not match any device on the account.
|
|
576
|
+
* @throws {@link UnsupportedFanSpeedError} When the requested fan speed is not supported by the device.
|
|
504
577
|
* @throws {@link Error} When MQTT connection or command sending fails.
|
|
505
578
|
*/
|
|
506
579
|
async setDeviceState(deviceId, setPoint, mode, fanSpeed) {
|
|
@@ -518,7 +591,10 @@ var MysaApiClient = class {
|
|
|
518
591
|
const now = dayjs();
|
|
519
592
|
this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
|
|
520
593
|
const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
|
|
521
|
-
const fanSpeedMap =
|
|
594
|
+
const fanSpeedMap = buildFanSpeedSendMap(device);
|
|
595
|
+
if (fanSpeed !== void 0 && fanSpeedMap[fanSpeed] === void 0) {
|
|
596
|
+
throw new UnsupportedFanSpeedError(deviceId, fanSpeed, Object.keys(fanSpeedMap));
|
|
597
|
+
}
|
|
522
598
|
const payload = serializeMqttPayload({
|
|
523
599
|
msg: 44 /* CHANGE_DEVICE_STATE */,
|
|
524
600
|
id: now.valueOf(),
|
|
@@ -535,7 +611,7 @@ var MysaApiClient = class {
|
|
|
535
611
|
resp: 2,
|
|
536
612
|
body: {
|
|
537
613
|
ver: 1,
|
|
538
|
-
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,
|
|
614
|
+
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,
|
|
539
615
|
cmd: [
|
|
540
616
|
{
|
|
541
617
|
tm: -1,
|
|
@@ -639,6 +715,46 @@ var MysaApiClient = class {
|
|
|
639
715
|
clearInterval(timer);
|
|
640
716
|
this._realtimeDeviceIds.delete(deviceId);
|
|
641
717
|
}
|
|
718
|
+
/**
|
|
719
|
+
* Subscribes to raw MQTT topic filters and relays every message verbatim.
|
|
720
|
+
*
|
|
721
|
+
* Unlike {@link startRealtimeUpdates}, this performs no parsing, emits no typed events and sends no "start publishing"
|
|
722
|
+
* request to the device — it simply forwards the full message topic and the decoded UTF-8 payload of everything that
|
|
723
|
+
* arrives on the given filters. It exists to reverse-engineer device families the SDK does not model yet, most
|
|
724
|
+
* notably the AWS IoT Device Shadow protocol used by the central-HVAC ST-V1 thermostats, where both the topic (which
|
|
725
|
+
* shadow, and `accepted`/`rejected`/`delta`/`documents`) and the raw JSON body carry the information a new
|
|
726
|
+
* implementation needs.
|
|
727
|
+
*
|
|
728
|
+
* The capture is passive: the device only publishes to its shadow topics when something drives a change (the Mysa
|
|
729
|
+
* mobile app, a schedule, or the device itself), so exercise the thermostat while a capture is running.
|
|
730
|
+
*
|
|
731
|
+
* Registered filters are re-subscribed automatically after a reconnect.
|
|
732
|
+
*
|
|
733
|
+
* @param topicFilters - MQTT topic filters to subscribe to. Wildcards (`+`, `#`) are allowed, subject to the AWS IoT
|
|
734
|
+
* policy attached to the account's Cognito identity — a filter the policy forbids resolves with a non-zero
|
|
735
|
+
* `error_code`, which is logged rather than thrown so the remaining filters still subscribe.
|
|
736
|
+
* @param handler - Invoked with the full message topic and the decoded UTF-8 payload for every message received.
|
|
737
|
+
* @throws {@link Error} When the MQTT connection cannot be established.
|
|
738
|
+
*/
|
|
739
|
+
async startRawTopicCapture(topicFilters, handler) {
|
|
740
|
+
this._logger.info(`Starting raw topic capture for ${topicFilters.length} filter(s)`);
|
|
741
|
+
const connection = await this._getMqttConnection();
|
|
742
|
+
const decoder = new TextDecoder("utf-8");
|
|
743
|
+
for (const filter of topicFilters) {
|
|
744
|
+
this._rawTopicCaptures.set(filter, handler);
|
|
745
|
+
this._logger.debug(`Subscribing to raw topic filter '${filter}'...`);
|
|
746
|
+
try {
|
|
747
|
+
const result = await connection.subscribe(filter, mqtt.QoS.AtLeastOnce, (topic, payload) => {
|
|
748
|
+
handler(topic, decoder.decode(payload));
|
|
749
|
+
});
|
|
750
|
+
this._logger.debug(
|
|
751
|
+
`Raw subscribe to '${filter}' granted (topic='${result.topic}', qos=${result.qos}, error_code=${result.error_code ?? 0})`
|
|
752
|
+
);
|
|
753
|
+
} catch (error) {
|
|
754
|
+
this._logger.warn(`Failed to subscribe to raw topic filter '${filter}'`, error);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
642
758
|
/**
|
|
643
759
|
* Ensures a valid, non-expired session is available.
|
|
644
760
|
*
|
|
@@ -840,7 +956,7 @@ var MysaApiClient = class {
|
|
|
840
956
|
const usernameHash = hash("sha1", username);
|
|
841
957
|
this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
|
|
842
958
|
}
|
|
843
|
-
const builder = 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(
|
|
959
|
+
const builder = 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);
|
|
844
960
|
const config = builder.build();
|
|
845
961
|
config.websocket_handshake_transform = async (request, done) => {
|
|
846
962
|
try {
|
|
@@ -883,89 +999,174 @@ var MysaApiClient = class {
|
|
|
883
999
|
};
|
|
884
1000
|
const client = new mqtt.MqttClient();
|
|
885
1001
|
const connection = client.new_connection(config);
|
|
886
|
-
|
|
887
|
-
|
|
1002
|
+
const generation = ++this._mqttGeneration;
|
|
1003
|
+
connection.on("connect", (sessionPresent) => {
|
|
1004
|
+
if (generation !== this._mqttGeneration) return;
|
|
1005
|
+
this._logger.debug(`MQTT connect (clientId=${this._mqttClientId}, sessionPresent=${sessionPresent})`);
|
|
888
1006
|
});
|
|
889
|
-
connection.on("connection_success", () => {
|
|
890
|
-
|
|
1007
|
+
connection.on("connection_success", (result) => {
|
|
1008
|
+
if (generation !== this._mqttGeneration) return;
|
|
1009
|
+
this._mqttLastConnectionSuccessAt = Date.now();
|
|
1010
|
+
this._logger.debug(
|
|
1011
|
+
`MQTT connection_success (clientId=${this._mqttClientId}, sessionPresent=${result == null ? void 0 : result.session_present}, reasonCode=${result == null ? void 0 : result.reason_code})`
|
|
1012
|
+
);
|
|
1013
|
+
this._armMqttStabilityTimer(generation);
|
|
891
1014
|
});
|
|
892
1015
|
connection.on("connection_failure", (e) => {
|
|
1016
|
+
if (generation !== this._mqttGeneration) return;
|
|
893
1017
|
this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
|
|
894
1018
|
});
|
|
895
|
-
connection.on("interrupt",
|
|
1019
|
+
connection.on("interrupt", (e) => {
|
|
896
1020
|
var _a2;
|
|
897
|
-
|
|
1021
|
+
if (generation !== this._mqttGeneration) return;
|
|
1022
|
+
const dwellMs = this._mqttLastConnectionSuccessAt !== void 0 ? Date.now() - this._mqttLastConnectionSuccessAt : void 0;
|
|
1023
|
+
this._logger.warn(
|
|
1024
|
+
`MQTT interrupt (clientId=${this._mqttClientId}, dwellMs=${dwellMs ?? "n/a"}, generation=${generation})`,
|
|
1025
|
+
e
|
|
1026
|
+
);
|
|
1027
|
+
this._clearMqttStabilityTimer();
|
|
898
1028
|
const now = Date.now();
|
|
899
|
-
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t <
|
|
1029
|
+
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < MqttInterruptWindow.asMilliseconds());
|
|
900
1030
|
this._mqttInterrupts.push(now);
|
|
901
1031
|
const areCredentialsExpired = !(((_a2 = this._mqttCredentialsExpiration) == null ? void 0 : _a2.isAfter(dayjs())) ?? false);
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
|
|
907
|
-
);
|
|
908
|
-
} else {
|
|
909
|
-
this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
|
|
910
|
-
}
|
|
911
|
-
this._mqttClientId = void 0;
|
|
912
|
-
this._mqttCredentialsExpiration = void 0;
|
|
913
|
-
this._mqttInterrupts = [];
|
|
914
|
-
this._mqttConnectionPromise = void 0;
|
|
915
|
-
try {
|
|
916
|
-
connection.removeAllListeners();
|
|
917
|
-
await connection.disconnect();
|
|
918
|
-
try {
|
|
919
|
-
this._logger.debug("Old MQTT connection disconnected; establishing new connection...");
|
|
920
|
-
const newConnection = await this._getMqttConnection();
|
|
921
|
-
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
|
922
|
-
const topic = `/v1/dev/${deviceId}/out`;
|
|
923
|
-
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
924
|
-
await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
925
|
-
this._processMqttMessage(payload);
|
|
926
|
-
});
|
|
927
|
-
}
|
|
928
|
-
this._logger.info("MQTT connection rebuilt successfully after interrupt storm or credentials expiration");
|
|
929
|
-
} catch (err) {
|
|
930
|
-
this._logger.error("Failed to re-subscribe after interrupt storm or credentials expiration", err);
|
|
931
|
-
}
|
|
932
|
-
} catch (error) {
|
|
933
|
-
this._logger.error("Error during MQTT reset", error);
|
|
934
|
-
} finally {
|
|
935
|
-
this._mqttResetInProgress = false;
|
|
936
|
-
}
|
|
1032
|
+
const isStorm = this._mqttInterrupts.length >= MqttInterruptThreshold;
|
|
1033
|
+
if (isStorm || areCredentialsExpired) {
|
|
1034
|
+
const reason = isStorm ? `High interrupt rate (${this._mqttInterrupts.length} in ${MqttInterruptWindow.asSeconds()}s)` : "Credentials expired";
|
|
1035
|
+
void this._resetMqttConnection(reason);
|
|
937
1036
|
}
|
|
938
1037
|
});
|
|
939
1038
|
connection.on("resume", async (returnCode, sessionPresent) => {
|
|
1039
|
+
if (generation !== this._mqttGeneration) return;
|
|
940
1040
|
this._logger.info(
|
|
941
1041
|
`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
|
|
942
1042
|
);
|
|
943
1043
|
if (!sessionPresent) {
|
|
944
|
-
this._logger.info("No session present, re-subscribing each device");
|
|
945
1044
|
try {
|
|
946
|
-
|
|
947
|
-
const topic = `/v1/dev/${deviceId}/out`;
|
|
948
|
-
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
949
|
-
await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
950
|
-
this._processMqttMessage(payload);
|
|
951
|
-
});
|
|
952
|
-
}
|
|
1045
|
+
await this._resubscribeAll(connection);
|
|
953
1046
|
} catch (err) {
|
|
954
1047
|
this._logger.error("Failed to re-subscribe after resume", err);
|
|
955
1048
|
}
|
|
956
1049
|
}
|
|
957
1050
|
});
|
|
958
1051
|
connection.on("error", (e) => {
|
|
1052
|
+
if (generation !== this._mqttGeneration) return;
|
|
959
1053
|
this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
|
|
960
1054
|
});
|
|
961
1055
|
connection.on("closed", () => {
|
|
1056
|
+
if (generation !== this._mqttGeneration) return;
|
|
962
1057
|
this._logger.info("MQTT connection closed");
|
|
1058
|
+
this._clearMqttStabilityTimer();
|
|
963
1059
|
this._mqttConnectionPromise = void 0;
|
|
964
1060
|
this._mqttCredentialsExpiration = void 0;
|
|
965
1061
|
});
|
|
966
1062
|
await connection.connect();
|
|
967
1063
|
return connection;
|
|
968
1064
|
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Re-subscribes every device currently receiving real-time updates on the given connection.
|
|
1067
|
+
*
|
|
1068
|
+
* Safe to call on every (re)connect: aws-crt replaces the per-topic callback on a duplicate subscribe, so this never
|
|
1069
|
+
* registers a message handler more than once.
|
|
1070
|
+
*
|
|
1071
|
+
* @param connection - The connection to (re-)establish subscriptions on.
|
|
1072
|
+
*/
|
|
1073
|
+
async _resubscribeAll(connection) {
|
|
1074
|
+
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
|
|
1075
|
+
const topic = `/v1/dev/${deviceId}/out`;
|
|
1076
|
+
this._logger.debug(`Re-subscribing to ${topic}`);
|
|
1077
|
+
await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
|
|
1078
|
+
this._processMqttMessage(payload);
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
const decoder = new TextDecoder("utf-8");
|
|
1082
|
+
for (const [filter, handler] of Array.from(this._rawTopicCaptures.entries())) {
|
|
1083
|
+
this._logger.debug(`Re-subscribing to raw topic filter '${filter}'`);
|
|
1084
|
+
try {
|
|
1085
|
+
await connection.subscribe(filter, mqtt.QoS.AtLeastOnce, (topic, payload) => {
|
|
1086
|
+
handler(topic, decoder.decode(payload));
|
|
1087
|
+
});
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
this._logger.warn(`Failed to re-subscribe to raw topic filter '${filter}'`, error);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Arms (or re-arms) the stability timer. When a connection stays interrupt-free for {@link MqttStabilityWindow}, the
|
|
1095
|
+
* consecutive-reset backoff counter is cleared so a future isolated storm still gets a fast first reset.
|
|
1096
|
+
*
|
|
1097
|
+
* @param generation - The connection generation that armed the timer; the callback is a no-op if the connection has
|
|
1098
|
+
* since been replaced.
|
|
1099
|
+
*/
|
|
1100
|
+
_armMqttStabilityTimer(generation) {
|
|
1101
|
+
var _a, _b;
|
|
1102
|
+
this._clearMqttStabilityTimer();
|
|
1103
|
+
this._mqttStabilityTimer = setTimeout(() => {
|
|
1104
|
+
if (generation !== this._mqttGeneration) return;
|
|
1105
|
+
if (this._mqttConsecutiveResets > 0) {
|
|
1106
|
+
this._logger.debug("MQTT connection stable; clearing consecutive-reset counter");
|
|
1107
|
+
this._mqttConsecutiveResets = 0;
|
|
1108
|
+
}
|
|
1109
|
+
}, MqttStabilityWindow.asMilliseconds());
|
|
1110
|
+
(_b = (_a = this._mqttStabilityTimer).unref) == null ? void 0 : _b.call(_a);
|
|
1111
|
+
}
|
|
1112
|
+
/** Clears the stability timer, if armed. */
|
|
1113
|
+
_clearMqttStabilityTimer() {
|
|
1114
|
+
if (this._mqttStabilityTimer) {
|
|
1115
|
+
clearTimeout(this._mqttStabilityTimer);
|
|
1116
|
+
this._mqttStabilityTimer = void 0;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Forcefully tears down the current MQTT connection and rebuilds it with a fresh client id and fresh credentials,
|
|
1121
|
+
* escaping interrupt storms that a plain native reconnect cannot.
|
|
1122
|
+
*
|
|
1123
|
+
* Repeatable and self-contained: only one reset runs at a time, consecutive resets without an intervening stable
|
|
1124
|
+
* period back off exponentially (up to {@link MqttResetMaxDelay}), and it never rejects — so it is safe to invoke
|
|
1125
|
+
* fire-and-forget from an event handler.
|
|
1126
|
+
*
|
|
1127
|
+
* @param reason - Human-readable reason for the reset, used in logs.
|
|
1128
|
+
*/
|
|
1129
|
+
async _resetMqttConnection(reason) {
|
|
1130
|
+
if (this._mqttResetInProgress) {
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
this._mqttResetInProgress = true;
|
|
1134
|
+
const connectionToClose = this._mqttConnectionPromise;
|
|
1135
|
+
try {
|
|
1136
|
+
this._mqttConsecutiveResets++;
|
|
1137
|
+
const delayMs = Math.min(
|
|
1138
|
+
MqttResetBaseDelay.asMilliseconds() * Math.pow(2, this._mqttConsecutiveResets - 1),
|
|
1139
|
+
MqttResetMaxDelay.asMilliseconds()
|
|
1140
|
+
);
|
|
1141
|
+
this._logger.warn(
|
|
1142
|
+
`${reason}. Forcing MQTT reset #${this._mqttConsecutiveResets} (new clientId, fresh credentials) after ${Math.round(delayMs)}ms...`
|
|
1143
|
+
);
|
|
1144
|
+
this._mqttClientId = void 0;
|
|
1145
|
+
this._mqttCredentialsExpiration = void 0;
|
|
1146
|
+
this._mqttInterrupts = [];
|
|
1147
|
+
this._clearMqttStabilityTimer();
|
|
1148
|
+
this._mqttConnectionPromise = void 0;
|
|
1149
|
+
if (connectionToClose) {
|
|
1150
|
+
try {
|
|
1151
|
+
const connection = await connectionToClose;
|
|
1152
|
+
connection.removeAllListeners();
|
|
1153
|
+
await connection.disconnect();
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
this._logger.debug("Error tearing down old MQTT connection during reset (ignored)", err);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (delayMs > 0) {
|
|
1159
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
1160
|
+
}
|
|
1161
|
+
const newConnection = await this._getMqttConnection();
|
|
1162
|
+
await this._resubscribeAll(newConnection);
|
|
1163
|
+
this._logger.info(`MQTT connection rebuilt successfully after reset #${this._mqttConsecutiveResets} (${reason})`);
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
this._logger.error("Failed to rebuild MQTT connection after reset", err);
|
|
1166
|
+
} finally {
|
|
1167
|
+
this._mqttResetInProgress = false;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
969
1170
|
/**
|
|
970
1171
|
* Processes incoming MQTT messages and emits appropriate events.
|
|
971
1172
|
*
|
|
@@ -1001,7 +1202,6 @@ var MysaApiClient = class {
|
|
|
1001
1202
|
} else if (isMsgOutPayload(parsedPayload)) {
|
|
1002
1203
|
switch (parsedPayload.msg) {
|
|
1003
1204
|
case 30 /* DEVICE_AC_STATUS */:
|
|
1004
|
-
case 40 /* DEVICE_V2_STATUS */:
|
|
1005
1205
|
this.emitter.emit("statusChanged", {
|
|
1006
1206
|
deviceId: parsedPayload.src.ref,
|
|
1007
1207
|
temperature: parsedPayload.body.ambTemp,
|
|
@@ -1010,6 +1210,16 @@ var MysaApiClient = class {
|
|
|
1010
1210
|
dutyCycle: parsedPayload.body.dtyCycle
|
|
1011
1211
|
});
|
|
1012
1212
|
break;
|
|
1213
|
+
case 40 /* DEVICE_V2_STATUS */:
|
|
1214
|
+
this.emitter.emit("statusChanged", {
|
|
1215
|
+
deviceId: parsedPayload.src.ref,
|
|
1216
|
+
temperature: parsedPayload.body.ambTemp,
|
|
1217
|
+
humidity: parsedPayload.body.hum,
|
|
1218
|
+
setPoint: parsedPayload.body.stpt,
|
|
1219
|
+
dutyCycle: parsedPayload.body.dtyCycle ?? parsedPayload.body.heatStat,
|
|
1220
|
+
floorTemperature: parsedPayload.body.flrSnsrTemp
|
|
1221
|
+
});
|
|
1222
|
+
break;
|
|
1013
1223
|
case 44 /* DEVICE_STATE_CHANGE */: {
|
|
1014
1224
|
const modeMap = {
|
|
1015
1225
|
1: "off",
|
|
@@ -1019,18 +1229,11 @@ var MysaApiClient = class {
|
|
|
1019
1229
|
5: "fan_only",
|
|
1020
1230
|
6: "dry"
|
|
1021
1231
|
};
|
|
1022
|
-
const fanSpeedMap = {
|
|
1023
|
-
1: "auto",
|
|
1024
|
-
3: "low",
|
|
1025
|
-
5: "medium",
|
|
1026
|
-
7: "high",
|
|
1027
|
-
8: "max"
|
|
1028
|
-
};
|
|
1029
1232
|
this.emitter.emit("stateChanged", {
|
|
1030
1233
|
deviceId: parsedPayload.src.ref,
|
|
1031
1234
|
mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : void 0,
|
|
1032
1235
|
setPoint: parsedPayload.body.state.sp,
|
|
1033
|
-
fanSpeed: parsedPayload.body.state.fn !== void 0 ?
|
|
1236
|
+
fanSpeed: parsedPayload.body.state.fn !== void 0 ? FanSpeedReceiveMap[parsedPayload.body.state.fn] : void 0
|
|
1034
1237
|
});
|
|
1035
1238
|
break;
|
|
1036
1239
|
}
|
|
@@ -1049,6 +1252,7 @@ export {
|
|
|
1049
1252
|
OutMessageType,
|
|
1050
1253
|
UnauthenticatedError,
|
|
1051
1254
|
UnknownDeviceError,
|
|
1255
|
+
UnsupportedFanSpeedError,
|
|
1052
1256
|
VoidLogger
|
|
1053
1257
|
};
|
|
1054
1258
|
//# sourceMappingURL=index.js.map
|