54 Commits

Author SHA1 Message Date
Pascal Bourque
d813c4f9a9 fix: Race condition when initializing the MqttClientConnection (#144)
Fixes https://github.com/bourquep/mysa2mqtt/issues/41
2025-10-05 14:53:17 -04:00
Pascal Bourque
598edf50d9 fix(example): Provide error object as first arg to rootLogger.error() (#143) 2025-10-05 11:04:22 -04:00
Pascal Bourque
ad34fe7486 style: Fixed lint errors introduced by all-contributors bot (#142) 2025-10-05 10:54:48 -04:00
allcontributors[bot]
daed17753e docs: add jagmandan as a contributor for code (#140)
Adds @jagmandan as a contributor for code.

This was requested by bourquep [in this
comment](https://github.com/bourquep/mysa-js-sdk/pull/139#issuecomment-3369095675)

[skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-10-05 10:45:23 -04:00
jagmandan
b845fe5a82 fix: Unable to control BB-V2-0 thermostats (#139)
This change was identified while investigating why my BB-V2-0 Mysa would
not respond to device state commands from Home Assistant (using
mysq2mqtt). The id type was previously in unix time (seconds), but
should be in milliseconds per the reference here:
https://github.com/dlenski/mysotherm/blob/main/mysa_messages.md Making
this update corrected the behavior and commands now work successfully.
2025-10-05 10:44:34 -04:00
Pascal Bourque
bb876ef60d ci: Add CodeQL analysis workflow configuration (#141) 2025-10-05 10:41:33 -04:00
dependabot[bot]
2aa7bd1679 chore(deps): Bump @aws-sdk/client-iot from 3.835.0 to 3.901.0 (#136) 2025-10-05 14:20:52 +00:00
dependabot[bot]
df16d2553b chore(deps-dev): Bump conventional-changelog-conventionalcommits from 9.0.0 to 9.1.0 (#98) 2025-10-05 14:18:26 +00:00
dependabot[bot]
55ec9a8fe9 chore(deps): Bump axios from 1.9.0 to 1.12.1 in the npm_and_yarn group across 1 directory (#114) 2025-10-05 14:18:11 +00:00
dependabot[bot]
77e972bde2 chore(deps-dev): Bump typedoc from 0.28.11 to 0.28.13 (#115) 2025-10-05 14:17:59 +00:00
dependabot[bot]
ed8a83f89b chore(deps): Bump @aws-sdk/credential-providers from 3.876.0 to 3.901.0 (#137) 2025-10-05 14:17:33 +00:00
dependabot[bot]
aa6ed44a19 chore(deps-dev): Bump pino from 9.7.0 to 9.13.0 (#138) 2025-10-05 14:17:13 +00:00
Pascal Bourque
98003665b8 fix: Build error after TypeScript update (#95) 2025-08-29 08:14:35 -04:00
dependabot[bot]
7afec1a7a9 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 10 updates (#78) 2025-08-29 12:02:56 +00:00
dependabot[bot]
e6631b0fd8 chore(deps): Bump form-data from 4.0.2 to 4.0.4 in the npm_and_yarn group (#69) 2025-08-29 11:56:32 +00:00
dependabot[bot]
efaf3310d2 chore(deps-dev): Bump dotenv from 16.5.0 to 17.2.1 (#71) 2025-08-29 11:56:16 +00:00
dependabot[bot]
a62b538c42 chore(deps-dev): Bump typedoc from 0.28.5 to 0.28.11 (#91) 2025-08-29 11:55:28 +00:00
dependabot[bot]
2023e8b321 chore(deps-dev): Bump @eslint/js from 9.29.0 to 9.34.0 (#92) 2025-08-29 11:55:17 +00:00
dependabot[bot]
808e8f1037 chore(deps-dev): Bump typescript-eslint from 8.35.0 to 8.41.0 (#93) 2025-08-29 11:55:02 +00:00
dependabot[bot]
f201c7944a chore(deps): Bump @aws-sdk/credential-providers from 3.835.0 to 3.876.0 (#94) 2025-08-29 11:54:39 +00:00
dependabot[bot]
f6c6127dab chore(deps): Bump @aws-sdk/credential-providers from 3.830.0 to 3.835.0 (#42) 2025-06-25 14:44:42 +00:00
dependabot[bot]
73cec9a90e chore(deps): Bump aws-iot-device-sdk-v2 from 1.21.5 to 1.22.0 (#40) 2025-06-25 14:41:32 +00:00
dependabot[bot]
39fc9048df chore(deps-dev): Bump typescript-eslint from 8.34.0 to 8.35.0 (#43) 2025-06-25 14:38:35 +00:00
dependabot[bot]
45d69453df chore(deps): Bump @aws-sdk/client-iot from 3.830.0 to 3.835.0 (#44) 2025-06-25 14:38:21 +00:00
Pascal Bourque
6dc6da2dde chore(deps): Updated brace-expansion to fix CVE-2025-5889 (#37) 2025-06-21 10:58:17 -04:00
Pascal Bourque
0cf7a1756c feat(example): Ability to output raw data from the thermostats (#36) 2025-06-21 10:33:08 -04:00
dependabot[bot]
51b8f64dab chore(deps): Bump @aws-sdk/client-iot from 3.826.0 to 3.830.0 (#33) 2025-06-21 13:36:28 +00:00
dependabot[bot]
14ee1d30eb chore(deps-dev): Bump tsx from 4.19.4 to 4.20.3 (#30) 2025-06-21 13:35:23 +00:00
dependabot[bot]
4680ca2f85 chore(deps-dev): Bump @eslint/js from 9.28.0 to 9.29.0 (#32) 2025-06-21 13:35:06 +00:00
dependabot[bot]
3f020d5dc3 chore(deps): Bump @aws-sdk/credential-providers from 3.826.0 to 3.830.0 (#34) 2025-06-21 13:33:25 +00:00
dependabot[bot]
e93741525c chore(deps-dev): Bump the dev-dependencies group across 1 directory with 4 updates (#35) 2025-06-21 13:32:47 +00:00
dependabot[bot]
9b945869aa chore(deps-dev): Bump @types/node from 22.15.30 to 24.0.0 in the dev-dependencies group (#22) 2025-06-10 11:41:22 +00:00
dependabot[bot]
becafbfacc chore(deps-dev): Bump typescript-eslint from 8.33.1 to 8.34.0 (#23) 2025-06-10 11:41:11 +00:00
dependabot[bot]
c29e750f9c chore(deps): Bump @aws-sdk/client-iot from 3.825.0 to 3.826.0 (#20) 2025-06-09 12:34:40 +00:00
dependabot[bot]
e3c93453ed chore(deps): Bump @aws-sdk/credential-providers from 3.825.0 to 3.826.0 (#21) 2025-06-09 12:31:41 +00:00
Pascal Bourque
17f277e844 docs: Added missing TSDoc comments (#19) 2025-06-07 10:08:05 -04:00
Pascal Bourque
6a88e52702 feat: Added getDeviceSerialNumber() API (#18) 2025-06-07 09:57:28 -04:00
dependabot[bot]
d6971453d2 chore(deps-dev): Bump @types/node from 22.15.29 to 22.15.30 in the dev-dependencies group (#16) 2025-06-06 12:21:38 +00:00
dependabot[bot]
8769928622 chore(deps): Bump @aws-sdk/credential-providers from 3.823.0 to 3.825.0 (#17) 2025-06-06 12:21:26 +00:00
Pascal Bourque
131f8677d9 fix: DayJS 'duration' import breaks ESM consumers (#15) 2025-06-05 16:32:14 -04:00
dependabot[bot]
ba5d29379a chore(deps): Bump aws-iot-device-sdk-v2 from 1.21.4 to 1.21.5 (#11) 2025-06-05 12:15:07 +00:00
dependabot[bot]
0dfb486ea1 chore(deps-dev): Bump typescript-eslint from 8.33.0 to 8.33.1 (#12) 2025-06-05 12:12:11 +00:00
dependabot[bot]
2c483a835d chore(deps): Bump @aws-sdk/credential-providers from 3.821.0 to 3.823.0 (#13) 2025-06-05 12:11:58 +00:00
Pascal Bourque
7f89e9867a fix(logging): Reduced the log level of session-related logs (#14) 2025-06-05 08:11:37 -04:00
Pascal Bourque
6b4e41828a feat: Added getDeviceStates API 2025-06-02 07:25:04 -04:00
dependabot[bot]
be940daf91 chore(deps-dev): Bump the dev-dependencies group with 3 updates (#8) 2025-06-02 11:09:46 +00:00
dependabot[bot]
847b9abf5e chore(deps): Bump @aws-sdk/credential-providers from 3.817.0 to 3.821.0 (#7) 2025-06-02 11:06:16 +00:00
dependabot[bot]
17d7e9b7b0 chore(deps-dev): Bump @eslint/js from 9.27.0 to 9.28.0 (#9) 2025-06-02 11:05:16 +00:00
Pascal Bourque
9df5030228 feat: Added getDeviceFirmwares API (#10) 2025-06-02 07:04:45 -04:00
dependabot[bot]
5ae54dd05d chore(deps-dev): Bump typedoc from 0.28.4 to 0.28.5 (#4) 2025-05-31 13:54:45 +00:00
dependabot[bot]
4039f5f165 chore(deps-dev): Bump @types/node from 22.15.21 to 22.15.23 in the dev-dependencies group (#5) 2025-05-31 13:52:37 +00:00
dependabot[bot]
45ae56f0f9 chore(deps-dev): Bump typescript-eslint from 8.32.1 to 8.33.0 (#6) 2025-05-31 13:52:20 +00:00
Pascal Bourque
aaac9f6d73 fix: Export all types modules (#3) 2025-05-25 17:59:25 -04:00
Pascal Bourque
43ac8334f0 ci: Configured Dependabot to use chore commit prefix 2025-05-25 11:29:56 -04:00
17 changed files with 1726 additions and 1131 deletions

22
.all-contributorsrc Normal file
View File

@@ -0,0 +1,22 @@
{
"files": ["README.md"],
"imageSize": 100,
"commit": false,
"commitType": "docs",
"commitConvention": "angular",
"contributors": [
{
"login": "jagmandan",
"name": "jagmandan",
"avatar_url": "https://avatars.githubusercontent.com/u/227265405?v=4",
"profile": "https://github.com/jagmandan",
"contributions": ["code"]
}
],
"contributorsPerLine": 7,
"skipCi": true,
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "mysa-js-sdk",
"projectOwner": "bourquep"
}

View File

@@ -11,6 +11,9 @@ updates:
interval: 'daily' interval: 'daily'
labels: labels:
- 'dependencies' - 'dependencies'
commit-message:
prefix: 'chore'
include: 'scope'
groups: groups:
dev-dependencies: dev-dependencies:
patterns: patterns:

100
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL Advanced'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '44 5 * * 5'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: '/language:${{matrix.language}}'

View File

@@ -1,5 +1,11 @@
# Mysa Smart Thermostat JavaScript SDK # Mysa Smart Thermostat JavaScript SDK
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![NPM Version](https://img.shields.io/npm/v/mysa-js-sdk)](https://www.npmjs.com/package/mysa-js-sdk) [![NPM Version](https://img.shields.io/npm/v/mysa-js-sdk)](https://www.npmjs.com/package/mysa-js-sdk)
[![CodeQL](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql) [![CodeQL](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/github-code-scanning/codeql)
[![CI: lint, build and release](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml) [![CI: lint, build and release](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/bourquep/mysa-js-sdk/actions/workflows/ci.yml)
@@ -51,6 +57,12 @@ Then, run the example:
npm run example npm run example
``` ```
If you prefer to see the raw data published by your Mysa smart thermostats, run:
```bash
npm run example:raw
```
## Using ## Using
The Mysa SDK provides a simple interface to interact with Mysa smart thermostats. The Mysa SDK provides a simple interface to interact with Mysa smart thermostats.
@@ -223,3 +235,26 @@ For general questions and discussions, join our [Discussion Forum](https://githu
This library would not be possible without the amazing work by [@dlenski](https://github.com/dlenski) in his 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 [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. protocol which is being used by this library.
## Contributors ✨
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/jagmandan"><img src="https://avatars.githubusercontent.com/u/227265405?v=4?s=100" width="100px;" alt="jagmandan"/><br /><sub><b>jagmandan</b></sub></a><br /><a href="https://github.com/bourquep/mysa-js-sdk/commits?author=jagmandan" title="Code">💻</a></td>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification.
Contributions of any kind welcome!

View File

@@ -1,5 +1,4 @@
import { MysaApiClient } from '@/api/MysaApiClient'; import { MysaApiClient, MysaSession } from '@/api';
import { MysaSession } from '@/api/MysaSession';
import 'dotenv/config'; import 'dotenv/config';
import { readFile, rm, writeFile } from 'fs/promises'; import { readFile, rm, writeFile } from 'fs/promises';
import { pino } from 'pino'; import { pino } from 'pino';
@@ -58,39 +57,52 @@ async function main() {
const devices = await client.getDevices(); const devices = await client.getDevices();
client.emitter.on('statusChanged', (status) => { if (process.env.MYSA_OUTPUT_RAW_DATA === 'true') {
try { client.emitter.on('rawRealtimeMessageReceived', (data) => {
const device = devices.DevicesObj[status.deviceId]; rootLogger.info(data, 'Raw message received');
const watts = status.current !== undefined ? status.current * device.Voltage : undefined; });
rootLogger.info( } else {
`'${device.Name}' status changed: ${status.temperature}°C, ${status.humidity}%, ${watts ?? 'na'}W` client.emitter.on('statusChanged', (status) => {
); try {
} catch (error) { const device = devices.DevicesObj[status.deviceId];
rootLogger.error(`Error processing status update for device '${status.deviceId}':`, error); 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, `Error processing status update for device '${status.deviceId}'`);
}
});
client.emitter.on('setPointChanged', (change) => { client.emitter.on('setPointChanged', (change) => {
try { try {
const device = devices.DevicesObj[change.deviceId]; const device = devices.DevicesObj[change.deviceId];
rootLogger.info(`'${device.Name}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`); rootLogger.info(`'${device.Name}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`);
} catch (error) { } catch (error) {
rootLogger.error(`Error processing setpoint update for device '${change.deviceId}':`, error); rootLogger.error(error, `Error processing setpoint update for device '${change.deviceId}'`);
} }
}); });
client.emitter.on('stateChanged', (change) => { client.emitter.on('stateChanged', (change) => {
try { try {
const device = devices.DevicesObj[change.deviceId]; const device = devices.DevicesObj[change.deviceId];
rootLogger.info(change, `'${device.Name}' state changed.`); rootLogger.info(change, `'${device.Name}' state changed.`);
} catch (error) { } catch (error) {
rootLogger.error(`Error processing setpoint update for device '${change.deviceId}':`, error); rootLogger.error(error, `Error processing state update for device '${change.deviceId}'`);
} }
}); });
for (const device of Object.entries(devices.DevicesObj)) {
await client.startRealtimeUpdates(device[0]);
} }
await Promise.all(
Object.entries(devices.DevicesObj).map(async ([deviceId, device]) => {
const serial = await client.getDeviceSerialNumber(deviceId);
rootLogger.info(`Serial number for device '${deviceId}' (${device.Name}): ${serial}`);
await client.startRealtimeUpdates(deviceId);
})
);
} }
main().catch(rootLogger.error); main().catch((error) => {
rootLogger.error(error, 'Error in main');
});

2187
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,39 +40,44 @@
"browser": false, "browser": false,
"scripts": { "scripts": {
"example": "tsx --watch ./example/main.ts", "example": "tsx --watch ./example/main.ts",
"example:raw": "MYSA_OUTPUT_RAW_DATA=true tsx --watch ./example/main.ts",
"lint": "eslint --max-warnings 0 src/**/*.ts", "lint": "eslint --max-warnings 0 src/**/*.ts",
"style-lint": "prettier -c .", "style-lint": "prettier -c .",
"build": "tsup", "build": "tsup",
"build:docs": "typedoc" "build:docs": "typedoc"
}, },
"overrides": {
"brace-expansion": "^2.0.2"
},
"dependencies": { "dependencies": {
"@aws-sdk/credential-providers": "3.817.0", "@aws-sdk/client-iot": "3.901.0",
"@aws-sdk/credential-providers": "3.901.0",
"amazon-cognito-identity-js": "6.3.15", "amazon-cognito-identity-js": "6.3.15",
"aws-iot-device-sdk-v2": "1.21.4", "aws-iot-device-sdk-v2": "1.22.0",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"lodash": "4.17.21" "lodash": "4.17.21"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.27.0", "@eslint/js": "9.34.0",
"@semantic-release/npm": "12.0.1", "@semantic-release/npm": "12.0.2",
"@types/lodash": "4.17.17", "@types/lodash": "4.17.20",
"@types/node": "22.15.21", "@types/node": "24.3.0",
"conventional-changelog-conventionalcommits": "9.0.0", "conventional-changelog-conventionalcommits": "9.1.0",
"dotenv": "16.5.0", "dotenv": "17.2.1",
"eslint": "9.27.0", "eslint": "9.34.0",
"eslint-plugin-jsdoc": "50.6.17", "eslint-plugin-jsdoc": "54.1.1",
"eslint-plugin-tsdoc": "0.4.0", "eslint-plugin-tsdoc": "0.4.0",
"pino": "9.7.0", "pino": "9.13.0",
"pino-pretty": "13.0.0", "pino-pretty": "13.0.0",
"prettier": "3.5.3", "prettier": "3.6.2",
"prettier-plugin-jsdoc": "1.3.2", "prettier-plugin-jsdoc": "1.3.3",
"prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-organize-imports": "4.2.0",
"semantic-release": "24.2.5", "semantic-release": "24.2.7",
"tsup": "8.5.0", "tsup": "8.5.0",
"tsx": "4.19.4", "tsx": "4.20.3",
"typedoc": "0.28.4", "typedoc": "0.28.13",
"typedoc-material-theme": "1.4.0", "typedoc-material-theme": "1.4.0",
"typescript": "5.8.3", "typescript": "5.9.2",
"typescript-eslint": "8.32.1" "typescript-eslint": "8.41.0"
} }
} }

View File

@@ -6,7 +6,8 @@ import { ChangeDeviceState } from '@/types/mqtt/in/ChangeDeviceState';
import { InMessageType } from '@/types/mqtt/in/InMessageType'; import { InMessageType } from '@/types/mqtt/in/InMessageType';
import { StartPublishingDeviceStatus } from '@/types/mqtt/in/StartPublishingDeviceStatus'; import { StartPublishingDeviceStatus } from '@/types/mqtt/in/StartPublishingDeviceStatus';
import { OutMessageType } from '@/types/mqtt/out/OutMessageType'; import { OutMessageType } from '@/types/mqtt/out/OutMessageType';
import { Devices } from '@/types/rest/Devices'; import { Devices, DeviceStates, Firmwares } from '@/types/rest';
import { DescribeThingCommand, IoTClient } from '@aws-sdk/client-iot';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers'; import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
import { import {
AuthenticationDetails, AuthenticationDetails,
@@ -19,7 +20,7 @@ import {
} from 'amazon-cognito-identity-js'; } from 'amazon-cognito-identity-js';
import { iot, mqtt } from 'aws-iot-device-sdk-v2'; import { iot, mqtt } from 'aws-iot-device-sdk-v2';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration.js';
import { MysaApiError, UnauthenticatedError } from './Errors'; import { MysaApiError, UnauthenticatedError } from './Errors';
import { Logger, VoidLogger } from './Logger'; import { Logger, VoidLogger } from './Logger';
import { MysaApiClientEventTypes } from './MysaApiClientEventTypes'; import { MysaApiClientEventTypes } from './MysaApiClientEventTypes';
@@ -74,8 +75,8 @@ export class MysaApiClient {
/** The fetcher function used by the client. */ /** The fetcher function used by the client. */
private _fetcher: typeof fetch; private _fetcher: typeof fetch;
/** The MQTT connection used for real-time updates. */ /** A promise that resolves to the MQTT connection used for real-time updates. */
private _mqttConnection?: mqtt.MqttClientConnection; private _mqttConnectionPromise?: Promise<mqtt.MqttClientConnection>;
/** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */ /** The device IDs that are currently being updated in real-time, mapped to their respective timeouts. */
private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map(); private _realtimeDeviceIds: Map<string, NodeJS.Timeout> = new Map();
@@ -143,8 +144,23 @@ export class MysaApiClient {
/** /**
* Logs in the user with the given email address and password. * Logs in the user with the given email address and password.
* *
* This method authenticates the user with Mysa's Cognito user pool and establishes a session that can be used for
* subsequent API calls. Upon successful login, a 'sessionChanged' event is emitted.
*
* @example
*
* ```typescript
* try {
* await client.login('user@example.com', 'password123');
* console.log('Login successful!');
* } catch (error) {
* console.error('Login failed:', error.message);
* }
* ```
*
* @param emailAddress - The email address of the user. * @param emailAddress - The email address of the user.
* @param password - The password of the user. * @param password - The password of the user.
* @throws {@link Error} When authentication fails due to invalid credentials or network issues.
*/ */
async login(emailAddress: string, password: string): Promise<void> { async login(emailAddress: string, password: string): Promise<void> {
this._cognitoUser = undefined; this._cognitoUser = undefined;
@@ -175,7 +191,21 @@ export class MysaApiClient {
/** /**
* Retrieves the list of devices associated with the user. * Retrieves the list of devices associated with the user.
* *
* This method fetches all Mysa devices linked to the authenticated user's account, including device information such
* as models, locations, and configuration details.
*
* @example
*
* ```typescript
* const devices = await client.getDevices();
* for (const [deviceId, device] of Object.entries(devices.DevicesObj)) {
* console.log(`Device: ${device.DisplayName} (${device.Model})`);
* }
* ```
*
* @returns A promise that resolves to the list of devices. * @returns A promise that resolves to the list of devices.
* @throws {@link MysaApiError} When the API request fails.
* @throws {@link UnauthenticatedError} When the user is not authenticated.
*/ */
async getDevices(): Promise<Devices> { async getDevices(): Promise<Devices> {
this._logger.debug(`Fetching devices...`); this._logger.debug(`Fetching devices...`);
@@ -184,7 +214,7 @@ export class MysaApiClient {
const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, { const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
headers: { headers: {
Authorization: `${session.getAccessToken().getJwtToken()}` Authorization: `${session.getIdToken().getJwtToken()}`
} }
}); });
@@ -195,6 +225,138 @@ export class MysaApiClient {
return response.json(); return response.json();
} }
/**
* Retrieves the serial number for a specific device.
*
* This method uses AWS IoT's DescribeThing API to fetch the serial number attribute for the specified device. This
* requires additional AWS IoT permissions and may not be available for all devices.
*
* @example
*
* ```typescript
* const serialNumber = await client.getDeviceSerialNumber('device123');
* if (serialNumber) {
* console.log(`Device serial: ${serialNumber}`);
* } else {
* console.log('Serial number not available');
* }
* ```
*
* @param deviceId - The ID of the device to get the serial number for.
* @returns A promise that resolves to the serial number, or undefined if not found.
* @throws {@link UnauthenticatedError} When the user is not authenticated.
*/
async getDeviceSerialNumber(deviceId: string): Promise<string | undefined> {
this._logger.debug(`Fetching serial number for device ${deviceId}...`);
const session = await this.getFreshSession();
// Get AWS credentials for IoT client
const credentialsProvider = fromCognitoIdentityPool({
clientConfig: {
region: AwsRegion
},
identityPoolId: CognitoIdentityPoolId,
logins: {
[CognitoLoginKey]: session.getIdToken().getJwtToken()
}
});
const credentials = await credentialsProvider();
const iotClient = new IoTClient({
region: AwsRegion,
credentials: {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey,
sessionToken: credentials.sessionToken
}
});
try {
const command = new DescribeThingCommand({ thingName: deviceId });
const response = await iotClient.send(command);
return response.attributes?.['Serial'];
} catch (error) {
this._logger.warn(`Could not get serial number for device ${deviceId}:`, error);
return undefined;
}
}
/**
* Retrieves firmware information for all devices.
*
* @returns A promise that resolves to the firmware information for all devices.
* @throws {@link MysaApiError} When the API request fails.
* @throws {@link UnauthenticatedError} When the user is not authenticated.
*/
async getDeviceFirmwares(): Promise<Firmwares> {
this._logger.debug(`Fetching device firmwares...`);
const session = await this.getFreshSession();
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, {
headers: {
Authorization: `${session.getIdToken().getJwtToken()}`
}
});
if (!response.ok) {
throw new MysaApiError(response);
}
return response.json();
}
/**
* Retrieves the current state information for all devices.
*
* @returns A promise that resolves to the current state of all devices.
* @throws {@link MysaApiError} When the API request fails.
* @throws {@link UnauthenticatedError} When the user is not authenticated.
*/
async getDeviceStates(): Promise<DeviceStates> {
this._logger.debug(`Fetching device states...`);
const session = await this.getFreshSession();
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, {
headers: {
Authorization: `${session.getIdToken().getJwtToken()}`
}
});
if (!response.ok) {
throw new MysaApiError(response);
}
return response.json();
}
/**
* Sets the state of a specific device by sending commands via MQTT.
*
* This method allows you to change the temperature set point and/or operating mode of a Mysa device. The command is
* sent through the MQTT connection for real-time device control.
*
* @example
*
* ```typescript
* // Set temperature to 22°C
* await client.setDeviceState('device123', 22);
*
* // Turn device off
* await client.setDeviceState('device123', undefined, 'off');
*
* // Set temperature and mode
* await client.setDeviceState('device123', 20, 'heat');
* ```
*
* @param deviceId - The ID of the device to control.
* @param setPoint - The target temperature set point (optional).
* @param mode - The operating mode to set ('off', 'heat', or undefined to leave unchanged).
* @throws {@link UnauthenticatedError} When the user is not authenticated.
* @throws {@link Error} When MQTT connection or command sending fails.
*/
async setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode) { async setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode) {
this._logger.debug(`Setting device state for '${deviceId}'`); this._logger.debug(`Setting device state for '${deviceId}'`);
@@ -212,7 +374,7 @@ export class MysaApiClient {
this._logger.debug(`Sending request to set device state for '${deviceId}'...`); this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
const payload = serializeMqttPayload<ChangeDeviceState>({ const payload = serializeMqttPayload<ChangeDeviceState>({
msg: InMessageType.CHANGE_DEVICE_STATE, msg: InMessageType.CHANGE_DEVICE_STATE,
id: now.unix(), id: now.valueOf(),
time: now.unix(), time: now.unix(),
ver: '1.0', ver: '1.0',
src: { src: {
@@ -249,13 +411,29 @@ export class MysaApiClient {
/** /**
* Starts receiving real-time updates for the specified device. * Starts receiving real-time updates for the specified device.
* *
* This method establishes an MQTT subscription to receive live status updates from the device, including temperature,
* humidity, set point changes, and other state information. The client will automatically send keep-alive messages to
* maintain the connection.
*
* @example
*
* ```typescript
* // Start receiving updates and listen for events
* await client.startRealtimeUpdates('device123');
*
* client.emitter.on('statusChanged', (status) => {
* console.log(`Temperature: ${status.temperature}°C`);
* });
* ```
*
* @param deviceId - The ID of the device to start receiving updates for. * @param deviceId - The ID of the device to start receiving updates for.
* @throws {@link Error} When MQTT connection or subscription fails.
*/ */
async startRealtimeUpdates(deviceId: string) { async startRealtimeUpdates(deviceId: string) {
this._logger.info(`Starting realtime updates for device '${deviceId}'`); this._logger.info(`Starting real-time updates for device '${deviceId}'`);
if (this._realtimeDeviceIds.has(deviceId)) { if (this._realtimeDeviceIds.has(deviceId)) {
this._logger.debug(`Realtime updates for device '${deviceId}' already started`); this._logger.debug(`Real-time updates for device '${deviceId}' already started`);
return; return;
} }
@@ -293,9 +471,15 @@ export class MysaApiClient {
/** /**
* Stops receiving real-time updates for the specified device. * Stops receiving real-time updates for the specified device.
* *
* This method unsubscribes from the MQTT topic for the specified device and clears any associated timers to stop the
* keep-alive messages.
*
* @param deviceId - The ID of the device to stop receiving real-time updates for. * @param deviceId - The ID of the device to stop receiving real-time updates for.
* @throws {@link Error} When MQTT unsubscription fails.
*/ */
async stopRealtimeUpdates(deviceId: string) { async stopRealtimeUpdates(deviceId: string) {
this._logger.info(`Stopping real-time updates for device '${deviceId}'`);
const timer = this._realtimeDeviceIds.get(deviceId); const timer = this._realtimeDeviceIds.get(deviceId);
if (!timer) { if (!timer) {
this._logger.warn(`No real-time updates are running for device '${deviceId}'`); this._logger.warn(`No real-time updates are running for device '${deviceId}'`);
@@ -308,11 +492,19 @@ export class MysaApiClient {
this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`); this._logger.debug(`Unsubscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`); await mqttConnection.unsubscribe(`/v1/dev/${deviceId}/out`);
this._logger.debug(`Stopping real-time updates for device '${deviceId}'...`);
clearInterval(timer); clearInterval(timer);
this._realtimeDeviceIds.delete(deviceId); this._realtimeDeviceIds.delete(deviceId);
} }
/**
* Ensures a valid, non-expired session is available.
*
* This method checks if the current session is valid and not expired. If the session is expired, it automatically
* refreshes it using the refresh token.
*
* @returns A promise that resolves to a valid CognitoUserSession.
* @throws {@link UnauthenticatedError} When no session exists or refresh fails.
*/
private async getFreshSession(): Promise<CognitoUserSession> { private async getFreshSession(): Promise<CognitoUserSession> {
if (!this._cognitoUser || !this._cognitoUserSession) { if (!this._cognitoUser || !this._cognitoUserSession) {
throw new UnauthenticatedError('An attempt was made to access a resource without a valid session.'); throw new UnauthenticatedError('An attempt was made to access a resource without a valid session.');
@@ -320,20 +512,20 @@ export class MysaApiClient {
if ( if (
this._cognitoUserSession.isValid() && this._cognitoUserSession.isValid() &&
dayjs.unix(this._cognitoUserSession.getAccessToken().getExpiration()).isAfter() dayjs.unix(this._cognitoUserSession.getIdToken().getExpiration()).isAfter()
) { ) {
this._logger.info('Session is valid, no need to refresh'); this._logger.debug('Session is valid, no need to refresh');
return Promise.resolve(this._cognitoUserSession); return Promise.resolve(this._cognitoUserSession);
} }
this._logger.info('Session is not valid or expired, refreshing...'); this._logger.debug('Session is not valid or expired, refreshing...');
return new Promise<CognitoUserSession>((resolve, reject) => { return new Promise<CognitoUserSession>((resolve, reject) => {
this._cognitoUser!.refreshSession(this._cognitoUserSession!.getRefreshToken(), (error, session) => { this._cognitoUser!.refreshSession(this._cognitoUserSession!.getRefreshToken(), (error, session) => {
if (error) { if (error) {
this._logger.error('Failed to refresh session:', error); this._logger.error('Failed to refresh session:', error);
reject(new UnauthenticatedError('Unable to refresh the authentication session.')); reject(new UnauthenticatedError('Unable to refresh the authentication session.'));
} else { } else {
this._logger.info('Session refreshed successfully'); this._logger.debug('Session refreshed successfully');
this._cognitoUserSession = session; this._cognitoUserSession = session;
this.emitter.emit('sessionChanged', this.session); this.emitter.emit('sessionChanged', this.session);
resolve(session); resolve(session);
@@ -342,11 +534,33 @@ export class MysaApiClient {
}); });
} }
private async getMqttConnection(): Promise<mqtt.MqttClientConnection> { /**
if (this._mqttConnection) { * Establishes and returns an MQTT connection for real-time communication.
return this._mqttConnection; *
* This method creates a new MQTT connection if one doesn't exist, using AWS IoT WebSocket connections with Cognito
* credentials. The connection is cached and reused for subsequent calls.
*
* @returns A promise that resolves to an active MQTT connection.
* @throws {@link Error} When connection establishment fails.
*/
private getMqttConnection(): Promise<mqtt.MqttClientConnection> {
if (!this._mqttConnectionPromise) {
this._mqttConnectionPromise = this.createMqttConnection().catch((err) => {
this._mqttConnectionPromise = undefined;
throw err;
});
} }
return this._mqttConnectionPromise;
}
/**
* Creates a new MQTT connection using AWS IoT WebSocket connections with Cognito credentials.
*
* @returns A promise that resolves to an active MQTT connection.
* @throws {@link Error} When connection establishment fails.
*/
private async createMqttConnection(): Promise<mqtt.MqttClientConnection> {
const session = await this.getFreshSession(); const session = await this.getFreshSession();
const credentialsProvider = fromCognitoIdentityPool({ const credentialsProvider = fromCognitoIdentityPool({
clientConfig: { clientConfig: {
@@ -371,18 +585,36 @@ export class MysaApiClient {
const config = builder.build(); const config = builder.build();
const client = new mqtt.MqttClient(); const client = new mqtt.MqttClient();
this._mqttConnection = client.new_connection(config); const connection = client.new_connection(config);
this._mqttConnection.on('closed', () => { connection.on('connect', () => this._logger.debug('MQTT connect'));
connection.on('connection_success', () => this._logger.debug('MQTT connection_success'));
connection.on('connection_failure', (e) => this._logger.error('MQTT connection_failure', e));
connection.on('interrupt', (e) => this._logger.warn('MQTT interrupt', e));
connection.on('resume', (returnCode, sessionPresent) =>
this._logger.info(`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent}`)
);
connection.on('error', (e) => this._logger.error('MQTT error', e));
connection.on('closed', () => {
this._logger.info('MQTT connection closed'); this._logger.info('MQTT connection closed');
this._mqttConnection = undefined; this._mqttConnectionPromise = undefined;
}); });
await this._mqttConnection.connect(); await connection.connect();
return this._mqttConnection; return connection;
} }
/**
* Processes incoming MQTT messages and emits appropriate events.
*
* This method parses MQTT payloads and converts them into typed events that can be listened to via the client's event
* emitter. It handles both v1 and v2 device message formats and emits events like 'statusChanged', 'setPointChanged',
* and 'stateChanged'.
*
* @param payload - The raw MQTT message payload to process.
*/
private processMqttMessage(payload: ArrayBuffer) { private processMqttMessage(payload: ArrayBuffer) {
try { try {
const parsedPayload = parseMqttPayload(payload); const parsedPayload = parseMqttPayload(payload);

View File

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

View File

@@ -32,7 +32,7 @@ export function parseMqttPayload(payload: ArrayBuffer): OutPayload {
* @param payload - The typed payload object to serialize * @param payload - The typed payload object to serialize
* @returns The serialized payload as ArrayBuffer ready for MQTT transmission * @returns The serialized payload as ArrayBuffer ready for MQTT transmission
*/ */
export function serializeMqttPayload<T extends InPayload>(payload: T): ArrayBuffer { export function serializeMqttPayload<T extends InPayload>(payload: T): Uint8Array<ArrayBuffer> {
const jsonString = JSON.stringify(payload); const jsonString = JSON.stringify(payload);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return encoder.encode(jsonString); return encoder.encode(jsonString);

2
src/types/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './mqtt';
export * from './rest';

View File

@@ -0,0 +1,4 @@
export * from './ChangeDeviceState';
export * from './CheckDeviceSettings';
export * from './InMessageType';
export * from './StartPublishingDeviceStatus';

10
src/types/mqtt/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export * from './in';
export * from './InPayload';
export * from './MsgBasePayload';
export * from './MsgInPayload';
export * from './MsgOutPayload';
export * from './MsgTypeBasePayload';
export * from './MsgTypeInPayload';
export * from './MsgTypeOutPayload';
export * from './out';
export * from './OutPayload';

View File

@@ -0,0 +1,7 @@
export * from './DeviceLog';
export * from './DevicePostBoot';
export * from './DeviceSetpointChange';
export * from './DeviceStateChange';
export * from './DeviceV1Status';
export * from './DeviceV2Status';
export * from './OutMessageType';

View File

@@ -0,0 +1,17 @@
/** Device firmware information */
export interface FirmwareDevice {
/** Device ID */
Device: string;
/** Device firmware version */
InstalledVersion: string;
}
/**
* Collection of firmware devices indexed by device ID
*
* Maps device ID strings to their corresponding firmware device objects, providing a lookup table for all devices
* associated with a user account.
*/
export interface Firmwares {
Firmware: Record<string, FirmwareDevice>;
}

69
src/types/rest/States.ts Normal file
View File

@@ -0,0 +1,69 @@
/** Represents a timestamped value with metadata */
export interface TimestampedValue<T = number> {
/** Timestamp when the value was recorded */
t: number;
/** The actual value */
v: T;
}
/** Represents the state of a single device */
export interface DeviceState {
/** Device identifier */
Device: string;
/** Overall timestamp for the device state */
Timestamp: number;
/** Time the device has been on */
OnTime: TimestampedValue<number>;
/** Temperature set point */
SetPoint: TimestampedValue<number>;
/** Display brightness level */
Brightness: TimestampedValue<number>;
/** Schedule mode setting */
ScheduleMode: TimestampedValue<number>;
/** Hold time setting */
HoldTime: TimestampedValue<number>;
/** Wi-Fi signal strength */
Rssi: TimestampedValue<number>;
/** Thermostat mode */
TstatMode: TimestampedValue<number>;
/** Available heap memory */
FreeHeap: TimestampedValue<number>;
/** Sensor temperature reading */
SensorTemp: TimestampedValue<number>;
/** Current mode */
Mode: TimestampedValue<number>;
/** Voltage measurement */
Voltage: TimestampedValue<number>;
/** Temperature corrected for calibration */
CorrectedTemp: TimestampedValue<number>;
/** Duty cycle percentage */
Duty: TimestampedValue<number>;
/** Heat sink temperature */
HeatSink: TimestampedValue<number>;
/** Time the device has been off */
OffTime: TimestampedValue<number>;
/** Connection status */
Connected: TimestampedValue<boolean>;
/** Current consumption */
Current: TimestampedValue<number>;
/** Humidity reading */
Humidity: TimestampedValue<number>;
/** Lock status */
Lock: TimestampedValue<number>;
}
/**
* Collection of device states indexed by device ID
*
* Maps device ID strings to their corresponding device state objects, providing a lookup table for all devices
* associated with a user account.
*/
export interface DeviceStatesObj {
/** Device state objects indexed by their unique device ID strings */
[deviceId: string]: DeviceState;
}
/** Top-level interface for the device states REST API response. */
export interface DeviceStates {
DeviceStatesObj: DeviceStatesObj;
}

3
src/types/rest/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './Devices';
export * from './Firmwares';
export * from './States';