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:
18
.editorconfig
Normal file
18
.editorconfig
Normal 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
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
docs
|
||||||
|
vitest-ctrf
|
||||||
|
.env
|
||||||
|
session.json
|
||||||
177
CONTRIBUTING.md
Normal file
177
CONTRIBUTING.md
Normal 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
21
LICENSE.txt
Normal 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
225
README.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Mysa Smart Thermostat JavaScript SDK
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/mysa-js-sdk)
|
||||||
|
[](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql)
|
||||||
|
[](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
23
eslint.config.js
Normal 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
96
example/main.ts
Normal 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
13032
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
78
package.json
Normal file
78
package.json
Normal 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
11
prettier.config.cjs
Normal 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
38
release.config.mjs
Normal 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
34
src/api/Errors.ts
Normal 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
24
src/api/Logger.ts
Normal 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
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/api/MysaApiClientEventTypes.ts
Normal file
62
src/api/MysaApiClientEventTypes.ts
Normal 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];
|
||||||
|
};
|
||||||
18
src/api/MysaApiClientOptions.ts
Normal file
18
src/api/MysaApiClientOptions.ts
Normal 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;
|
||||||
|
}
|
||||||
7
src/api/MysaDeviceMode.ts
Normal file
7
src/api/MysaDeviceMode.ts
Normal 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
16
src/api/MysaSession.ts
Normal 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;
|
||||||
|
}
|
||||||
15
src/api/events/SetPointChange.ts
Normal file
15
src/api/events/SetPointChange.ts
Normal 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;
|
||||||
|
}
|
||||||
17
src/api/events/StateChange.ts
Normal file
17
src/api/events/StateChange.ts
Normal 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
20
src/api/events/Status.ts
Normal 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
3
src/api/events/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './SetPointChange';
|
||||||
|
export * from './StateChange';
|
||||||
|
export * from './Status';
|
||||||
8
src/api/index.ts
Normal file
8
src/api/index.ts
Normal 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
1
src/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './api';
|
||||||
106
src/lib/EventEmitter.ts
Normal file
106
src/lib/EventEmitter.ts
Normal 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
39
src/lib/PayloadParser.ts
Normal 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);
|
||||||
|
}
|
||||||
29
src/lib/PayloadTypeGuards.ts
Normal file
29
src/lib/PayloadTypeGuards.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/types/mqtt/InPayload.ts
Normal file
10
src/types/mqtt/InPayload.ts
Normal 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;
|
||||||
28
src/types/mqtt/MsgBasePayload.ts
Normal file
28
src/types/mqtt/MsgBasePayload.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/types/mqtt/MsgInPayload.ts
Normal file
9
src/types/mqtt/MsgInPayload.ts
Normal 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;
|
||||||
10
src/types/mqtt/MsgOutPayload.ts
Normal file
10
src/types/mqtt/MsgOutPayload.ts
Normal 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;
|
||||||
27
src/types/mqtt/MsgTypeBasePayload.ts
Normal file
27
src/types/mqtt/MsgTypeBasePayload.ts
Normal 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;
|
||||||
|
}
|
||||||
10
src/types/mqtt/MsgTypeInPayload.ts
Normal file
10
src/types/mqtt/MsgTypeInPayload.ts
Normal 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;
|
||||||
13
src/types/mqtt/MsgTypeOutPayload.ts
Normal file
13
src/types/mqtt/MsgTypeOutPayload.ts
Normal 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;
|
||||||
10
src/types/mqtt/OutPayload.ts
Normal file
10
src/types/mqtt/OutPayload.ts
Normal 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;
|
||||||
49
src/types/mqtt/in/ChangeDeviceState.ts
Normal file
49
src/types/mqtt/in/ChangeDeviceState.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/types/mqtt/in/CheckDeviceSettings.ts
Normal file
13
src/types/mqtt/in/CheckDeviceSettings.ts
Normal 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;
|
||||||
|
}
|
||||||
24
src/types/mqtt/in/InMessageType.ts
Normal file
24
src/types/mqtt/in/InMessageType.ts
Normal 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
|
||||||
|
}
|
||||||
13
src/types/mqtt/in/StartPublishingDeviceStatus.ts
Normal file
13
src/types/mqtt/in/StartPublishingDeviceStatus.ts
Normal 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;
|
||||||
|
}
|
||||||
15
src/types/mqtt/out/DeviceLog.ts
Normal file
15
src/types/mqtt/out/DeviceLog.ts
Normal 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;
|
||||||
|
}
|
||||||
11
src/types/mqtt/out/DevicePostBoot.ts
Normal file
11
src/types/mqtt/out/DevicePostBoot.ts
Normal 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> {}
|
||||||
17
src/types/mqtt/out/DeviceSetpointChange.ts
Normal file
17
src/types/mqtt/out/DeviceSetpointChange.ts
Normal 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;
|
||||||
|
}
|
||||||
40
src/types/mqtt/out/DeviceStateChange.ts
Normal file
40
src/types/mqtt/out/DeviceStateChange.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/types/mqtt/out/DeviceV1Status.ts
Normal file
26
src/types/mqtt/out/DeviceV1Status.ts
Normal 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;
|
||||||
|
}
|
||||||
30
src/types/mqtt/out/DeviceV2Status.ts
Normal file
30
src/types/mqtt/out/DeviceV2Status.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
33
src/types/mqtt/out/OutMessageType.ts
Normal file
33
src/types/mqtt/out/OutMessageType.ts
Normal 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
150
src/types/rest/Devices.ts
Normal 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
15
tsconfig.json
Normal 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
37
tsup.config.cjs
Normal 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
5
typedoc.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"entryPoints": ["./src", "./src/lib/**/*.ts", "./src/types/**/*.ts"],
|
||||||
|
"plugin": ["typedoc-material-theme"],
|
||||||
|
"themeColor": "#6b4f8d"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user