feat: Initial commit

This commit is contained in:
Pascal Bourque
2025-05-25 11:03:21 -04:00
commit ff0163043a
50 changed files with 15156 additions and 0 deletions

18
.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
# Top-most EditorConfig file
root = true
# Default settings for all files
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 120
# TypeScript and JavaScript files
[*.{ts,tsx,js,jsx}]
quote_type = single
curly_bracket_next_line = false
spaces_around_operators = true

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
docs
vitest-ctrf
.env
session.json

177
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,177 @@
# Contributing to mysa-js-sdk
First off, thank you for considering contributing to `mysa-js-sdk`! Contributions from the community are essential in
making this project better. Whether you want to report a bug, propose new features, improve documentation or submit code
changes, I welcome your input and assistance. This guide will help you get started with contributing.
## Development Environment
This project uses Node.js and npm for development. Make sure you have Node.js 22.4.0 or higher installed.
### Setting Up Your Development Environment
1. Fork the repository on GitHub
2. Clone your fork locally
3. Navigate to the cloned directory
4. Install dependencies using npm:
```bash
npm install
```
### Project Structure
```
mysa-js-sdk/
├── src/ # Source code
│ ├── api/ # Main API classes and client
│ │ └── events/ # Event type definitions
│ ├── lib/ # Utility libraries
│ └── types/ # TypeScript type definitions
│ ├── mqtt/ # MQTT message types
│ │ ├── in/ # Incoming MQTT message types
│ │ └── out/ # Outgoing MQTT message types
│ └── rest/ # REST API types
├── example/ # Example usage
├── dist/ # Built JavaScript files (generated)
├── docs/ # Generated API documentation (generated)
└── node_modules/ # Dependencies (generated)
```
### Development Workflow
1. Create a new branch for your feature or bug fix
2. Make your changes
3. Run linting checks: `npm run lint` and `npm run style-lint`
4. Build the project: `npm run build`
5. Commit your changes using conventional commit format
6. Push your changes to your fork
7. Create a pull request
### Building
Build the project using [tsup](https://github.com/egoist/tsup):
```bash
npm run build
```
This will generate JavaScript files in the `dist/` directory.
### Documentation
Generate API documentation using TypeDoc:
```bash
npm run build:docs
```
This will generate documentation in the `docs/` directory.
### Environment Variables
To run the [example](example/main.ts), you'll need to provide your Mysa credentials. Create a `.env` file in the project
root:
```
MYSA_USERNAME=your-email@example.com
MYSA_PASSWORD=your-password
```
**Important:** Never commit your `.env` file to the repository!
## Submitting Pull Requests
### Conventional Commits
This repository uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). This means that each commit
message must follow a specific format. The format is as follows:
```
<type>[optional scope]: <description>
```
The `type` must be one of the following:
| Type | Description |
| ---------- | ---------------------------------------------------------------------------------- |
| `feat` | A new feature. |
| `fix` | A bug fix. |
| `docs` | Documentation only changes. |
| `test` | Changes to tests. |
| `perf` | A code change that improves performance. |
| `refactor` | A code change that neither fixes a bug nor adds a feature. |
| `style` | Changes that do not affect the meaning of the code (white-space, formatting, etc). |
| `chore` | Regular maintenance tasks and updates. |
| `build` | Changes that affect the build system or external dependencies. |
| `ci` | Changes to CI configuration files and scripts. |
| `revert` | Reverting a previous commit. |
The `scope` is optional and should be used to specify the part of the codebase that the commit affects.
The `description` should be a short, concise summary of the changes made in the commit. The description will appear
as-is in the release notes, so make sure it is clear, informative and not too technical.
For example:
```
feat: Added support for dark mode
```
### Semantic Versioning
This repository uses [semantic versioning](https://semver.org/). This means that each release will be versioned
according to the following rules:
- Increment the major version for breaking changes
- Increment the minor version for new features
- Increment the patch version for bug fixes
Releases are automatically generated by [semantic-release](https://github.com/semantic-release/semantic-release) based
on the commit messages. The version number is determined by the type of commits since the last release.
### Coding Standards
This project adheres to a set of coding standards to ensure consistency and maintainability:
1. **TypeScript**: Write all code in TypeScript with proper type annotations.
2. **Documentation**: Use [TSDoc](https://tsdoc.org/) comments for all public APIs.
3. **Clean Code**: Write clear, self-explanatory code with meaningful variable names.
4. **Error Handling**: Properly handle errors and edge cases.
### Code Style
This repository uses [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) to enforce code style and
formatting. All code must pass both linting checks:
```bash
# Run ESLint
npm run lint
# Run Prettier
npm run style-lint
```
The project is configured with specific rules for:
- Maximum line length
- Indentation (2 spaces)
- Quote style (single quotes)
- Semi-colons (required)
- Trailing commas
- And more
These rules are automatically enforced and cannot be overridden. Please make sure that your code follows these
conventions.
### Pull Request Checklist
Before submitting a pull request, please make sure that:
- Your code follows the coding standards and conventions used in the project
- Your code passes linting checks: `npm run lint`
- Your code passes style checks: `npm run style-lint`
- The documentation has been updated to reflect any changes
- Your commit messages follow the conventional commits format
- The build completes successfully: `npm run build`
- You've verified that your changes work as expected

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

225
README.md Normal file
View File

@@ -0,0 +1,225 @@
# Mysa Smart Thermostat JavaScript SDK
[![NPM Version](https://img.shields.io/npm/v/mysa-js-sdk)](https://www.npmjs.com/package/mysa-js-sdk)
[![CodeQL](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql)
[![CI: lint, build and release](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml)
A JavaScript SDK for accessing Mysa smart thermostats.
## Description
This SDK provides a simple and intuitive way to interact with Mysa smart thermostats, allowing developers to easily
query and update data from their Mysa smart thermostats, including real-time updates.
## Disclaimer
This SDK was developed without the consent of the Mysa Smart Thermostats company, and makes use of undocumented and
unsupported APIs. Use at your own risk, and be aware that Mysa may change the APIs at any time and break this repository
permanently.
## Installation
### Prerequisites
- You must own at least one [Mysa Smart Thermostat](https://getmysa.com) or have credentials to access a working setup.
- Node.js version 22.4.0 or higher.
```bash
# Using npm
npm install mysa-js-sdk
# Using yarn
yarn add mysa-js-sdk
# Using pnpm
pnpm add mysa-js-sdk
```
### Running the Example Application
To run the [example](example/main.ts) application, you'll need to provide your Mysa credentials. Create a `.env` file in
the project root:
```
MYSA_USERNAME=your-email@example.com
MYSA_PASSWORD=your-password
```
Then, run the example:
```bash
npm run example
```
## Using
The Mysa SDK provides a simple interface to interact with Mysa smart thermostats.
### Basic Authentication
```typescript
import { MysaApiClient } from 'mysa-js-sdk';
const client = new MysaApiClient();
// Login with email and password
await client.login('your-email@example.com', 'your-password');
// Check if authenticated
if (client.isAuthenticated) {
console.log('Successfully authenticated!');
}
```
### Retrieving Thermostat Data
Once authenticated, you can access your thermostat data:
```typescript
// Get all devices
const devices = await client.getDevices();
// Access individual devices
for (const [deviceId, device] of Object.entries(devices.DevicesObj)) {
console.log(`Device: ${device.Name}`);
console.log(`Model: ${device.Model}`);
console.log(`Location: ${device.Location}`);
console.log(`Voltage: ${device.Voltage}V`);
}
// Set device temperature and mode
await client.setDeviceState('device-id', 22, 'heat'); // Set to 22°C in heat mode
await client.setDeviceState('device-id', undefined, 'off'); // Turn off
```
### Real-time Updates
The SDK also supports real-time updates:
```typescript
// Listen for temperature and status changes
client.emitter.on('statusChanged', (status) => {
console.log(`Device ${status.deviceId}:`);
console.log(` Temperature: ${status.temperature}°C`);
console.log(` Humidity: ${status.humidity}%`);
console.log(` Set Point: ${status.setPoint}°C`);
if (status.current !== undefined) {
console.log(` Current: ${status.current}A`);
}
});
// Listen for setpoint changes
client.emitter.on('setPointChanged', (change) => {
console.log(`Setpoint changed from ${change.previousSetPoint}°C to ${change.newSetPoint}°C`);
});
// Listen for device state changes
client.emitter.on('stateChanged', (change) => {
console.log(`Device mode changed to: ${change.mode}`);
console.log(`New setpoint: ${change.setPoint}°C`);
});
// Start real-time updates for all devices
const devices = await client.getDevices();
for (const deviceId of Object.keys(devices.DevicesObj)) {
await client.startRealtimeUpdates(deviceId);
}
```
### Error Handling
The SDK provides specific error types to handle API errors:
```typescript
import { MysaApiClient, MysaApiError, UnauthenticatedError } from 'mysa-js-sdk';
const client = new MysaApiClient();
try {
await client.login('user@example.com', 'password');
const devices = await client.getDevices();
} catch (error) {
if (error instanceof UnauthenticatedError) {
console.error('Authentication failed:', error.message);
} else if (error instanceof MysaApiError) {
console.error(`API Error ${error.status}: ${error.statusText}`);
} else {
console.error('Unexpected error:', error);
}
}
```
### Advanced Configuration
You can customize the client with various options:
```typescript
import { MysaApiClient } from 'mysa-js-sdk';
import { pino } from 'pino';
// Create a custom logger
const logger = pino({
name: 'mysa-client',
level: 'debug'
});
// Configure client with options
const client = new MysaApiClient(undefined, {
logger: logger,
fetcher: fetch // Custom fetch implementation if needed
});
// Or restore from a saved session
const savedSession = {
username: 'user@example.com',
idToken: 'eyJ...',
accessToken: 'eyJ...',
refreshToken: 'abc123...'
};
const clientWithSession = new MysaApiClient(savedSession, { logger });
// Listen for session changes to persist them
client.emitter.on('sessionChanged', (newSession) => {
if (newSession) {
// Save session to storage (file, database, etc.)
localStorage.setItem('mysaSession', JSON.stringify(newSession));
} else {
// Session expired or logged out
localStorage.removeItem('mysaSession');
}
});
```
### Reference documentation
The complete reference documentation for the `mysa-js-sdk` library can be found at
[https://bourquep.github.io/mysa-js-sdk/](https://bourquep.github.io/mysa-js-sdk/).
## Contributing
If you want to contribute to this project, please read the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines.
## License
`mysa-js-sdk` is licensed under the MIT License. This is a permissive license that allows you to use, modify, and
redistribute this software in both private and commercial projects. You can change the code and distribute your changes
without being required to release your source code. The MIT License only requires that you include the original
copyright notice and license text in any copy of the software or substantial portion of it.
## Copyright
© 2025 Pascal Bourque
## Support
For bug reports and feature requests, please use the [GitHub Issues](https://github.com/bourquep/mysa-js-sdk/issues)
page.
For general questions and discussions, join our [Discussion Forum](https://github.com/bourquep/mysa-js-sdk/discussions).
## Acknowledgments
This library would not be possible without the amazing work by [@dlenski](https://github.com/dlenski) in his
[mysotherm](https://github.com/dlenski/mysotherm) repository. He's the one who reversed-engineered the Mysa MQTT
protocol which is being used by this library.

23
eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import pluginJs from '@eslint/js';
import jsdoc from 'eslint-plugin-jsdoc';
import tsdoc from 'eslint-plugin-tsdoc';
import globals from 'globals';
import tseslint from 'typescript-eslint';
/** @type {import('eslint').Linter.Config[]} */
export default [
{ files: ['**/*.{js,mjs,cjs,ts}'] },
{ languageOptions: { globals: globals.node } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
jsdoc.configs['flat/recommended-typescript'],
{
plugins: { jsdoc },
rules: {
'jsdoc/tag-lines': 'off',
'jsdoc/check-tag-names': 'off',
'jsdoc/valid-types': 'off'
}
},
{ plugins: { tsdoc }, rules: { 'tsdoc/syntax': 'warn' } }
];

96
example/main.ts Normal file
View File

@@ -0,0 +1,96 @@
import { MysaApiClient } from '@/api/MysaApiClient';
import { MysaSession } from '@/api/MysaSession';
import 'dotenv/config';
import { readFile, rm, writeFile } from 'fs/promises';
import { pino } from 'pino';
const rootLogger = pino({
name: 'example',
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
singleLine: true,
ignore: 'hostname,module',
messageFormat: '\x1b[33m[{module}]\x1b[39m {msg}'
}
}
}).child({ module: 'example' });
/** Main entry point of the example application. */
async function main() {
let session: MysaSession | undefined;
try {
rootLogger.info('Loading session...');
const sessionJson = await readFile('session.json', 'utf8');
session = JSON.parse(sessionJson);
} catch {
rootLogger.info('No valid session file found.');
}
const client = new MysaApiClient(session, { logger: rootLogger.child({ module: 'mysa-js-sdk' }) });
client.emitter.on('sessionChanged', async (newSession) => {
if (newSession) {
rootLogger.info('Saving new session...');
await writeFile('session.json', JSON.stringify(newSession));
} else {
try {
rootLogger.info('Removing session file...');
await rm('session.json');
} catch {
// Ignore error if file does not exist
}
}
});
if (!client.isAuthenticated) {
rootLogger.info('Logging in...');
const username = process.env.MYSA_USERNAME;
const password = process.env.MYSA_PASSWORD;
if (!username || !password) {
throw new Error('Missing MYSA_USERNAME or MYSA_PASSWORD environment variables.');
}
await client.login(username, password);
}
const devices = await client.getDevices();
client.emitter.on('statusChanged', (status) => {
try {
const device = devices.DevicesObj[status.deviceId];
const watts = status.current !== undefined ? status.current * device.Voltage : undefined;
rootLogger.info(
`'${device.Name}' status changed: ${status.temperature}°C, ${status.humidity}%, ${watts ?? 'na'}W`
);
} catch (error) {
rootLogger.error(`Error processing status update for device '${status.deviceId}':`, error);
}
});
client.emitter.on('setPointChanged', (change) => {
try {
const device = devices.DevicesObj[change.deviceId];
rootLogger.info(`'${device.Name}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`);
} catch (error) {
rootLogger.error(`Error processing setpoint update for device '${change.deviceId}':`, error);
}
});
client.emitter.on('stateChanged', (change) => {
try {
const device = devices.DevicesObj[change.deviceId];
rootLogger.info(change, `'${device.Name}' state changed.`);
} catch (error) {
rootLogger.error(`Error processing setpoint update for device '${change.deviceId}':`, error);
}
});
for (const device of Object.entries(devices.DevicesObj)) {
await client.startRealtimeUpdates(device[0]);
}
}
main().catch(rootLogger.error);

13032
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

78
package.json Normal file
View File

@@ -0,0 +1,78 @@
{
"name": "mysa-js-sdk",
"version": "0.0.0",
"license": "MIT",
"private": false,
"description": "A JavaScript SDK for accessing Mysa smart thermostats.",
"keywords": [
"mysa",
"thermostat",
"sdk",
"api"
],
"publishConfig": {
"provenance": true
},
"author": {
"name": "Pascal Bourque",
"email": "pascal@cosmos.moi"
},
"bugs": {
"url": "https://github.com/bourquep/mysa-js-sdk/issues"
},
"homepage": "https://github.com/bourquep/mysa-js-sdk",
"repository": {
"type": "git",
"url": "git+https://github.com/bourquep/mysa-js-sdk.git"
},
"files": [
"README.md",
"LICENSE.txt",
"dist"
],
"type": "module",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"engines": {
"node": ">=22.4.0"
},
"browser": false,
"scripts": {
"example": "tsx --watch ./example/main.ts",
"lint": "eslint --max-warnings 0 src/**/*.ts",
"style-lint": "prettier -c .",
"build": "tsup",
"build:docs": "typedoc"
},
"dependencies": {
"@aws-sdk/credential-providers": "3.808.0",
"amazon-cognito-identity-js": "6.3.15",
"aws-iot-device-sdk-v2": "1.21.4",
"dayjs": "1.11.13",
"lodash": "4.17.21"
},
"devDependencies": {
"@eslint/js": "9.27.0",
"@semantic-release/npm": "12.0.1",
"@types/lodash": "4.17.16",
"@types/node": "22.15.17",
"conventional-changelog-conventionalcommits": "9.0.0",
"dotenv": "16.5.0",
"eslint": "9.27.0",
"eslint-plugin-jsdoc": "50.6.17",
"eslint-plugin-tsdoc": "0.4.0",
"pino": "9.7.0",
"pino-pretty": "13.0.0",
"prettier": "3.5.3",
"prettier-plugin-jsdoc": "1.3.2",
"prettier-plugin-organize-imports": "4.1.0",
"semantic-release": "24.2.5",
"tsup": "8.5.0",
"tsx": "4.19.4",
"typedoc": "0.28.4",
"typedoc-material-theme": "1.4.0",
"typescript": "5.8.3",
"typescript-eslint": "8.32.1"
}
}

11
prettier.config.cjs Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
tabWidth: 2,
useTabs: false,
printWidth: 120,
proseWrap: 'always',
singleQuote: true,
trailingComma: 'none',
arrowParens: 'always',
tsdoc: true,
plugins: ['prettier-plugin-organize-imports', 'prettier-plugin-jsdoc']
};

38
release.config.mjs Normal file
View File

@@ -0,0 +1,38 @@
/** @type {import('semantic-release').GlobalConfig} */
const semanticReleaseConfig = {
branches: ['main'],
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'conventionalcommits',
releaseRules: [{ type: 'chore', scope: 'deps', release: 'patch' }]
}
],
[
'@semantic-release/release-notes-generator',
{
preset: 'conventionalcommits',
presetConfig: {
types: [
{ type: 'feat', section: '✨ Features' },
{ type: 'fix', section: '🐛 Bug Fixes' },
{ type: 'docs', section: '📚 Documentation' },
{ type: 'test', section: '🧪 Tests' },
{ type: 'perf', section: '⚡️ Performance Improvements' },
{ type: 'refactor', section: '♻️ Code Refactoring' },
{ type: 'style', section: '💄 Style' },
{ type: 'chore', section: '🔧 Maintenance' },
{ type: 'build', section: '📦 Build System' },
{ type: 'ci', section: '👷 Continuous Integration' },
{ type: 'revert', section: '⏪ Reverts' }
]
}
}
],
'@semantic-release/npm',
'@semantic-release/github'
]
};
export default semanticReleaseConfig;

34
src/api/Errors.ts Normal file
View File

@@ -0,0 +1,34 @@
/** Error thrown when attempting to access the Mysa API without proper authentication. */
export class UnauthenticatedError extends Error {
/**
* Creates a new UnauthenticatedError instance.
*
* @param message - The error message
*/
constructor(message: string) {
super(message);
this.name = 'UnauthenticatedError';
}
}
/** Error thrown when a Mysa API request fails. */
export class MysaApiError extends Error {
/** The HTTP status code returned by the API */
readonly status: number;
/** The HTTP status text returned by the API */
readonly statusText: string;
/**
* Creates a new MysaApiError instance.
*
* @param apiResponse - The failed Response object from the API call
*/
constructor(apiResponse: Response) {
super(
`Failed to call the '${apiResponse.url}' Mysa API endpoint. The server responded with a status of ${apiResponse.status} (${apiResponse.statusText}).`
);
this.name = 'MysaApiError';
this.status = apiResponse.status;
this.statusText = apiResponse.statusText;
}
}

24
src/api/Logger.ts Normal file
View File

@@ -0,0 +1,24 @@
/** Interface for logging operations at different severity levels */
export interface Logger {
/** Logs a debug message with optional metadata */
debug(message: string, ...meta: unknown[]): void;
/** Logs an info message with optional metadata */
info(message: string, ...meta: unknown[]): void;
/** Logs a warning message with optional metadata */
warn(message: string, ...meta: unknown[]): void;
/** Logs an error message with optional metadata */
error(message: string, ...meta: unknown[]): void;
}
/** Logger implementation that silently discards all log messages. */
/* eslint-disable @typescript-eslint/no-unused-vars */
export class VoidLogger implements Logger {
debug(message: string, ...meta: unknown[]): void {}
info(message: string, ...meta: unknown[]): void {}
warn(message: string, ...meta: unknown[]): void {}
error(message: string, ...meta: unknown[]): void {}
}
/* eslint-enable @typescript-eslint/no-unused-vars */

437
src/api/MysaApiClient.ts Normal file
View 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);
}
}
}

View File

@@ -0,0 +1,62 @@
import { SetPointChange } from '@/api/events/SetPointChange';
import { StateChange } from '@/api/events/StateChange';
import { Status } from '@/api/events/Status';
import { MysaSession } from '@/api/MysaSession';
import { OutPayload } from '@/types/mqtt/OutPayload';
/**
* Defines the event types and their parameters for the MysaApiClient.
*
* This type maps event names to their corresponding parameter arrays, providing type safety for event subscription and
* emission in the Mysa API client's event system.
*/
export type MysaApiClientEventTypes = {
/**
* Event emitted when the session changes.
*
* @remarks
* You should subscribe to this event and persist the session object whenever it changes.
* @param session - The new session object or undefined if session was cleared.
*/
sessionChanged: [session: MysaSession | undefined];
/**
* Event emitted when a device's status information is updated.
*
* This event provides comprehensive status information including temperature readings, operational state, and device
* health data.
*
* @param status - The updated device status information
*/
statusChanged: [status: Status];
/**
* Event emitted when a device's temperature setpoint is changed.
*
* This event is triggered when the target temperature for a device is modified, either through user interaction or
* programmatic control.
*
* @param change - Details about the setpoint change including old and new values
*/
setPointChanged: [change: SetPointChange];
/**
* Event emitted when a device's operational state changes.
*
* This event is triggered when device parameters such as mode, brightness, or other operational settings are
* modified.
*
* @param change - Details about the state change including affected parameters
*/
stateChanged: [change: StateChange];
/**
* Event emitted when a raw MQTT message is received from devices.
*
* This low-level event provides access to the unprocessed MQTT payload for advanced use cases that require direct
* access to the raw device data.
*
* @param message - The raw outgoing MQTT payload from the device
*/
rawRealtimeMessageReceived: [message: OutPayload];
};

View File

@@ -0,0 +1,18 @@
import { Logger } from './Logger';
/** Configuration options for the Mysa API client. */
export interface MysaApiClientOptions {
/**
* Optional logger instance for client logging.
*
* @defaultValue A _void_ logger instance that does nothing.
*/
logger?: Logger;
/**
* Optional fetch function to use for HTTP requests.
*
* @defaultValue The global `fetch` function.
*/
fetcher?: typeof fetch;
}

View File

@@ -0,0 +1,7 @@
/**
* Union type representing the available operating modes for Mysa devices.
*
* Defines the possible operational states that a Mysa thermostat or heating device can be set to. These modes control
* the device's heating behavior and power consumption.
*/
export type MysaDeviceMode = 'off' | 'heat';

16
src/api/MysaSession.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* Interface representing an authenticated Mysa user session.
*
* Contains the authentication tokens and user information required to make authorized API calls to the Mysa service.
* These tokens are typically obtained through the login process and used for subsequent API requests.
*/
export interface MysaSession {
/** The username/email address of the authenticated user */
username: string;
/** JWT identity token containing user identity information */
idToken: string;
/** JWT access token used for authorizing API requests */
accessToken: string;
/** JWT refresh token used to obtain new access tokens when they expire */
refreshToken: string;
}

View File

@@ -0,0 +1,15 @@
/**
* Interface representing a temperature setpoint change event for a Mysa device.
*
* This event is emitted when a device's target temperature setting is modified, providing both the previous and new
* setpoint values for tracking and logging purposes. The change may be initiated by user interaction, scheduling, or
* programmatic control through the API.
*/
export interface SetPointChange {
/** Unique identifier of the device whose setpoint was changed */
deviceId: string;
/** The new temperature setpoint value after the change */
newSetPoint: number;
/** The previous temperature setpoint value before the change */
previousSetPoint: number;
}

View File

@@ -0,0 +1,17 @@
import { MysaDeviceMode } from '@/api/MysaDeviceMode';
/**
* Interface representing a device state change event for a Mysa device.
*
* This event is emitted when a device's operational parameters are modified, such as changing the operating mode or
* temperature setpoint. State changes can be initiated through user interaction, scheduling, or programmatic control
* through the API.
*/
export interface StateChange {
/** Unique identifier of the device whose state was changed */
deviceId: string;
/** The device's operating mode (e.g., 'heat', 'off'), if available */
mode?: MysaDeviceMode;
/** Current temperature setpoint after the state change */
setPoint: number;
}

20
src/api/events/Status.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Interface representing the current status of a Mysa device.
*
* Contains real-time operational data and measurements from the device, including environmental readings and electrical
* parameters. This data is typically received through status update events from the device.
*/
export interface Status {
/** Unique identifier of the device reporting this status */
deviceId: string;
/** Current ambient temperature reading from the device sensor */
temperature: number;
/** Current relative humidity percentage reading from the device sensor */
humidity: number;
/** Current temperature setpoint setting */
setPoint: number;
/** Optional electrical current draw measurement in amperes */
current?: number;
/** Optional heating element duty cycle as a percentage (0-100) */
dutyCycle?: number;
}

3
src/api/events/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './SetPointChange';
export * from './StateChange';
export * from './Status';

8
src/api/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export * from './Errors';
export * from './events';
export * from './Logger';
export * from './MysaApiClient';
export * from './MysaApiClientEventTypes';
export * from './MysaApiClientOptions';
export * from './MysaDeviceMode';
export * from './MysaSession';

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './api';

106
src/lib/EventEmitter.ts Normal file
View File

@@ -0,0 +1,106 @@
import { EventEmitter as NodeEventEmitter } from 'node:events';
/**
* Typed wrapper around Node's `EventEmitter` class.
*
* @remarks
* Source: {@link https://blog.makerx.com.au/a-type-safe-event-emitter-in-node-js}
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export class EventEmitter<TEvents extends Record<string, any>> implements NodeJS.EventEmitter {
private _emitter = new NodeEventEmitter();
emit<TEventName extends keyof TEvents & string>(eventName: TEventName, ...eventArg: TEvents[TEventName]) {
return this._emitter.emit(eventName, ...(eventArg as []));
}
on<TEventName extends keyof TEvents & string>(
eventName: TEventName,
handler: (...eventArg: TEvents[TEventName]) => void
) {
this._emitter.on(eventName, handler as any);
return this;
}
once<TEventName extends keyof TEvents & string>(
eventName: TEventName,
handler: (...eventArg: TEvents[TEventName]) => void
) {
this._emitter.once(eventName, handler as any);
return this;
}
off<TEventName extends keyof TEvents & string>(
eventName: TEventName,
handler: (...eventArg: TEvents[TEventName]) => void
) {
this._emitter.off(eventName, handler as any);
return this;
}
addListener<TEventName extends keyof TEvents & string>(
eventName: TEventName,
listener: (...args: TEvents[TEventName]) => void
) {
this._emitter.addListener(eventName, listener);
return this;
}
removeListener<TEventName extends keyof TEvents & string>(
eventName: TEventName,
listener: (...args: TEvents[TEventName]) => void
) {
this._emitter.removeListener(eventName, listener);
return this;
}
removeAllListeners<TEventName extends keyof TEvents & string>(eventName?: TEventName | undefined) {
this._emitter.removeAllListeners(eventName);
return this;
}
setMaxListeners(n: number) {
this._emitter.setMaxListeners(n);
return this;
}
getMaxListeners(): number {
return this._emitter.getMaxListeners();
}
listeners<TEventName extends keyof TEvents & string>(eventName: TEventName) {
return this._emitter.listeners(eventName);
}
rawListeners<TEventName extends keyof TEvents & string>(eventName: TEventName) {
return this._emitter.rawListeners(eventName);
}
listenerCount<TEventName extends keyof TEvents & string>(
eventName: TEventName,
listener?: (...args: TEvents[TEventName]) => void
) {
return this._emitter.listenerCount(eventName, listener);
}
prependListener<TEventName extends keyof TEvents & string>(
eventName: TEventName,
listener: (...args: TEvents[TEventName]) => void
) {
this._emitter.prependListener(eventName, listener);
return this;
}
prependOnceListener<TEventName extends keyof TEvents & string>(
eventName: TEventName,
listener: (...args: TEvents[TEventName]) => void
) {
this._emitter.prependOnceListener(eventName, listener);
return this;
}
eventNames() {
return this._emitter.eventNames();
}
}
/* eslint-enable @typescript-eslint/no-explicit-any */

39
src/lib/PayloadParser.ts Normal file
View File

@@ -0,0 +1,39 @@
import { InPayload } from '@/types/mqtt/InPayload';
import { OutPayload } from '@/types/mqtt/OutPayload';
/**
* Parses an MQTT payload from binary data into a typed OutPayload object.
*
* Converts the raw ArrayBuffer received from MQTT messages into a structured TypeScript object representing device
* status, state changes, or other outgoing message types from Mysa devices.
*
* @param payload - The raw binary MQTT message payload as ArrayBuffer
* @returns The parsed payload as a typed OutPayload object
* @throws Error if the payload cannot be decoded or parsed as valid JSON
*/
export function parseMqttPayload(payload: ArrayBuffer): OutPayload {
try {
const decoder = new TextDecoder('utf-8');
const jsonString = decoder.decode(payload);
return JSON.parse(jsonString);
} catch (error) {
console.error('Error parsing MQTT payload:', error);
throw new Error('Failed to parse MQTT payload');
}
}
/**
* Serializes an InPayload object into binary data for MQTT transmission.
*
* Converts a typed TypeScript payload object into the binary ArrayBuffer format required for sending commands and
* requests to Mysa devices via MQTT.
*
* @typeParam T - The specific InPayload type being serialized
* @param payload - The typed payload object to serialize
* @returns The serialized payload as ArrayBuffer ready for MQTT transmission
*/
export function serializeMqttPayload<T extends InPayload>(payload: T): ArrayBuffer {
const jsonString = JSON.stringify(payload);
const encoder = new TextEncoder();
return encoder.encode(jsonString);
}

View File

@@ -0,0 +1,29 @@
import { MsgOutPayload } from '@/types/mqtt/MsgOutPayload';
import { MsgTypeOutPayload } from '@/types/mqtt/MsgTypeOutPayload';
import { OutPayload } from '@/types/mqtt/OutPayload';
/**
* Type guard function to determine if an OutPayload is a MsgType-based payload.
*
* Checks whether the payload uses the legacy MsgType field format for message type identification. This is used to
* differentiate between different payload structures and ensure proper type narrowing in TypeScript.
*
* @param payload - The OutPayload to check
* @returns True if the payload is a MsgTypeOutPayload, false otherwise
*/
export function isMsgTypeOutPayload(payload: OutPayload): payload is MsgTypeOutPayload {
return 'MsgType' in payload;
}
/**
* Type guard function to determine if an OutPayload is a message-based payload.
*
* Checks whether the payload uses the newer msg field format for message type identification. This is used to
* differentiate between different payload structures and ensure proper type narrowing in TypeScript.
*
* @param payload - The OutPayload to check
* @returns True if the payload is a MsgOutPayload, false otherwise
*/
export function isMsgOutPayload(payload: OutPayload): payload is MsgOutPayload {
return 'msg' in payload;
}

View File

@@ -0,0 +1,10 @@
import { MsgInPayload } from './MsgInPayload';
import { MsgTypeInPayload } from './MsgTypeInPayload';
/**
* Union type representing all possible incoming MQTT payload types.
*
* This type encompasses both message type-based payloads and message-based payloads that can be received from Mysa
* devices via MQTT.
*/
export type InPayload = MsgTypeInPayload | MsgInPayload;

View File

@@ -0,0 +1,28 @@
/**
* Base interface for all MQTT message payloads.
*
* This interface defines the common structure that all MQTT messages must contain, providing essential metadata for
* message handling.
*/
export interface MsgBasePayload {
/** The message type identifier */
msg: number;
/** Unix timestamp when the message was created */
time: number;
/** Version string of the message format */
ver: string;
/** Unique identifier for the device or message source */
id: number;
}
/**
* Generic typed message payload interface.
*
* Extends the base payload with a strongly-typed message identifier, ensuring type safety for specific message types.
*
* @typeParam T - The specific message type number
*/
export interface MsgPayload<T extends number> extends MsgBasePayload {
/** The strongly-typed message type identifier */
msg: T;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDeviceState } from './in/ChangeDeviceState';
/**
* Union type representing all possible incoming message-based MQTT payloads.
*
* This type encompasses payloads where the message type is specified in the `msg` field rather than the `MsgType`
* field. Currently includes device state change commands.
*/
export type MsgInPayload = ChangeDeviceState;

View File

@@ -0,0 +1,10 @@
import { DeviceStateChange } from './out/DeviceStateChange';
import { DeviceV2Status } from './out/DeviceV2Status';
/**
* Union type representing all possible outgoing message-based MQTT payloads.
*
* This type encompasses payloads where the message type is specified in the `msg` field rather than the `MsgType`
* field. Includes device status reports and state change notifications.
*/
export type MsgOutPayload = DeviceV2Status | DeviceStateChange;

View File

@@ -0,0 +1,27 @@
/**
* Base interface for MQTT message payloads that use the MsgType field.
*
* This interface defines the common structure for MQTT messages where the message type is specified in the `MsgType`
* field rather than the `msg` field. These are typically older message formats or specific device communications.
*/
export interface MsgTypeBasePayload {
/** The message type identifier */
MsgType: number;
/** Unix timestamp when the message was created */
Timestamp: number;
/** Device identifier string */
Device: string;
}
/**
* Generic typed message payload interface for MsgType-based messages.
*
* Extends the base MsgType payload with a strongly-typed message identifier, ensuring type safety for specific message
* types that use the MsgType field.
*
* @typeParam T - The specific message type number
*/
export interface MsgTypePayload<T extends number> extends MsgTypeBasePayload {
/** The strongly-typed message type identifier */
MsgType: T;
}

View File

@@ -0,0 +1,10 @@
import { CheckDeviceSettings } from './in/CheckDeviceSettings';
import { StartPublishingDeviceStatus } from './in/StartPublishingDeviceStatus';
/**
* Union type representing all possible incoming MsgType-based MQTT payloads.
*
* This type encompasses payloads where the message type is specified in the `MsgType` field rather than the `msg`
* field. These are typically configuration and control commands that use the legacy message format structure.
*/
export type MsgTypeInPayload = CheckDeviceSettings | StartPublishingDeviceStatus;

View File

@@ -0,0 +1,13 @@
import { DeviceLog } from './out/DeviceLog';
import { DevicePostBoot } from './out/DevicePostBoot';
import { DeviceSetpointChange } from './out/DeviceSetpointChange';
import { DeviceV1Status } from './out/DeviceV1Status';
/**
* Union type representing all possible outgoing MsgType-based MQTT payloads.
*
* This type encompasses payloads where the message type is specified in the `MsgType` field rather than the `msg`
* field. These include legacy device status reports, configuration change notifications, diagnostic logs, and system
* events that use the older message format.
*/
export type MsgTypeOutPayload = DeviceV1Status | DeviceSetpointChange | DeviceLog | DevicePostBoot;

View File

@@ -0,0 +1,10 @@
import { MsgOutPayload } from './MsgOutPayload';
import { MsgTypeOutPayload } from './MsgTypeOutPayload';
/**
* Union type representing all possible outgoing MQTT payload types.
*
* This type encompasses both message type-based payloads and message-based payloads that can be sent from Mysa devices
* via MQTT.
*/
export type OutPayload = MsgTypeOutPayload | MsgOutPayload;

View File

@@ -0,0 +1,49 @@
import { MsgPayload } from '../MsgBasePayload';
import { InMessageType } from './InMessageType';
/**
* Interface representing a command to change the state of a Mysa device.
*
* This message type allows clients to modify device settings such as temperature setpoint and operating mode. The
* command is structured with source and destination routing information along with the specific state changes to
* apply.
*/
export interface ChangeDeviceState extends MsgPayload<InMessageType.CHANGE_DEVICE_STATE> {
/** Source routing information for the command */
src: {
/** Reference identifier for the command source. Should correspond to the user id. */
ref: string;
/** Type identifier for the source. Should be 100. */
type: number;
};
/** Destination routing information for the command */
dest: {
/** Reference identifier for the command destination (device) */
ref: string;
/** Type identifier for the destination. Should be 1. */
type: number;
};
/** Unknown, should always be 2. */
resp: number;
/** Command payload containing the state changes to apply */
body: {
/** Array of command objects to execute */
cmd: [
{
/** Optional temperature setpoint in the device's configured units */
sp?: number;
/** Optional device mode (e.g., heat, off) */
md?: number;
/** Unknown, should always be -1 */
tm: number;
}
];
/**
* Command type identifier. Must be 1 for BB-V1-X, 4 for BB-V2-X, and 5 for BB-V2-X-L. Devices don't seem to respond
* to this command if it has the wrong type value for the device.
*/
type: number;
/** Command format version. Should be 1. */
ver: number;
};
}

View File

@@ -0,0 +1,13 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { InMessageType } from './InMessageType';
/**
* Interface representing a request to check and retrieve device settings.
*
* This message is sent to query a device for its current configuration and settings. The response typically includes
* device parameters, modes, and other configuration data needed for proper device management.
*/
export interface CheckDeviceSettings extends MsgTypePayload<InMessageType.CHECK_DEVICE_SETTINGS> {
/** Event type identifier specifying what kind of settings check to perform */
EventType: number;
}

View File

@@ -0,0 +1,24 @@
/**
* Enumeration of message types for incoming MQTT messages from clients to devices.
*
* These message types determine how commands and requests are interpreted by Mysa devices. The enum values correspond
* to specific numeric identifiers used in the MQTT protocol.
*/
export enum InMessageType {
//
// When the message type is reported in the `MsgType` field of the payload.
//
/** Request to check and retrieve current device settings */
CHECK_DEVICE_SETTINGS = 6,
/** Command to start publishing periodic device status updates */
START_PUBLISHING_DEVICE_STATUS = 11,
//
// When the message type is reported in the `msg` field of the payload.
//
/** Command to change the current state of a device (temperature, mode, etc.) */
CHANGE_DEVICE_STATE = 44
}

View File

@@ -0,0 +1,13 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { InMessageType } from './InMessageType';
/**
* Interface representing a command to start publishing periodic device status updates.
*
* This message instructs a device to begin sending regular status reports at predefined intervals. The timeout
* parameter controls how long the device should continue publishing status updates before stopping automatically.
*/
export interface StartPublishingDeviceStatus extends MsgTypePayload<InMessageType.START_PUBLISHING_DEVICE_STATUS> {
/** Timeout duration in seconds for how long to continue publishing status updates */
Timeout: number;
}

View File

@@ -0,0 +1,15 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a device log entry from a Mysa device.
*
* This message contains diagnostic information, error reports, or general logging data from the device. Log entries
* include a severity level and a descriptive message for debugging and monitoring purposes.
*/
export interface DeviceLog extends MsgTypePayload<OutMessageType.DEVICE_LOG> {
/** Log severity level (e.g., "INFO", "WARN", "ERROR", "DEBUG") */
Level: string;
/** Descriptive log message containing the actual log content */
Message: string;
}

View File

@@ -0,0 +1,11 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a device post-boot notification from a Mysa device.
*
* This message is sent when a device has completed its boot sequence and is ready for normal operation. It serves as a
* signal that the device has successfully initialized and is available for commands and status requests.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface DevicePostBoot extends MsgTypePayload<OutMessageType.DEVICE_POST_BOOT> {}

View File

@@ -0,0 +1,17 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a device setpoint change notification from a Mysa device.
*
* This message is sent when a device's temperature setpoint has been modified, providing information about the source
* of the change and both the previous and new setpoint values for tracking and logging purposes.
*/
export interface DeviceSetpointChange extends MsgTypePayload<OutMessageType.DEVICE_SETPOINT_CHANGE> {
/** Source identifier indicating what initiated the setpoint change (user, schedule, etc.) */
Source: number;
/** Previous temperature setpoint value before the change */
Prev: number;
/** New temperature setpoint value after the change */
Next: number;
}

View File

@@ -0,0 +1,40 @@
import { MsgPayload } from '../MsgBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a device state change notification from a Mysa device.
*
* This message is sent when a device's operational state has been modified, either through user interaction, scheduled
* changes, or external commands. It provides confirmation of the change and the resulting device state.
*/
export interface DeviceStateChange extends MsgPayload<OutMessageType.DEVICE_STATE_CHANGE> {
/** Source information identifying the device that changed state */
src: {
/** Reference identifier for the device */
ref: string;
/** Type identifier for the source device */
type: number;
};
/** State change data payload containing the new device state and change metadata */
body: {
/** Current device state parameters after the change */
state: {
/** Brightness level (0-100) */
br: number;
/** Unknown */
ho: number;
/** Unknown */
lk: number;
/** Device mode (1 = OFF, 3 = HEAT) */
md: number;
/** Temperature setpoint */
sp: number;
};
/** Success indicator for the state change operation (1 = success, 0 = failure) */
success: number;
/** Trigger source identifier indicating what initiated the state change */
trig_src: number;
/** State change type identifier */
type: number;
};
}

View File

@@ -0,0 +1,26 @@
import { MsgTypePayload } from '../MsgTypeBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a version 1 device status report from a Mysa device.
*
* This legacy status message format provides basic operational information about the device's current state, including
* temperature readings, electrical parameters, and configuration settings. Version 1 status reports use the MsgType
* field format.
*/
export interface DeviceV1Status extends MsgTypePayload<OutMessageType.DEVICE_V1_STATUS> {
/** Main temperature sensor reading */
MainTemp: number;
/** Thermistor temperature sensor reading */
ThermistorTemp: number;
/** Combined/calculated temperature reading */
ComboTemp: number;
/** Relative humidity percentage reading */
Humidity: number;
/** Current electrical current draw in amperes */
Current: number;
/** Current temperature setpoint setting */
SetPoint: number;
/** Data stream identifier or status */
Stream: number;
}

View File

@@ -0,0 +1,30 @@
import { MsgPayload } from '../MsgBasePayload';
import { OutMessageType } from './OutMessageType';
/**
* Interface representing a version 2 device status report from a Mysa device.
*
* This enhanced status message provides comprehensive information about the device's current operational state,
* including environmental readings and system parameters. Version 2 status reports include additional data compared to
* version 1 reports.
*/
export interface DeviceV2Status extends MsgPayload<OutMessageType.DEVICE_V2_STATUS> {
/** Source information identifying the device sending the status */
src: {
/** Reference identifier for the device */
ref: string;
/** Type identifier for the source device */
type: number;
};
/** Status data payload containing current device measurements and settings */
body: {
/** Ambient temperature reading from the device sensor */
ambTemp: number;
/** Current duty cycle percentage of the heating element */
dtyCycle: number;
/** Relative humidity percentage reading from the device sensor */
hum: number;
/** Current temperature setpoint setting */
stpt: number;
};
}

View File

@@ -0,0 +1,33 @@
/**
* Enumeration of message types for outgoing MQTT messages from devices to clients.
*
* These message types identify different kinds of status updates, notifications, and data reports that Mysa devices can
* send via MQTT. The enum values correspond to specific numeric identifiers used in the MQTT protocol.
*/
export enum OutMessageType {
//
// When the message type is reported in the `MsgType` field of the payload.
//
/** Version 1 device status report with basic device information */
DEVICE_V1_STATUS = 0,
/** Notification that a device's temperature setpoint has been changed */
DEVICE_SETPOINT_CHANGE = 1,
/** Device log entry or diagnostic information */
DEVICE_LOG = 4,
/** Notification sent when a device completes its boot sequence */
DEVICE_POST_BOOT = 10,
//
// When the message type is reported in the `msg` field of the payload.
//
/** Version 2 device status report with enhanced device information */
DEVICE_V2_STATUS = 40,
/** Notification that a device's operational state has changed */
DEVICE_STATE_CHANGE = 44
}

150
src/types/rest/Devices.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* Brand information for air conditioning devices.
*
* Contains manufacturer and model details for AC units that are controlled through the Mysa system, including both
* brand and OEM information.
*/
export interface BrandInfo {
/** The brand name of the AC device */
Brand: string;
/** Unique identifier for the brand */
Id: number;
/** Remote control model number for the AC device */
remoteModelNumber: string;
/** Original Equipment Manufacturer brand name */
OEMBrand: string;
}
/**
* Supported capabilities and features for air conditioning devices.
*
* Defines the operational parameters and available functions for AC units, including temperature ranges, operating
* modes, and supported control keys.
*/
export interface SupportedCaps {
/** Temperature range as [minimum, maximum] in device units */
tempRange: [number, number];
/** Available operating modes with their supported temperature settings */
modes: {
[modeId: string]: {
/** Array of available temperature setpoints for this mode */
temperatures: number[];
};
};
/** Version string of the capability definition */
version: string;
/** Array of supported remote control key codes */
keys: number[];
}
/**
* Device operating mode information.
*
* Represents the current or available operating mode for a device, identified by a numeric mode identifier.
*/
export interface ModeObj {
/** Numeric identifier for the device operating mode */
Id: number;
}
/**
* Base interface for all Mysa device types.
*
* Defines the common properties and configuration parameters shared across different types of Mysa devices, including
* thermostats, switches, and AC controllers. This interface encompasses both required core properties and optional
* features that may vary depending on the specific device model and capabilities.
*/
export interface DeviceBase {
/** Button digital input configuration value */
ButtonDI: number;
/** Maximum current rating as a string value */
MaxCurrent: string;
/** Device model identifier string */
Model: string;
/** Button average value configuration */
ButtonAVE: number;
/** Operating voltage of the device */
Voltage: number;
/** Button polling interval configuration */
ButtonPolling: number;
/** Minimum brightness level (0-100) */
MinBrightness: number;
/** User-assigned device name */
Name: string;
/** Button low power mode configuration */
ButtonLowPower: number;
/** Type of heater controlled by the device */
HeaterType: string;
/** Button repeat delay configuration in milliseconds */
ButtonRepeatDelay: number;
/** Button repeat start delay configuration in milliseconds */
ButtonRepeatStart: number;
/** Display animation style setting */
Animation: string;
/** Maximum brightness level (0-100) */
MaxBrightness: number;
/** Array of user IDs allowed to control this device */
AllowedUsers: string[];
/** Current button state indicator */
ButtonState: string;
/** Home identifier that this device belongs to */
Home: string;
/** Button sensitivity threshold configuration */
ButtonThreshold: number;
/** Data format version used by the device */
Format: string;
/** Time zone setting for the device */
TimeZone: string;
/** Unix timestamp of when device was last paired */
LastPaired: number;
/** Minimum temperature setpoint allowed */
MinSetpoint: number;
/** Current operating mode of the device */
Mode: ModeObj;
/** User ID of the device owner */
Owner: string;
/** Maximum temperature setpoint allowed */
MaxSetpoint: number;
/** Unique device identifier */
Id: string;
/** Optional zone assignment for the device */
Zone?: string;
/** Optional measured voltage reading from the device */
MeasuredVoltage?: number;
/** Optional duty cycle optimization setting */
DutyCycleOpt?: number;
/** Optional eco mode configuration */
ecoMode?: number;
/** Optional flag indicating if device has thermostatic control */
IsThermostatic?: boolean;
/** Optional flag indicating if device requires setup */
SetupRequired?: boolean;
/** Optional brand information for AC devices */
Brand?: BrandInfo;
/** Optional supported capabilities for AC devices */
SupportedCaps?: SupportedCaps;
/** Optional device code number */
CodeNum?: number;
}
/**
* Collection of devices indexed by their unique identifiers.
*
* Maps device ID strings to their corresponding device configuration objects, providing a lookup table for all devices
* associated with a user account.
*/
export interface DevicesObj {
/** Device objects indexed by their unique device ID strings */
[deviceId: string]: DeviceBase;
}
/**
* Top-level interface for the devices REST API response.
*
* Contains the complete collection of devices associated with a user account, typically returned from API endpoints
* that fetch device information.
*/
export interface Devices {
/** Collection of all devices indexed by their unique identifiers */
DevicesObj: DevicesObj;
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"esModuleInterop": true,
"moduleResolution": "node",
"skipLibCheck": true,
"allowJs": false,
"strict": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["dist", "node_modules"]
}

37
tsup.config.cjs Normal file
View File

@@ -0,0 +1,37 @@
import { defineConfig } from 'tsup';
const banner = `/*
mysa-js-sdk
Copyright (C) 2025 Pascal Bourque
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
`;
export default defineConfig({
entry: ['src/index.ts'],
platform: 'node',
format: ['cjs', 'esm'],
clean: true,
dts: true,
sourcemap: true,
banner: {
js: banner
}
});

5
typedoc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"entryPoints": ["./src", "./src/lib/**/*.ts", "./src/types/**/*.ts"],
"plugin": ["typedoc-material-theme"],
"themeColor": "#6b4f8d"
}