68 Commits

Author SHA1 Message Date
dependabot[bot]
85c123d2aa chore(deps): Bump @aws-sdk/credential-providers from 3.927.0 to 3.936.0 (#189) 2025-11-28 14:16:33 +00:00
dependabot[bot]
0524bcea73 chore(deps-dev): Bump pino-pretty from 13.0.0 to 13.1.2 (#190) 2025-11-28 14:15:48 +00:00
dependabot[bot]
bb10ba4616 chore(deps-dev): Bump pino from 9.13.0 to 10.1.0 (#191) 2025-11-28 14:13:00 +00:00
dependabot[bot]
5366ea6fc9 chore(deps): Bump @aws-sdk/client-iot from 3.920.0 to 3.936.0 (#192) 2025-11-28 14:12:56 +00:00
dependabot[bot]
baa7941cfc chore(deps): Bump amazon-cognito-identity-js from 6.3.15 to 6.3.16 (#193) 2025-11-28 14:12:49 +00:00
Pascal Bourque
ef60db37d5 fix: Unable to automatically reconnect when credentials have expired (#194) 2025-11-28 09:06:54 -05:00
dependabot[bot]
f1525cd1f1 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 7 updates (#188) 2025-11-23 15:34:54 +00:00
dependabot[bot]
3b2a020ac7 chore(deps): Bump aws-iot-device-sdk-v2 from 1.22.0 to 1.23.1 (#182) 2025-11-23 14:59:39 +00:00
dependabot[bot]
cbac285b1e chore(deps-dev): Bump @eslint/js from 9.38.0 to 9.39.1 (#183) 2025-11-23 14:57:01 +00:00
dependabot[bot]
ca127483c1 chore(deps): Bump @aws-sdk/credential-providers from 3.922.0 to 3.927.0 (#184) 2025-11-23 14:56:56 +00:00
dependabot[bot]
e320d658e8 chore(deps): Bump dayjs from 1.11.18 to 1.11.19 (#185) 2025-11-23 14:56:52 +00:00
dependabot[bot]
c8dac38563 chore(deps-dev): Bump js-yaml from 4.1.0 to 4.1.1 (#187) 2025-11-23 14:56:24 +00:00
Pascal Bourque
94acdede23 fix: Prevent AWS_ERROR_MQTT_UNEXPECTED_HANGUP connection interruptions (#179)
By using a stable, unique per-process client identifier.

Also:

- Configured MQTT auto-reconnect on interruption
- Reset connection on high MQTT connection interruption rate
2025-11-08 15:12:30 -05:00
dependabot[bot]
d007c2d745 chore(deps-dev): Bump typedoc from 0.28.13 to 0.28.14 (#174) 2025-11-03 11:51:43 +00:00
dependabot[bot]
5d9981f9e0 chore(deps): Bump @aws-sdk/credential-providers from 3.901.0 to 3.922.0 (#173) 2025-11-03 11:49:06 +00:00
dependabot[bot]
2f2cdef0ee chore(deps-dev): Bump typedoc-material-theme from 1.4.0 to 1.4.1 (#175) 2025-11-03 11:48:50 +00:00
dependabot[bot]
193f67226b chore(deps-dev): Bump typescript-eslint from 8.41.0 to 8.46.2 (#176) 2025-11-03 11:48:46 +00:00
dependabot[bot]
ef8d787e05 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 2 updates (#177) 2025-11-03 11:48:39 +00:00
Pascal Bourque
0c71ed95ce chore: Changed dependabot schedule from daily to weekly (#171) 2025-11-02 12:10:05 -05:00
Pascal Bourque
d861a50136 fix!: Device and state properties are now optional (#170)
Updated DeviceBase, BrandInfo, and DeviceState interfaces to make most properties optional, improving flexibility for partial objects and better handling of missing data.
2025-11-01 09:33:31 -04:00
allcontributors[bot]
7b332b1416 docs: add remiolivier as a contributor for code (#169)
Adds @remiolivier as a contributor for code.

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

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Pascal Bourque <pascal@cosmos.moi>
2025-10-31 19:47:48 -04:00
remiolivier
15edd9dbbf feat: Partial support for the AC-V1-1 thermostat (#156)
## Feat. Add Support for Mysa AC-V1-1 Devices

### Overview
This PR aims to extend **mysa2mqtt** to support **Mysa AC-V1-1**
thermostats in addition to the existing baseboard models.
AC-V1 devices use different operating modes and fan modes, which
required updates to both mode translation and MQTT behavior.
Tested with `BB-V1-1` and `AC-V1-1`.

*Note: I'm no typescript expert so code might not look the best but is
fully tested*

### Key Changes
- Supports `cool`, `dry`, `fan_only`, and `auto` in addition to `off`
and `heat`.
  - New fan modes: `auto`, `low`, `medium`, `high`, and `max`.    

### Does not support yet
 - Vertical swing
 - Horizontal swing

### Technical stuff
`AC-V1-1` payload:

`"body":{"success":1,"type":2,"trig_src":3,"state":{"md":3,"sp":23.5,"lk":0,"ho":1,"br":100,"da":2,"fn":5,"ss":4,"ssh":12,"it":0}}}}`

Fan mode values: 1 = 'auto', 3 = 'low', 5 = 'medium', 7 = 'high', 8 =
'max'
**I named the value 8 max as I needed a 4th value but is not tied to
anything in HA or Mysa**

### Testing
```
npm run example

[23:13:06.300] INFO (example/3281203): [example] 'Office Room' status changed: 21.9°C, 49%, 0W
[23:13:21.701] INFO (example/3281203): [example] 'Office Room' status changed: 21.9°C, 49%, 0W
[23:13:21.938] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"heat","setPoint":23,"fanSpeed":"auto"}
[23:13:33.282] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"heat","setPoint":23.5,"fanSpeed":"auto"}
[23:13:38.132] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"heat","setPoint":23.5,"fanSpeed":"high"}
[23:13:44.380] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"fan_only","setPoint":23.5,"fanSpeed":"high"}
[23:13:52.609] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"cool","setPoint":23.5,"fanSpeed":"high"}
[23:13:57.942] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"heat","setPoint":23.5,"fanSpeed":"high"}
[23:14:01.052] INFO (example/3281203): [example] 'Family Room' state changed. {"deviceId":"<redacted>","mode":"heat","setPoint":23.5,"fanSpeed":"auto"}
```
PR to `mysa2mqtt` coming right after
2025-10-31 19:39:28 -04:00
Pascal Bourque
0c906fefe9 fix: Better resilience towards MQTT connection loss and errors (#168) 2025-10-31 15:47:15 -04:00
dependabot[bot]
137e51efa0 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 8 updates (#166) 2025-10-31 13:58:29 +00:00
dependabot[bot]
5644bd7a1e chore(deps): Bump @aws-sdk/client-iot from 3.901.0 to 3.920.0 (#165) 2025-10-31 13:41:31 +00:00
dependabot[bot]
e434b96087 chore(deps-dev): Bump @eslint/js from 9.34.0 to 9.38.0 (#158) 2025-10-31 13:41:19 +00:00
dependabot[bot]
bec3a9804d chore(deps): Bump dayjs from 1.11.13 to 1.11.18 (#149) 2025-10-31 13:41:03 +00:00
dependabot[bot]
64ba134b76 chore(deps-dev): Bump tsx from 4.20.3 to 4.20.6 (#148) 2025-10-31 13:40:46 +00:00
dependabot[bot]
8dfb1b7e82 chore(deps-dev): Bump dotenv from 17.2.1 to 17.2.3 (#147) 2025-10-31 13:40:02 +00:00
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
17 changed files with 3549 additions and 2039 deletions

29
.all-contributorsrc Normal file
View File

@@ -0,0 +1,29 @@
{
"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"]
},
{
"login": "remiolivier",
"name": "remiolivier",
"avatar_url": "https://avatars.githubusercontent.com/u/1379047?v=4",
"profile": "https://github.com/remiolivier",
"contributions": ["code"]
}
],
"contributorsPerLine": 7,
"skipCi": true,
"repoType": "github",
"repoHost": "https://github.com",
"projectName": "mysa-js-sdk",
"projectOwner": "bourquep"
}

View File

@@ -8,7 +8,7 @@ updates:
- package-ecosystem: 'npm' # See documentation for possible values - package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests directory: '/' # Location of package manifests
schedule: schedule:
interval: 'daily' interval: 'weekly'
labels: labels:
- 'dependencies' - 'dependencies'
commit-message: commit-message:

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-2-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,27 @@ 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>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/remiolivier"><img src="https://avatars.githubusercontent.com/u/1379047?v=4?s=100" width="100px;" alt="remiolivier"/><br /><sub><b>remiolivier</b></sub></a><br /><a href="https://github.com/bourquep/mysa-js-sdk/commits?author=remiolivier" 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

@@ -16,7 +16,8 @@ export default [
rules: { rules: {
'jsdoc/tag-lines': 'off', 'jsdoc/tag-lines': 'off',
'jsdoc/check-tag-names': 'off', 'jsdoc/check-tag-names': 'off',
'jsdoc/valid-types': 'off' 'jsdoc/valid-types': 'off',
'jsdoc/require-throws-type': 'off'
} }
}, },
{ plugins: { tsdoc }, rules: { 'tsdoc/syntax': 'warn' } } { plugins: { tsdoc }, rules: { 'tsdoc/syntax': 'warn' } }

View File

@@ -57,39 +57,53 @@ 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 && device.Voltage !== undefined ? status.current * device.Voltage : undefined;
}); rootLogger.info(
`[${status.deviceId}] '${device.Name ?? 'Unknown'}' 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(
} catch (error) { `'${device.Name ?? 'Unknown'}' setpoint changed from ${change.previousSetPoint} to ${change.newSetPoint}`
rootLogger.error(`Error processing setpoint update for device '${change.deviceId}':`, error); );
} } catch (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 ?? 'Unknown'}' 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 ?? 'Unknown'}): ${serial}`);
await client.startRealtimeUpdates(deviceId);
})
);
} }
main().catch((error) => { main().catch((error) => {

4605
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,39 +40,45 @@
"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.823.0", "@aws-sdk/client-iot": "3.936.0",
"amazon-cognito-identity-js": "6.3.15", "@aws-sdk/credential-providers": "3.940.0",
"aws-iot-device-sdk-v2": "1.21.5", "amazon-cognito-identity-js": "6.3.16",
"dayjs": "1.11.13", "aws-iot-device-sdk-v2": "1.23.1",
"lodash": "4.17.21" "dayjs": "1.11.19",
"lodash": "4.17.21",
"nanoid": "5.1.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.28.0", "@eslint/js": "9.39.1",
"@semantic-release/npm": "12.0.1", "@semantic-release/npm": "13.1.2",
"@types/lodash": "4.17.17", "@types/lodash": "4.17.21",
"@types/node": "22.15.29", "@types/node": "24.10.1",
"conventional-changelog-conventionalcommits": "9.0.0", "conventional-changelog-conventionalcommits": "9.1.0",
"dotenv": "16.5.0", "dotenv": "17.2.3",
"eslint": "9.28.0", "eslint": "9.39.1",
"eslint-plugin-jsdoc": "50.7.1", "eslint-plugin-jsdoc": "61.4.1",
"eslint-plugin-tsdoc": "0.4.0", "eslint-plugin-tsdoc": "0.5.0",
"pino": "9.7.0", "pino": "10.1.0",
"pino-pretty": "13.0.0", "pino-pretty": "13.1.2",
"prettier": "3.5.3", "prettier": "3.6.2",
"prettier-plugin-jsdoc": "1.3.2", "prettier-plugin-jsdoc": "1.5.0",
"prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-organize-imports": "4.3.0",
"semantic-release": "24.2.5", "semantic-release": "25.0.2",
"tsup": "8.5.0", "tsup": "8.5.0",
"tsx": "4.19.4", "tsx": "4.20.6",
"typedoc": "0.28.5", "typedoc": "0.28.14",
"typedoc-material-theme": "1.4.0", "typedoc-material-theme": "1.4.1",
"typescript": "5.8.3", "typescript": "5.9.3",
"typescript-eslint": "8.33.1" "typescript-eslint": "8.46.2"
} }
} }

View File

@@ -32,3 +32,22 @@ export class MysaApiError extends Error {
this.statusText = apiResponse.statusText; this.statusText = apiResponse.statusText;
} }
} }
/** Error thrown when an MQTT publish ultimately fails after retry attempts. */
export class MqttPublishError extends Error {
/**
* Creates a new MqttPublishError instance.
*
* @param message - A human-readable description of the publish failure.
* @param attempts - The number of attempts that were made before giving up.
* @param original - The original error object thrown by the underlying MQTT library (optional).
*/
constructor(
message: string,
public attempts: number,
public original?: unknown
) {
super(message);
this.name = 'MqttPublishError';
}
}

View File

@@ -7,6 +7,7 @@ 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, DeviceStates, Firmwares } from '@/types/rest'; 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,
@@ -18,16 +19,28 @@ import {
CognitoUserSession CognitoUserSession
} 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 { hash } from 'crypto';
import dayjs, { Dayjs } from 'dayjs';
import duration from 'dayjs/plugin/duration.js'; import duration from 'dayjs/plugin/duration.js';
import { MysaApiError, UnauthenticatedError } from './Errors'; import { customAlphabet } from 'nanoid';
import { MqttPublishError, MysaApiError, UnauthenticatedError } from './Errors';
import { Logger, VoidLogger } from './Logger'; import { Logger, VoidLogger } from './Logger';
import { MysaApiClientEventTypes } from './MysaApiClientEventTypes'; import { MysaApiClientEventTypes } from './MysaApiClientEventTypes';
import { MysaApiClientOptions } from './MysaApiClientOptions'; import { MysaApiClientOptions } from './MysaApiClientOptions';
import { MysaDeviceMode } from './MysaDeviceMode'; import { MysaDeviceMode, MysaFanSpeedMode } from './MysaDeviceMode';
dayjs.extend(duration); dayjs.extend(duration);
const getRandomClientId = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 8);
/** Options for MQTT publish operations. */
export interface MqttPublishOptions {
/** Maximum number of publish attempts before failing (default: 5). */
maxAttempts?: number;
/** Base delay in milliseconds used for exponential backoff calculation (default: 500). */
baseDelayMs?: number;
}
const AwsRegion = 'us-east-1'; const AwsRegion = 'us-east-1';
const CognitoUserPoolId = 'us-east-1_GUFWfhI7g'; const CognitoUserPoolId = 'us-east-1_GUFWfhI7g';
const CognitoClientId = '19efs8tgqe942atbqmot5m36t3'; const CognitoClientId = '19efs8tgqe942atbqmot5m36t3';
@@ -74,8 +87,20 @@ 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>;
/** Stable per-process MQTT client id (prevents collisions between multiple processes). */
private _mqttClientId?: string;
/** Expiration time of the credentials currently in use by the MQTT client. */
private _mqttCredentialsExpiration?: Dayjs;
/** Interrupt timestamps for storm / collision detection. */
private _mqttInterrupts: number[] = [];
/** Whether a forced MQTT reset is currently in progress (guards against re-entrancy). */
private _mqttResetInProgress = false;
/** 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,12 +168,30 @@ 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;
this._cognitoUserSession = undefined; this._cognitoUserSession = undefined;
this._mqttClientId = undefined;
this._mqttInterrupts = [];
this.emitter.emit('sessionChanged', this.session); this.emitter.emit('sessionChanged', this.session);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -175,12 +218,26 @@ 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...`);
const session = await this.getFreshSession(); const session = await this._getFreshSession();
const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, { const response = await this._fetcher(`${MysaApiBaseUrl}/devices`, {
headers: { headers: {
@@ -195,10 +252,74 @@ 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> { async getDeviceFirmwares(): Promise<Firmwares> {
this._logger.debug(`Fetching device firmwares...`); this._logger.debug(`Fetching device firmwares...`);
const session = await this.getFreshSession(); const session = await this._getFreshSession();
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, { const response = await this._fetcher(`${MysaApiBaseUrl}/devices/firmware`, {
headers: { headers: {
@@ -213,10 +334,17 @@ export class MysaApiClient {
return response.json(); 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> { async getDeviceStates(): Promise<DeviceStates> {
this._logger.debug(`Fetching device states...`); this._logger.debug(`Fetching device states...`);
const session = await this.getFreshSession(); const session = await this._getFreshSession();
const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, { const response = await this._fetcher(`${MysaApiBaseUrl}/devices/state`, {
headers: { headers: {
@@ -231,7 +359,37 @@ export class MysaApiClient {
return response.json(); return response.json();
} }
async setDeviceState(deviceId: string, setPoint?: number, mode?: MysaDeviceMode) { /**
* 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');
*
* // Set fan speed
* await client.setDeviceState('device123', undefined, undefined, 'auto');
* ```
*
* @param deviceId - The ID of the device to control.
* @param setPoint - The target temperature set point (optional).
* @param mode - The operating mode to set (one of MysaDeviceMode values, or undefined to leave unchanged).
* @param fanSpeed - The fan speed mode to set ('low', 'medium', 'high', 'max', 'auto', 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, fanSpeed?: MysaFanSpeedMode) {
this._logger.debug(`Setting device state for '${deviceId}'`); this._logger.debug(`Setting device state for '${deviceId}'`);
if (!this._cachedDevices) { if (!this._cachedDevices) {
@@ -241,18 +399,21 @@ export class MysaApiClient {
const device = this._cachedDevices.DevicesObj[deviceId]; const device = this._cachedDevices.DevicesObj[deviceId];
this._logger.debug(`Initializing MQTT connection...`); this._logger.debug(`Initializing MQTT connection...`);
const mqttConnection = await this.getMqttConnection(); const mqttConnection = await this._getMqttConnection();
const now = dayjs(); const now = dayjs();
this._logger.debug(`Sending request to set device state for '${deviceId}'...`); this._logger.debug(`Sending request to set device state for '${deviceId}'...`);
const modeMap = { off: 1, auto: 2, heat: 3, cool: 4, fan_only: 5, dry: 6 };
const fanSpeedMap = { auto: 1, low: 3, medium: 5, high: 7, max: 8 };
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: {
ref: this.session!.username, ref: this.session?.username ?? '',
type: 100 type: 100
}, },
dest: { dest: {
@@ -264,28 +425,53 @@ export class MysaApiClient {
ver: 1, ver: 1,
type: device.Model.startsWith('BB-V1') type: device.Model.startsWith('BB-V1')
? 1 ? 1
: device.Model.startsWith('BB-V2') : device.Model.startsWith('AC-V1')
? device.Model.endsWith('-L') ? 2
? 5 : device.Model.startsWith('BB-V2')
: 4 ? device.Model.endsWith('-L')
: 0, ? 5
: 4
: 0,
cmd: [ cmd: [
{ {
tm: -1, tm: -1,
sp: setPoint, sp: setPoint,
md: mode === 'off' ? 1 : mode === 'heat' ? 3 : undefined md: mode ? modeMap[mode] : undefined,
fn: fanSpeed ? fanSpeedMap[fanSpeed] : undefined
} }
] ]
} }
}); });
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce); try {
await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
this._logger.debug(`Device state publish succeeded for '${deviceId}'`);
} catch (error) {
this._logger.error(`Failed to set device state for '${deviceId}'`, error);
throw error;
}
} }
/** /**
* 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 real-time updates for device '${deviceId}'`); this._logger.info(`Starting real-time updates for device '${deviceId}'`);
@@ -296,11 +482,11 @@ export class MysaApiClient {
} }
this._logger.debug(`Initializing MQTT connection...`); this._logger.debug(`Initializing MQTT connection...`);
const mqttConnection = await this.getMqttConnection(); const mqttConnection = await this._getMqttConnection();
this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`); this._logger.debug(`Subscribing to MQTT topic '/v1/dev/${deviceId}/out'...`);
await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, mqtt.QoS.AtLeastOnce, (_, payload) => { await mqttConnection.subscribe(`/v1/dev/${deviceId}/out`, mqtt.QoS.AtLeastOnce, (_, payload) => {
this.processMqttMessage(payload); this._processMqttMessage(payload);
}); });
this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`); this._logger.debug(`Sending request to start publishing device status for '${deviceId}'...`);
@@ -310,17 +496,19 @@ export class MysaApiClient {
Timestamp: dayjs().unix(), Timestamp: dayjs().unix(),
Timeout: RealtimeKeepAliveInterval.asSeconds() Timeout: RealtimeKeepAliveInterval.asSeconds()
}); });
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce); await this._publishWithRetry(mqttConnection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
const timer = setInterval(async () => { const timer = setInterval(async () => {
this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`); this._logger.debug(`Sending request to keep-alive publishing device status for '${deviceId}'...`);
const connection = await this._getMqttConnection();
const payload = serializeMqttPayload<StartPublishingDeviceStatus>({ const payload = serializeMqttPayload<StartPublishingDeviceStatus>({
Device: deviceId, Device: deviceId,
MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS, MsgType: InMessageType.START_PUBLISHING_DEVICE_STATUS,
Timestamp: dayjs().unix(), Timestamp: dayjs().unix(),
Timeout: RealtimeKeepAliveInterval.asSeconds() Timeout: RealtimeKeepAliveInterval.asSeconds()
}); });
await mqttConnection.publish(`/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce); await this._publishWithRetry(connection, `/v1/dev/${deviceId}/in`, payload, mqtt.QoS.AtLeastOnce);
}, RealtimeKeepAliveInterval.subtract(10, 'seconds').asMilliseconds()); }, RealtimeKeepAliveInterval.subtract(10, 'seconds').asMilliseconds());
this._realtimeDeviceIds.set(deviceId, timer); this._realtimeDeviceIds.set(deviceId, timer);
@@ -329,7 +517,11 @@ 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}'`); this._logger.info(`Stopping real-time updates for device '${deviceId}'`);
@@ -341,7 +533,7 @@ export class MysaApiClient {
} }
this._logger.debug(`Initializing MQTT connection...`); this._logger.debug(`Initializing MQTT connection...`);
const mqttConnection = await this.getMqttConnection(); const mqttConnection = await this._getMqttConnection();
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`);
@@ -350,7 +542,16 @@ export class MysaApiClient {
this._realtimeDeviceIds.delete(deviceId); this._realtimeDeviceIds.delete(deviceId);
} }
private async getFreshSession(): Promise<CognitoUserSession> { /**
* 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> {
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.');
} }
@@ -379,12 +580,129 @@ 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;
});
} }
const session = await this.getFreshSession(); return this._mqttConnectionPromise;
}
/**
* Determines whether an MQTT-related error is considered transient and worth retrying.
*
* Transient errors include timeouts, cancelled operations due to clean sessions, temporary connectivity loss, and
* other recoverable network issues. Fatal errors (auth, permission, configuration) should not be retried at this
* layer.
*
* @param err - The error object thrown by the underlying MQTT operation.
* @returns True if the error appears transient and a retry should be attempted; false otherwise.
*/
private _isTransientMqttError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false;
}
const anyErr = err as { error_code?: unknown; error_name?: unknown; error?: unknown; message?: unknown };
const code = anyErr.error_code || anyErr.error_name || anyErr.error;
const msg = (anyErr.message || anyErr.error || '').toString();
const transientMarkers = [
'AWS_ERROR_MQTT_TIMEOUT',
'AWS_ERROR_MQTT_NO_CONNECTION',
'AWS_ERROR_MQTT_UNEXPECTED_HANGUP',
'UNEXPECTED_HANGUP',
'AWS_ERROR_MQTT_CONNECTION_DESTROYED',
'Time limit between request and response',
'timeout'
];
return transientMarkers.some((m) => (code && String(code).includes(m)) || msg.includes(m));
}
/**
* Publishes an MQTT message with exponential backoff retries for transient failures.
*
* Retries occur for errors classified by `_isTransientMqttError`. Between attempts the delay grows exponentially with
* jitter to avoid thundering herds after broker recovery. If the connection is not currently marked as connected, a
* reconnect is attempted; if that fails, the connection is rebuilt (fresh credentials) before the next retry.
*
* On final failure (after maxAttempts) a {@link MqttPublishError} is thrown including the number of attempts and
* original error for higher-level handling.
*
* @remarks
* Retry options fields:
*
* - MaxAttempts: Maximum number of publish attempts before failing (default: 5).
* - BaseDelayMs: Base delay in milliseconds used for exponential backoff calculation (default: 500).
*
* @param connection - The active MQTT client connection used to send the publish.
* @param topic - The MQTT topic to publish to.
* @param payload - The serialized payload (binary buffer or Uint8Array).
* @param qos - The desired MQTT QoS level for the publish.
* @param opts - Retry options (defaults: maxAttempts=5, baseDelayMs=500).
* @returns A promise that resolves when the publish succeeds, or rejects with {@link MqttPublishError}.
*/
private async _publishWithRetry(
connection: mqtt.MqttClientConnection,
topic: string,
payload: ArrayBuffer | Uint8Array,
qos: mqtt.QoS,
opts: MqttPublishOptions = {}
): Promise<void> {
const maxAttempts = opts.maxAttempts ?? 5;
const baseDelayMs = opts.baseDelayMs ?? 500;
let attempt = 0;
while (true) {
attempt++;
try {
await connection.publish(topic, payload, qos);
return;
} catch (err) {
const isTransient = this._isTransientMqttError(err);
if (!isTransient || attempt >= maxAttempts) {
throw new MqttPublishError(`MQTT publish failed after ${attempt} attempts`, attempt, err);
}
// Apply jitter: delay is randomized between 75% and 125% of the base exponential backoff
const JITTER_MIN_FACTOR = 0.75;
const JITTER_RANGE = 0.5;
const delay = baseDelayMs * Math.pow(2, attempt - 1) * (JITTER_MIN_FACTOR + Math.random() * JITTER_RANGE);
this._logger.warn(
`Transient MQTT publish error on '${topic}' (attempt ${attempt}/${maxAttempts}). Retrying in ${Math.round(
delay
)}ms`
);
await new Promise((r) => setTimeout(r, delay));
}
}
}
/**
* 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 credentialsProvider = fromCognitoIdentityPool({ const credentialsProvider = fromCognitoIdentityPool({
clientConfig: { clientConfig: {
region: AwsRegion region: AwsRegion
@@ -397,30 +715,161 @@ export class MysaApiClient {
}); });
const credentials = await credentialsProvider(); const credentials = await credentialsProvider();
if (!credentials.expiration) {
throw new Error('MQTT credentials do not have an expiration time.');
}
this._mqttCredentialsExpiration = dayjs(credentials.expiration);
this._logger.debug(`MQTT credentials expiration: ${this._mqttCredentialsExpiration.format()}`);
if (!this._mqttCredentialsExpiration.isAfter(dayjs())) {
this._mqttCredentialsExpiration = undefined;
throw new Error('MQTT credentials are already expired.');
}
// Per-process stable client id. Random suffix avoids collisions with other running processes.
if (!this._mqttClientId) {
const username = this.session?.username ?? 'anon';
const usernameHash = hash('sha1', username);
this._mqttClientId = `mysa-js-sdk-${usernameHash}-${process.pid}-${getRandomClientId()}`;
}
const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets() const builder = iot.AwsIotMqttConnectionConfigBuilder.new_with_websockets()
.with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken) .with_credentials(AwsRegion, credentials.accessKeyId, credentials.secretAccessKey, credentials.sessionToken)
.with_endpoint(MqttEndpoint) .with_endpoint(MqttEndpoint)
.with_client_id(`mysa-js-sdk-${dayjs().unix()}`) // Unique client ID .with_client_id(this._mqttClientId)
.with_clean_session(true) .with_clean_session(false)
.with_keep_alive_seconds(30) .with_keep_alive_seconds(30)
.with_ping_timeout_ms(3000) .with_ping_timeout_ms(3000)
.with_protocol_operation_timeout_ms(60000); .with_protocol_operation_timeout_ms(60000)
.with_reconnect_min_sec(1)
.with_reconnect_max_sec(30);
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.info('MQTT connection closed'); this._logger.debug(`MQTT connect (clientId=${this._mqttClientId})`);
this._mqttConnection = undefined;
}); });
await this._mqttConnection.connect(); connection.on('connection_success', () => {
this._logger.debug(`MQTT connection_success (clientId=${this._mqttClientId})`);
});
return this._mqttConnection; connection.on('connection_failure', (e) => {
this._logger.error(`MQTT connection_failure (clientId=${this._mqttClientId})`, e);
});
connection.on('interrupt', async (e) => {
this._logger.warn(`MQTT interrupt (clientId=${this._mqttClientId})`, e);
// Track recent interrupts
const now = Date.now();
// Keep only last 60s
this._mqttInterrupts = this._mqttInterrupts.filter((t) => now - t < 60000);
this._mqttInterrupts.push(now);
const areCredentialsExpired = !(this._mqttCredentialsExpiration?.isAfter(dayjs()) ?? false);
if ((this._mqttInterrupts.length > 5 || areCredentialsExpired) && !this._mqttResetInProgress) {
this._mqttResetInProgress = true;
if (this._mqttInterrupts.length > 5) {
this._logger.warn(
`High interrupt rate (${this._mqttInterrupts.length}/60s). Possible clientId collision. Regenerating clientId and resetting connection...`
);
} else {
this._logger.warn(`Credentials expired. Regenerating clientId and resetting connection...`);
}
// Force new client id to escape collision; close current connection
this._mqttClientId = undefined;
this._mqttCredentialsExpiration = undefined;
// Clear interrupts
this._mqttInterrupts = [];
// Explicitly clear promise first to prevent reuse while disconnecting
// (publishers calling _getMqttConnection() will create a new one)
this._mqttConnectionPromise = undefined;
try {
await connection.disconnect();
try {
this._logger.debug('Old MQTT connection disconnected; establishing new connection...');
const newConnection = await this._getMqttConnection();
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
const topic = `/v1/dev/${deviceId}/out`;
this._logger.debug(`Re-subscribing to ${topic}`);
await newConnection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
this._processMqttMessage(payload);
});
}
this._logger.info('MQTT connection rebuilt successfully after interrupt storm or credentials expiration');
} catch (err) {
this._logger.error('Failed to re-subscribe after interrupt storm or credentials expiration', err);
}
} catch (error) {
this._logger.error('Error during MQTT reset', error);
} finally {
this._mqttResetInProgress = false;
}
}
});
connection.on('resume', async (returnCode, sessionPresent) => {
this._logger.info(
`MQTT resume returnCode=${returnCode} sessionPresent=${sessionPresent} clientId=${this._mqttClientId}`
);
if (!sessionPresent) {
this._logger.info('No session present, re-subscribing each device');
try {
for (const deviceId of Array.from(this._realtimeDeviceIds.keys())) {
const topic = `/v1/dev/${deviceId}/out`;
this._logger.debug(`Re-subscribing to ${topic}`);
await connection.subscribe(topic, mqtt.QoS.AtLeastOnce, (_topic, payload) => {
this._processMqttMessage(payload);
});
}
} catch (err) {
this._logger.error('Failed to re-subscribe after resume', err);
}
}
});
connection.on('error', (e) => {
this._logger.error(`MQTT error (clientId=${this._mqttClientId})`, e);
});
connection.on('closed', () => {
this._logger.info('MQTT connection closed');
this._mqttConnectionPromise = undefined;
this._mqttCredentialsExpiration = undefined;
});
await connection.connect();
return connection;
} }
private processMqttMessage(payload: ArrayBuffer) { /**
* 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) {
try { try {
const parsedPayload = parseMqttPayload(payload); const parsedPayload = parseMqttPayload(payload);
@@ -458,13 +907,31 @@ export class MysaApiClient {
}); });
break; break;
case OutMessageType.DEVICE_STATE_CHANGE: case OutMessageType.DEVICE_STATE_CHANGE: {
const modeMap: Record<number, MysaDeviceMode> = {
1: 'off',
2: 'auto',
3: 'heat',
4: 'cool',
5: 'fan_only',
6: 'dry'
};
const fanSpeedMap: Record<number, MysaFanSpeedMode> = {
1: 'auto',
3: 'low',
5: 'medium',
7: 'high',
8: 'max'
};
this.emitter.emit('stateChanged', { this.emitter.emit('stateChanged', {
deviceId: parsedPayload.src.ref, deviceId: parsedPayload.src.ref,
mode: parsedPayload.body.state.md === 1 ? 'off' : parsedPayload.body.state.md === 3 ? 'heat' : undefined, mode: parsedPayload.body.state.md ? modeMap[parsedPayload.body.state.md] : undefined,
setPoint: parsedPayload.body.state.sp setPoint: parsedPayload.body.state.sp,
fanSpeed: parsedPayload.body.state.fn !== undefined ? fanSpeedMap[parsedPayload.body.state.fn] : undefined
}); });
break; break;
}
} }
} }
} catch (error) { } catch (error) {

View File

@@ -4,4 +4,11 @@
* Defines the possible operational states that a Mysa thermostat or heating device can be set to. These modes control * 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. * the device's heating behavior and power consumption.
*/ */
export type MysaDeviceMode = 'off' | 'heat'; export type MysaDeviceMode = 'off' | 'heat' | 'cool' | 'dry' | 'fan_only' | 'auto';
/**
* Union type representing the available fan speed modes for Mysa devices.
*
* Defines the possible fan speed states that a Mysa thermostat device can be set to.
*/
export type MysaFanSpeedMode = 'auto' | 'low' | 'medium' | 'high' | 'max';

View File

@@ -1,4 +1,4 @@
import { MysaDeviceMode } from '@/api/MysaDeviceMode'; import { MysaDeviceMode, MysaFanSpeedMode } from '@/api/MysaDeviceMode';
/** /**
* Interface representing a device state change event for a Mysa device. * Interface representing a device state change event for a Mysa device.
@@ -14,4 +14,6 @@ export interface StateChange {
mode?: MysaDeviceMode; mode?: MysaDeviceMode;
/** Current temperature setpoint after the state change */ /** Current temperature setpoint after the state change */
setPoint: number; setPoint: number;
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
fanSpeed?: MysaFanSpeedMode;
} }

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);

View File

@@ -36,6 +36,8 @@ export interface ChangeDeviceState extends MsgPayload<InMessageType.CHANGE_DEVIC
md?: number; md?: number;
/** Unknown, should always be -1 */ /** Unknown, should always be -1 */
tm: number; tm: number;
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
fn?: number;
} }
]; ];
/** /**

View File

@@ -25,10 +25,12 @@ export interface DeviceStateChange extends MsgPayload<OutMessageType.DEVICE_STAT
ho: number; ho: number;
/** Unknown */ /** Unknown */
lk: number; lk: number;
/** Device mode (1 = OFF, 3 = HEAT) */ /** Device mode (1 = OFF, 2 = AUTO, 3 = HEAT, 4 = COOL, 5 = FAN_ONLY, 6 = DRY) */
md: number; md: number;
/** Temperature setpoint */ /** Temperature setpoint */
sp: number; sp: number;
/** Optional fan speed (1 = auto, 3 = low, 5 = medium, 7 = high, 8 = max). AC only */
fn?: number;
}; };
/** Success indicator for the state change operation (1 = success, 0 = failure) */ /** Success indicator for the state change operation (1 = success, 0 = failure) */
success: number; success: number;

View File

@@ -10,9 +10,9 @@ export interface BrandInfo {
/** Unique identifier for the brand */ /** Unique identifier for the brand */
Id: number; Id: number;
/** Remote control model number for the AC device */ /** Remote control model number for the AC device */
remoteModelNumber: string; remoteModelNumber?: string;
/** Original Equipment Manufacturer brand name */ /** Original Equipment Manufacturer brand name */
OEMBrand: string; OEMBrand?: string;
} }
/** /**
@@ -56,55 +56,55 @@ export interface ModeObj {
*/ */
export interface DeviceBase { export interface DeviceBase {
/** Button digital input configuration value */ /** Button digital input configuration value */
ButtonDI: number; ButtonDI?: number;
/** Maximum current rating as a string value */ /** Maximum current rating as a string value */
MaxCurrent: string; MaxCurrent?: string;
/** Device model identifier string */ /** Device model identifier string */
Model: string; Model: string;
/** Button average value configuration */ /** Button average value configuration */
ButtonAVE: number; ButtonAVE?: number;
/** Operating voltage of the device */ /** Operating voltage of the device */
Voltage: number; Voltage?: number;
/** Button polling interval configuration */ /** Button polling interval configuration */
ButtonPolling: number; ButtonPolling?: number;
/** Minimum brightness level (0-100) */ /** Minimum brightness level (0-100) */
MinBrightness: number; MinBrightness?: number;
/** User-assigned device name */ /** User-assigned device name */
Name: string; Name?: string;
/** Button low power mode configuration */ /** Button low power mode configuration */
ButtonLowPower: number; ButtonLowPower?: number;
/** Type of heater controlled by the device */ /** Type of heater controlled by the device */
HeaterType: string; HeaterType?: string;
/** Button repeat delay configuration in milliseconds */ /** Button repeat delay configuration in milliseconds */
ButtonRepeatDelay: number; ButtonRepeatDelay?: number;
/** Button repeat start delay configuration in milliseconds */ /** Button repeat start delay configuration in milliseconds */
ButtonRepeatStart: number; ButtonRepeatStart?: number;
/** Display animation style setting */ /** Display animation style setting */
Animation: string; Animation?: string;
/** Maximum brightness level (0-100) */ /** Maximum brightness level (0-100) */
MaxBrightness: number; MaxBrightness?: number;
/** Array of user IDs allowed to control this device */ /** Array of user IDs allowed to control this device */
AllowedUsers: string[]; AllowedUsers?: string[];
/** Current button state indicator */ /** Current button state indicator */
ButtonState: string; ButtonState?: string;
/** Home identifier that this device belongs to */ /** Home identifier that this device belongs to */
Home: string; Home?: string;
/** Button sensitivity threshold configuration */ /** Button sensitivity threshold configuration */
ButtonThreshold: number; ButtonThreshold?: number;
/** Data format version used by the device */ /** Data format version used by the device */
Format: string; Format?: string;
/** Time zone setting for the device */ /** Time zone setting for the device */
TimeZone: string; TimeZone?: string;
/** Unix timestamp of when device was last paired */ /** Unix timestamp of when device was last paired */
LastPaired: number; LastPaired?: number;
/** Minimum temperature setpoint allowed */ /** Minimum temperature setpoint allowed */
MinSetpoint: number; MinSetpoint?: number;
/** Current operating mode of the device */ /** Current operating mode of the device */
Mode: ModeObj; Mode?: ModeObj;
/** User ID of the device owner */ /** User ID of the device owner */
Owner: string; Owner?: string;
/** Maximum temperature setpoint allowed */ /** Maximum temperature setpoint allowed */
MaxSetpoint: number; MaxSetpoint?: number;
/** Unique device identifier */ /** Unique device identifier */
Id: string; Id: string;
/** Optional zone assignment for the device */ /** Optional zone assignment for the device */

View File

@@ -13,43 +13,45 @@ export interface DeviceState {
/** Overall timestamp for the device state */ /** Overall timestamp for the device state */
Timestamp: number; Timestamp: number;
/** Time the device has been on */ /** Time the device has been on */
OnTime: TimestampedValue<number>; OnTime?: TimestampedValue<number>;
/** Temperature set point */ /** Temperature set point */
SetPoint: TimestampedValue<number>; SetPoint?: TimestampedValue<number>;
/** Display brightness level */ /** Display brightness level */
Brightness: TimestampedValue<number>; Brightness?: TimestampedValue<number>;
/** Schedule mode setting */ /** Schedule mode setting */
ScheduleMode: TimestampedValue<number>; ScheduleMode?: TimestampedValue<number>;
/** Hold time setting */ /** Hold time setting */
HoldTime: TimestampedValue<number>; HoldTime?: TimestampedValue<number>;
/** Wi-Fi signal strength */ /** Wi-Fi signal strength */
Rssi: TimestampedValue<number>; Rssi?: TimestampedValue<number>;
/** Thermostat mode */ /** Thermostat mode */
TstatMode: TimestampedValue<number>; TstatMode?: TimestampedValue<number>;
/** Available heap memory */ /** Available heap memory */
FreeHeap: TimestampedValue<number>; FreeHeap?: TimestampedValue<number>;
/** Sensor temperature reading */ /** Sensor temperature reading */
SensorTemp: TimestampedValue<number>; SensorTemp?: TimestampedValue<number>;
/** Current mode */ /** Current mode */
Mode: TimestampedValue<number>; Mode?: TimestampedValue<number>;
/** Voltage measurement */ /** Voltage measurement */
Voltage: TimestampedValue<number>; Voltage?: TimestampedValue<number>;
/** Temperature corrected for calibration */ /** Temperature corrected for calibration */
CorrectedTemp: TimestampedValue<number>; CorrectedTemp?: TimestampedValue<number>;
/** Duty cycle percentage */ /** Duty cycle percentage */
Duty: TimestampedValue<number>; Duty?: TimestampedValue<number>;
/** Heat sink temperature */ /** Heat sink temperature */
HeatSink: TimestampedValue<number>; HeatSink?: TimestampedValue<number>;
/** Time the device has been off */ /** Time the device has been off */
OffTime: TimestampedValue<number>; OffTime?: TimestampedValue<number>;
/** Connection status */ /** Connection status */
Connected: TimestampedValue<boolean>; Connected?: TimestampedValue<boolean>;
/** Current consumption */ /** Current consumption */
Current: TimestampedValue<number>; Current?: TimestampedValue<number>;
/** Humidity reading */ /** Humidity reading */
Humidity: TimestampedValue<number>; Humidity?: TimestampedValue<number>;
/** Lock status */ /** Lock status */
Lock: TimestampedValue<number>; Lock?: TimestampedValue<number>;
/** Fan speed */
FanSpeed?: TimestampedValue<number>;
} }
/** /**