mirror of
https://github.com/bourquep/mysa-js-sdk.git
synced 2026-02-04 01:31:05 +00:00
feat: Initial commit
This commit is contained in:
437
src/api/MysaApiClient.ts
Normal file
437
src/api/MysaApiClient.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { MysaSession } from '@/api/MysaSession';
|
||||
import { EventEmitter } from '@/lib/EventEmitter';
|
||||
import { parseMqttPayload, serializeMqttPayload } from '@/lib/PayloadParser';
|
||||
import { isMsgOutPayload, isMsgTypeOutPayload } from '@/lib/PayloadTypeGuards';
|
||||
import { ChangeDeviceState } from '@/types/mqtt/in/ChangeDeviceState';
|
||||
import { InMessageType } from '@/types/mqtt/in/InMessageType';
|
||||
import { StartPublishingDeviceStatus } from '@/types/mqtt/in/StartPublishingDeviceStatus';
|
||||
import { OutMessageType } from '@/types/mqtt/out/OutMessageType';
|
||||
import { Devices } from '@/types/rest/Devices';
|
||||
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
|
||||
import {
|
||||
AuthenticationDetails,
|
||||
CognitoAccessToken,
|
||||
CognitoIdToken,
|
||||
CognitoRefreshToken,
|
||||
CognitoUser,
|
||||
CognitoUserPool,
|
||||
CognitoUserSession
|
||||
} from 'amazon-cognito-identity-js';
|
||||
import { iot, mqtt } from 'aws-iot-device-sdk-v2';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { MysaApiError, UnauthenticatedError } from './Errors';
|
||||
import { Logger, VoidLogger } from './Logger';
|
||||
import { MysaApiClientEventTypes } from './MysaApiClientEventTypes';
|
||||
import { MysaApiClientOptions } from './MysaApiClientOptions';
|
||||
import { MysaDeviceMode } from './MysaDeviceMode';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const AwsRegion = 'us-east-1';
|
||||
const CognitoUserPoolId = 'us-east-1_GUFWfhI7g';
|
||||
const CognitoClientId = '19efs8tgqe942atbqmot5m36t3';
|
||||
const CognitoIdentityPoolId = 'us-east-1:ebd95d52-9995-45da-b059-56b865a18379';
|
||||
const CognitoLoginKey = `cognito-idp.${AwsRegion}.amazonaws.com/${CognitoUserPoolId}`;
|
||||
const MqttEndpoint = 'a3q27gia9qg3zy-ats.iot.us-east-1.amazonaws.com';
|
||||
const MysaApiBaseUrl = 'https://app-prod.mysa.cloud';
|
||||
const RealtimeKeepAliveInterval = dayjs.duration(5, 'minutes');
|
||||
|
||||
/**
|
||||
* Main client for interacting with the Mysa API and real-time device communication.
|
||||
*
|
||||
* The MysaApiClient provides a comprehensive interface for authenticating with Mysa services, managing device data, and
|
||||
* receiving real-time updates from Mysa thermostats and heating devices. It handles both REST API calls for device
|
||||
* management and MQTT connections for live status updates and control commands.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* const client = new MysaApiClient();
|
||||
*
|
||||
* await client.login('user@example.com', 'password');
|
||||
* const devices = await client.getDevices();
|
||||
*
|
||||
* client.emitter.on('statusChanged', (status) => {
|
||||
* console.log(`Device ${status.deviceId} temperature: ${status.temperature}°C`);
|
||||
* });
|
||||
*
|
||||
* for (const device of Object.entries(devices.DevicesObj)) {
|
||||
* await client.startRealtimeUpdates(device[0]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class MysaApiClient {
|
||||
/** The current session object, if any. */
|
||||
private _cognitoUserSession?: CognitoUserSession;
|
||||
|
||||
/** The current user object, if any. */
|
||||
private _cognitoUser?: CognitoUser;
|
||||
|
||||
/** The logger instance used by the client. */
|
||||
private _logger: Logger;
|
||||
|
||||
/** The fetcher function used by the client. */
|
||||
private _fetcher: typeof fetch;
|
||||
|
||||
/** The MQTT connection used for real-time updates. */
|
||||
private _mqttConnection?: mqtt.MqttClientConnection;
|
||||
|
||||
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
|
||||
private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
/** The cached devices object, if any. */
|
||||
private _cachedDevices?: Devices;
|
||||
|
||||
/**
|
||||
* Event emitter for client events.
|
||||
*
|
||||
* @see {@link MysaApiClientEventTypes} for the possible events and their payloads.
|
||||
*/
|
||||
readonly emitter = new EventEmitter<MysaApiClientEventTypes>();
|
||||
|
||||
/**
|
||||
* Gets the persistable session object.
|
||||
*
|
||||
* @returns The current persistable session object, if any.
|
||||
*/
|
||||
get session(): MysaSession | undefined {
|
||||
if (!this._cognitoUserSession || !this._cognitoUser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
username: this._cognitoUser.getUsername(),
|
||||
idToken: this._cognitoUserSession.getIdToken().getJwtToken(),
|
||||
accessToken: this._cognitoUserSession.getAccessToken().getJwtToken(),
|
||||
refreshToken: this._cognitoUserSession.getRefreshToken().getToken()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the client currently has an active session.
|
||||
*
|
||||
* @returns True if the client has an active session, false otherwise.
|
||||
*/
|
||||
get isAuthenticated(): boolean {
|
||||
return !!this.session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new instance of the MysaApiClient.
|
||||
*
|
||||
* @param session - The persistable session object, if any.
|
||||
* @param options - The options for the client.
|
||||
*/
|
||||
constructor(session?: MysaSession, options?: MysaApiClientOptions) {
|
||||
this._logger = options?.logger || new VoidLogger();
|
||||
this._fetcher = options?.fetcher || fetch;
|
||||
|
||||
if (session) {
|
||||
this._cognitoUser = new CognitoUser({
|
||||
Username: session.username,
|
||||
Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
|
||||
});
|
||||
this._cognitoUserSession = new CognitoUserSession({
|
||||
IdToken: new CognitoIdToken({ IdToken: session.idToken }),
|
||||
AccessToken: new CognitoAccessToken({ AccessToken: session.accessToken }),
|
||||
RefreshToken: new CognitoRefreshToken({ RefreshToken: session.refreshToken })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user with the given email address and password.
|
||||
*
|
||||
* @param emailAddress - The email address of the user.
|
||||
* @param password - The password of the user.
|
||||
*/
|
||||
async login(emailAddress: string, password: string): Promise<void> {
|
||||
this._cognitoUser = undefined;
|
||||
this._cognitoUserSession = undefined;
|
||||
this.emitter.emit('sessionChanged', this.session);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const user = new CognitoUser({
|
||||
Username: emailAddress,
|
||||
Pool: new CognitoUserPool({ UserPoolId: CognitoUserPoolId, ClientId: CognitoClientId })
|
||||
});
|
||||
|
||||
user.authenticateUser(new AuthenticationDetails({ Username: emailAddress, Password: password }), {
|
||||
onSuccess: (session) => {
|
||||
this._cognitoUser = user;
|
||||
this._cognitoUserSession = session;
|
||||
this.emitter.emit('sessionChanged', this.session);
|
||||
|
||||
resolve();
|
||||
},
|
||||
onFailure: (err) => {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the list of devices associated with the user.
|
||||
*
|
||||
* @returns A promise that resolves to the list of devices.
|
||||
*/
|
||||
async getDevices(): Promise<Devices> {
|
||||
this._logger.debug(`Fetching devices...`);
|
||||
|
||||
const session = await this.getFreshSession();
|
||||
|
||||
const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
|
||||
headers: {
|
||||
Authorization: `${session.getAccessToken().getJwtToken()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new MysaApiError(response);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode) {
|
||||
this._logger.debug(`Setting device state for '${deviceId}'`);
|
||||
|
||||
if (!this._cachedDevices) {
|
||||
this._cachedDevices = await this.getDevices();
|
||||
}
|
||||
|
||||
const device = this._cachedDevices.DevicesObj[deviceId];
|
||||
|
||||
this._logger.debug(`Initializing MQTT connection...`);
|
||||
const mqttConnection = await this.getMqttConnection();
|
||||
|
||||
const now = dayjs();
|
||||
|
||||
this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
|
||||
const payload = serializeMqttPayload<ChangeDeviceState>({
|
||||
msg: InMessageType.CHANGE_DEVICE_STATE,
|
||||
id: now.unix(),
|
||||
time: now.unix(),
|
||||
ver: '1.0',
|
||||
src: {
|
||||
ref: this.session!.username,
|
||||
type: 100
|
||||
},
|
||||
dest: {
|
||||
ref: deviceId,
|
||||
type: 1
|
||||
},
|
||||
resp: 2,
|
||||
body: {
|
||||
ver: 1,
|
||||
type: device.Model.startsWith('BB-V1')
|
||||
? 1
|
||||
: device.Model.startsWith('BB-V2')
|
||||
? device.Model.endsWith('-L')
|
||||
? 5
|
||||
: 4
|
||||
: 0,
|
||||
cmd: [
|
||||
{
|
||||
tm: -1,
|
||||
sp: setPoint,
|
||||
md: mode === 'off' ? 1 : mode === 'heat' ? 3 : undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts receiving real-time updates for the specified device.
|
||||
*
|
||||
* @param deviceId - The ID of the device to start receiving updates for.
|
||||
*/
|
||||
async startRealtimeUpdates(deviceId: string) {
|
||||
this._logger.info(`Starting realtime updates for device '${deviceId}'`);
|
||||
|
||||
if (this._realtimeDeviceIds.has(deviceId)) {
|
||||
this._logger.debug(`Realtime updates for device '${deviceId}' already started`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.debug(`Initializing MQTT connection...`);
|
||||
const mqttConnection = await this.getMqttConnection();
|
||||
|
||||
this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
|
||||
await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, mqtt.QoS.AtLeastOnce, (_, payload) => {
|
||||
this.processMqttMessage(payload);
|
||||
});
|
||||
|
||||
this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`);
|
||||
const payload = serializeMqttPayload<StartPublishingDeviceStatus>({
|
||||
Device: deviceId,
|
||||
MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS,
|
||||
Timestamp: dayjs().unix(),
|
||||
Timeout: RealtimeKeepAliveInterval.asSeconds()
|
||||
});
|
||||
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
|
||||
const payload = serializeMqttPayload<StartPublishingDeviceStatus>({
|
||||
Device: deviceId,
|
||||
MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS,
|
||||
Timestamp: dayjs().unix(),
|
||||
Timeout: RealtimeKeepAliveInterval.asSeconds()
|
||||
});
|
||||
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
|
||||
}, RealtimeKeepAliveInterval.subtract(10, 'seconds').asMilliseconds());
|
||||
|
||||
this._realtimeDeviceIds.set(deviceId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops receiving real-time updates for the specified device.
|
||||
*
|
||||
* @param deviceId - The ID of the device to stop receiving real-time updates for.
|
||||
*/
|
||||
async stopRealtimeUpdates(deviceId: string) {
|
||||
const timer = this._realtimeDeviceIds.get(deviceId);
|
||||
if (!timer) {
|
||||
this._logger.warn(`No real-time updates are running for device '${deviceId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.debug(`Initializing MQTT connection...`);
|
||||
const mqttConnection = await this.getMqttConnection();
|
||||
|
||||
this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
|
||||
await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`);
|
||||
|
||||
this._logger.debug(`Stopping real-time updates for device '${deviceId}'...`);
|
||||
clearInterval(timer);
|
||||
this._realtimeDeviceIds.delete(deviceId);
|
||||
}
|
||||
|
||||
private async getFreshSession(): Promise<CognitoUserSession> {
|
||||
if (!this._cognitoUser || !this._cognitoUserSession) {
|
||||
throw new UnauthenticatedError('An attempt was made to access a resource without a valid session.');
|
||||
}
|
||||
|
||||
if (
|
||||
this._cognitoUserSession.isValid() &&
|
||||
dayjs.unix(this._cognitoUserSession.getAccessToken().getExpiration()).isAfter()
|
||||
) {
|
||||
this._logger.info('Session is valid, no need to refresh');
|
||||
return Promise.resolve(this._cognitoUserSession);
|
||||
}
|
||||
|
||||
this._logger.info('Session is not valid or expired, refreshing...');
|
||||
return new Promise<CognitoUserSession>((resolve, reject) => {
|
||||
this._cognitoUser!.refreshSession(this._cognitoUserSession!.getRefreshToken(), (error, session) => {
|
||||
if (error) {
|
||||
this._logger.error('Failed to refresh session:', error);
|
||||
reject(new UnauthenticatedError('Unable to refresh the authentication session.'));
|
||||
} else {
|
||||
this._logger.info('Session refreshed successfully');
|
||||
this._cognitoUserSession = session;
|
||||
this.emitter.emit('sessionChanged', this.session);
|
||||
resolve(session);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async getMqttConnection(): Promise<mqtt.MqttClientConnection> {
|
||||
if (this._mqttConnection) {
|
||||
return this._mqttConnection;
|
||||
}
|
||||
|
||||
const session = await this.getFreshSession();
|
||||
const credentialsProvider = fromCognitoIdentityPool({
|
||||
clientConfig: {
|
||||
region: AwsRegion
|
||||
},
|
||||
identityPoolId: CognitoIdentityPoolId,
|
||||
logins: {
|
||||
[CognitoLoginKey]: session.getIdToken().getJwtToken()
|
||||
},
|
||||
logger: this._logger
|
||||
});
|
||||
const credentials = await credentialsProvider();
|
||||
|
||||
const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets()
|
||||
.with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken)
|
||||
.with_endpoint(MqttEndpoint)
|
||||
.with_client_id(`mysa-js-sdk-${dayjs().unix()}`) // Unique client ID
|
||||
.with_clean_session(true)
|
||||
.with_keep_alive_seconds(30)
|
||||
.with_ping_timeout_ms(3000)
|
||||
.with_protocol_operation_timeout_ms(60000);
|
||||
|
||||
const config = builder.build();
|
||||
const client = new mqtt.MqttClient();
|
||||
this._mqttConnection = client.new_connection(config);
|
||||
|
||||
this._mqttConnection.on('closed', () => {
|
||||
this._logger.info('MQTT connection closed');
|
||||
this._mqttConnection = undefined;
|
||||
});
|
||||
|
||||
await this._mqttConnection.connect();
|
||||
|
||||
return this._mqttConnection;
|
||||
}
|
||||
|
||||
private processMqttMessage(payload: ArrayBuffer) {
|
||||
try {
|
||||
const parsedPayload = parseMqttPayload(payload);
|
||||
|
||||
this.emitter.emit('rawRealtimeMessageReceived', parsedPayload);
|
||||
|
||||
if (isMsgTypeOutPayload(parsedPayload)) {
|
||||
switch (parsedPayload.MsgType) {
|
||||
case OutMessageType.DEVICE_V1_STATUS:
|
||||
this.emitter.emit('statusChanged', {
|
||||
deviceId: parsedPayload.Device,
|
||||
temperature: parsedPayload.MainTemp,
|
||||
humidity: parsedPayload.Humidity,
|
||||
setPoint: parsedPayload.SetPoint,
|
||||
current: parsedPayload.Current
|
||||
});
|
||||
break;
|
||||
|
||||
case OutMessageType.DEVICE_SETPOINT_CHANGE:
|
||||
this.emitter.emit('setPointChanged', {
|
||||
deviceId: parsedPayload.Device,
|
||||
newSetPoint: parsedPayload.Next,
|
||||
previousSetPoint: parsedPayload.Prev
|
||||
});
|
||||
break;
|
||||
}
|
||||
} else if (isMsgOutPayload(parsedPayload)) {
|
||||
switch (parsedPayload.msg) {
|
||||
case OutMessageType.DEVICE_V2_STATUS:
|
||||
this.emitter.emit('statusChanged', {
|
||||
deviceId: parsedPayload.src.ref,
|
||||
temperature: parsedPayload.body.ambTemp,
|
||||
humidity: parsedPayload.body.hum,
|
||||
setPoint: parsedPayload.body.stpt,
|
||||
dutyCycle: parsedPayload.body.dtyCycle
|
||||
});
|
||||
break;
|
||||
|
||||
case OutMessageType.DEVICE_STATE_CHANGE:
|
||||
this.emitter.emit('stateChanged', {
|
||||
deviceId: parsedPayload.src.ref,
|
||||
mode: parsedPayload.body.state.md === 1 ? 'off' : parsedPayload.body.state.md === 3 ? 'heat' : undefined,
|
||||
setPoint: parsedPayload.body.state.sp
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this._logger.error('Error handling MQTT message:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user