36 Commits

Author SHA1 Message Date
dependabot[bot]
34f9b574a3 chore(deps-dev): Bump @eslint/js from 9.38.0 to 9.39.0
Bumps [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) from 9.38.0 to 9.39.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/commits/v9.39.0/packages/js)

---
updated-dependencies:
- dependency-name: "@eslint/js"
  dependency-version: 9.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-02 17:13:37 +00:00
Pascal Bourque
2ff6d00999 chore: Changed dependabot schedule from daily to weekly (#83) 2025-11-02 12:12:25 -05:00
remiolivier
da90344ffc fix: Unable to change the set point when Home Assistant is configured with Fahrenheit temperature unit (#73)
# Fix Temperature Handling in Fahrenheit Mode

## Problem

When operating in **Fahrenheit mode**, Mysa still expects temperature
values to be provided in **Celsius**.
However, Home Assistant sends integer Fahrenheit values (e.g.,
`72.02°F`), which convert to **non-aligned Celsius values** like
`22.22°C`.

Mysa’s API only accepts temperature values that are either **whole
numbers** or **increments of 0.5°C** (for example: `21.0`, `21.5`,
`22.0`).
As a result, values such as `22.22°C` or `21.72°C` are considered
invalid and are **rejected** by Mysa’s API.

---

## Root Cause

- The original code accepted **0.1°C precision** and **0.5°C step
size**.
- When Home Assistant runs in Fahrenheit, the conversion from °F to °C
produces fractional values that are not valid (e.g., 72°F → 22.22°C).
- Because Mysa enforces strict 0.5°C increments, these fractional
setpoints caused failed updates.

---

## Solution

This update ensures valid behavior when using Fahrenheit mode **while
keeping the current behavior for Celsius**:

- Adds a new environment variable:  
  **`M2M_TEMP_UNIT`** — accepts either:
  - `C` *(default)*
  - `F` *(for Fahrenheit operation)*
- When running in Fahrenheit mode (`M2M_TEMP_UNIT=F`):
  - Celsius values are **rounded and clamped to the nearest 0.5°C**.  
  - Temperature step size and precision are adjusted:
    - Precision → `1°F`
    - Step size → `1°F`
- When running in Celsius mode, existing logic remains unchanged (0.1
precision, 0.5 step).

---

## Technical Summary

| Mode | Env Variable | Precision | Step | Conversion Behavior |
|------|---------------|------------|------|----------------------|
| Celsius | `M2M_TEMP_UNIT=C` (default) | 0.1°C | 0.5°C | Direct
pass-through |
| Fahrenheit | `M2M_TEMP_UNIT=F` | 1°F | 1°F | Convert °F → °C, snap to
0.5°C |

The rounding logic ensures that when a Fahrenheit value (e.g., `72°F`)
is converted to Celsius (`21.72°C`), it is adjusted to the nearest valid
half-degree (`21.5°C` or `22.0°C`).

# Demo


https://github.com/user-attachments/assets/bbffe5fe-a3be-43cb-aed0-f63bdfacb1d4

---------

Co-authored-by: Pascal Bourque <pascal@cosmos.moi>
2025-11-02 10:00:14 -05:00
allcontributors[bot]
4069f24880 docs: add remiolivier as a contributor for code (#82)
Adds @remiolivier as a contributor for code.

This was requested by bourquep [in this
comment](https://github.com/bourquep/mysa2mqtt/pull/72#issuecomment-3476434699)

[skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
Co-authored-by: Pascal Bourque <pascal@cosmos.moi>
2025-11-01 10:55:59 -04:00
Pascal Bourque
a47cdbb45e fix: Don't crash on undefined values returned by the Mysa API (#81) 2025-11-01 10:49:27 -04:00
Pascal Bourque
2e075cd40d fix: Better resilience towards MQTT connection loss and errors (#80) 2025-11-01 09:05:05 -04:00
dependabot[bot]
f2d35a1ca5 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 6 updates (#79) 2025-10-31 20:43:34 +00:00
dependabot[bot]
122ffde2f1 chore(deps): Bump pino from 10.0.0 to 10.1.0 (#76) 2025-10-31 20:38:17 +00:00
dependabot[bot]
c259557da0 chore(deps-dev): Bump typescript-eslint from 8.45.0 to 8.46.2 (#78) 2025-10-31 20:35:35 +00:00
dependabot[bot]
57bd430c98 chore(deps-dev): Bump @eslint/js from 9.37.0 to 9.38.0 (#75) 2025-10-31 20:35:08 +00:00
dependabot[bot]
8ca80acb49 chore(deps): Bump pino-pretty from 13.1.1 to 13.1.2 (#68) 2025-10-31 20:34:55 +00:00
Pascal Bourque
3b5dafeda9 fix: Fatal error when starting realtime updates. (#64)
Via https://github.com/bourquep/mysa-js-sdk/pull/144
2025-10-05 15:08:07 -04:00
Pascal Bourque
35f2effe9c chore(deps): Updated all dependencies to their latest versions (#62) 2025-10-05 11:43:49 -04:00
dependabot[bot]
c29eae97ed chore(deps-dev): Bump the dev-dependencies group with 9 updates (#57) 2025-10-05 15:22:33 +00:00
dependabot[bot]
6502b76b77 chore(deps): Bump pino-pretty from 13.0.0 to 13.1.1 (#55) 2025-10-05 15:09:30 +00:00
dependabot[bot]
39d0e64dc0 chore(deps): Bump axios from 1.9.0 to 1.12.1 in the npm_and_yarn group across 1 directory (#60) 2025-10-05 15:08:44 +00:00
dependabot[bot]
49c7a0fd8e chore(deps-dev): Bump tsx from 4.20.3 to 4.20.6 (#61) 2025-10-05 15:08:29 +00:00
dependabot[bot]
36539b17b1 chore(deps-dev): Bump brace-expansion from 1.1.11 to 1.1.12 in the npm_and_yarn group (#54) 2025-08-29 12:36:55 +00:00
dependabot[bot]
e1bd2e3a91 chore(deps-dev): Bump conventional-changelog-conventionalcommits from 9.0.0 to 9.1.0 (#38) 2025-08-29 12:21:56 +00:00
dependabot[bot]
be163eddca chore(deps): Bump form-data from 4.0.2 to 4.0.4 in the npm_and_yarn group (#43) 2025-08-29 12:21:44 +00:00
dependabot[bot]
8a8ab7ab07 chore(deps): Bump dotenv from 16.5.0 to 17.2.1 (#45) 2025-08-29 12:21:29 +00:00
dependabot[bot]
a2f47220bd chore(deps-dev): Bump @eslint/js from 9.29.0 to 9.34.0 (#52) 2025-08-29 12:21:07 +00:00
dependabot[bot]
ec166cce61 chore(deps-dev): Bump typescript-eslint from 8.35.0 to 8.41.0 (#53) 2025-08-29 12:20:55 +00:00
Pascal Bourque
d2f7c73d84 docs(readme): Updated compatibility matrix for BB-V2 2025-08-25 11:40:39 -04:00
dependabot[bot]
1bfb7e3add chore(deps-dev): Bump typescript-eslint from 8.34.1 to 8.35.0 (#22) 2025-06-25 14:39:05 +00:00
dependabot[bot]
e9f2335c38 chore(deps): Bump mysa-js-sdk from 1.2.0 to 1.3.0 (#20) 2025-06-25 14:36:27 +00:00
dependabot[bot]
96114d2e91 chore(deps-dev): Bump the dev-dependencies group across 1 directory with 3 updates (#16) 2025-06-21 13:39:21 +00:00
dependabot[bot]
21bc257b22 chore(deps-dev): Bump tsx from 4.19.4 to 4.20.3 (#18) 2025-06-21 13:36:54 +00:00
dependabot[bot]
16a82f93f4 chore(deps-dev): Bump @eslint/js from 9.28.0 to 9.29.0 (#17) 2025-06-21 13:35:55 +00:00
dependabot[bot]
a95aee6c27 chore(deps-dev): Bump typescript-eslint from 8.34.0 to 8.34.1 (#19) 2025-06-21 13:33:57 +00:00
dependabot[bot]
20b2866ee4 chore(deps-dev): Bump @types/node from 22.15.30 to 24.0.0 in the dev-dependencies group (#10) 2025-06-10 11:41:44 +00:00
dependabot[bot]
dd23fca857 chore(deps-dev): Bump typescript-eslint from 8.33.1 to 8.34.0 (#11) 2025-06-10 11:41:35 +00:00
Pascal Bourque
374dae1885 feat: Expose device serial number and origin (#7) 2025-06-07 10:58:41 -04:00
Pascal Bourque
2e2e64d2d0 feat: Temperature and humidity sensors (#6) 2025-06-07 09:35:48 -04:00
Pascal Bourque
57502c5fb7 docs(readme): Added a Docker Hub badge to the readme 2025-06-06 13:04:41 -04:00
Pascal Bourque
4895828426 fix(ci): Properly pass release version output to docker job 2025-06-06 12:54:08 -04:00
12 changed files with 2590 additions and 1927 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": "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": "mysa2mqtt",
"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:

View File

@@ -63,6 +63,8 @@ jobs:
if: github.event_name == 'workflow_dispatch' if: github.event_name == 'workflow_dispatch'
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
permissions: permissions:
contents: write # to be able to publish a GitHub release contents: write # to be able to publish a GitHub release
issues: write # to be able to comment on released issues issues: write # to be able to comment on released issues

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
README.md

View File

@@ -1,6 +1,13 @@
# mysa2mqtt # mysa2mqtt
<!-- 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/mysa2mqtt)](https://www.npmjs.com/package/mysa2mqtt) [![NPM Version](https://img.shields.io/npm/v/mysa2mqtt)](https://www.npmjs.com/package/mysa2mqtt)
[![Docker Hub](https://img.shields.io/docker/pulls/bourquep/mysa2mqtt)](https://hub.docker.com/r/bourquep/mysa2mqtt)
[![CodeQL](https://github.com/bourquep/mysa2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/bourquep/mysa2mqtt/actions/workflows/github-code-scanning/codeql) [![CodeQL](https://github.com/bourquep/mysa2mqtt/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/bourquep/mysa2mqtt/actions/workflows/github-code-scanning/codeql)
[![CI: lint, build and release](https://github.com/bourquep/mysa2mqtt/actions/workflows/ci.yml/badge.svg)](https://github.com/bourquep/mysa2mqtt/actions/workflows/ci.yml) [![CI: lint, build and release](https://github.com/bourquep/mysa2mqtt/actions/workflows/ci.yml/badge.svg)](https://github.com/bourquep/mysa2mqtt/actions/workflows/ci.yml)
@@ -17,13 +24,13 @@ home automation platforms.
## Supported hardware ## Supported hardware
| Model Number | Description | Supported | | Model Number | Description | Supported |
| ------------ | --------------------------------------------------------- | ---------------------------------------------------------------- | | ------------ | --------------------------------------------------------- | -------------------------------------------------------------------- |
| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working | | `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working |
| `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Should work but not tested | | `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Partially working, in progress |
| `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Should work but not tested; does not report power consumption | | `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Partially working, in progress; does not report power consumption |
| `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested | | `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested |
| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | 🚫 Not supported (yet) | | `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | 🚫 Not supported (yet) |
## Disclaimer ## Disclaimer
@@ -117,6 +124,11 @@ For development or custom modifications:
The application can be configured using either command-line arguments or environment variables. Environment variables The application can be configured using either command-line arguments or environment variables. Environment variables
take precedence over command-line defaults. take precedence over command-line defaults.
> [!IMPORTANT]
> The `M2M_TEMPERATURE_UNIT` option must match Home Assistant's unit system (Settings → General → Unit System)
> so setpoints and readings are interpreted correctly. If mismatched, climate entities will show incorrect values (e.g.
> 21°C treated as 21°F) and commands may result in unexpected temperatures.
### Required Configuration ### Required Configuration
| CLI Option | Environment Variable | Description | | CLI Option | Environment Variable | Description |
@@ -144,6 +156,7 @@ take precedence over command-line defaults.
| `-l, --log-level` | `M2M_LOG_LEVEL` | `info` | Log level: `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` | | `-l, --log-level` | `M2M_LOG_LEVEL` | `info` | Log level: `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` |
| `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` | | `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` |
| `-s, --mysa-session-file` | `M2M_MYSA_SESSION_FILE` | `session.json` | Path to Mysa session file | | `-s, --mysa-session-file` | `M2M_MYSA_SESSION_FILE` | `session.json` | Path to Mysa session file |
| `-t, --temperature-unit` | `M2M_TEMPERATURE_UNIT` | `C` | Temperature unit (`C` = Celsius, `F` = Fahrenheit) |
## Usage Examples ## Usage Examples
@@ -212,13 +225,11 @@ When using Home Assistant, devices will be automatically discovered and appear i
### Common Issues ### Common Issues
1. **Authentication Failures** 1. **Authentication Failures**
- Verify your Mysa username and password - Verify your Mysa username and password
- Check if session.json exists and is valid - Check if session.json exists and is valid
- Try deleting session.json to force re-authentication - Try deleting session.json to force re-authentication
2. **MQTT Connection Issues** 2. **MQTT Connection Issues**
- Verify MQTT broker hostname and port - Verify MQTT broker hostname and port
- Check MQTT credentials if authentication is required - Check MQTT credentials if authentication is required
- Ensure the MQTT broker is accessible from your network - Ensure the MQTT broker is accessible from your network
@@ -363,7 +374,6 @@ copyright notice and license text in any copy of the software or substantial por
## Acknowledgments ## Acknowledgments
- [mysa-js-sdk](https://github.com/bourquep/mysa-js-sdk) - Mysa API client library - [mysa-js-sdk](https://github.com/bourquep/mysa-js-sdk) - Mysa API client library
- 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.
@@ -371,3 +381,26 @@ copyright notice and license text in any copy of the software or substantial por
- [mqtt2ha](https://github.com/bourquep/mqtt2ha) - MQTT to Home Assistant bridge library - [mqtt2ha](https://github.com/bourquep/mqtt2ha) - MQTT to Home Assistant bridge library
- [Commander.js](https://github.com/tj/commander.js) - Command-line argument parsing - [Commander.js](https://github.com/tj/commander.js) - Command-line argument parsing
- [Pino](https://github.com/pinojs/pino) - Fast JSON logger - [Pino](https://github.com/pinojs/pino) - Fast JSON logger
## 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/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/mysa2mqtt/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!

4243
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,29 +46,29 @@
"build": "tsup" "build": "tsup"
}, },
"dependencies": { "dependencies": {
"commander": "14.0.0", "commander": "14.0.1",
"dotenv": "16.5.0", "dotenv": "17.2.3",
"mqtt2ha": "4.0.0", "mqtt2ha": "4.1.2",
"mysa-js-sdk": "1.1.2", "mysa-js-sdk": "2.0.0",
"pino": "9.7.0", "pino": "10.1.0",
"pino-pretty": "13.0.0" "pino-pretty": "13.1.2"
}, },
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "14.0.0", "@commander-js/extra-typings": "14.0.0",
"@eslint/js": "9.28.0", "@eslint/js": "9.39.0",
"@semantic-release/npm": "12.0.1", "@semantic-release/npm": "13.1.1",
"@types/node": "22.15.30", "@types/node": "24.9.2",
"conventional-changelog-conventionalcommits": "9.0.0", "conventional-changelog-conventionalcommits": "9.1.0",
"eslint": "9.28.0", "eslint": "9.39.0",
"eslint-plugin-jsdoc": "50.7.1", "eslint-plugin-jsdoc": "61.1.11",
"eslint-plugin-tsdoc": "0.4.0", "eslint-plugin-tsdoc": "0.4.0",
"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.1",
"tsup": "8.5.0", "tsup": "8.5.0",
"tsx": "4.19.4", "tsx": "4.20.6",
"typescript": "5.8.3", "typescript": "5.9.3",
"typescript-eslint": "8.33.1" "typescript-eslint": "8.46.2"
} }
} }

View File

@@ -22,7 +22,7 @@ SOFTWARE.
*/ */
import { Logger } from 'mqtt2ha'; import { Logger } from 'mqtt2ha';
import { pino } from 'pino'; import pino from 'pino';
export class PinoLogger implements Logger { export class PinoLogger implements Logger {
constructor(private readonly logger: pino.Logger) {} constructor(private readonly logger: pino.Logger) {}
@@ -32,7 +32,7 @@ export class PinoLogger implements Logger {
if (obj) { if (obj) {
this.logger.debug(obj, message, ...meta); this.logger.debug(obj, message, ...meta);
} else { } else {
this.logger.debug(message, ...meta); this.logger.debug(null, message, ...meta);
} }
} }
@@ -41,7 +41,7 @@ export class PinoLogger implements Logger {
if (obj) { if (obj) {
this.logger.info(obj, message, ...meta); this.logger.info(obj, message, ...meta);
} else { } else {
this.logger.info(message, ...meta); this.logger.info(null, message, ...meta);
} }
} }
@@ -50,7 +50,7 @@ export class PinoLogger implements Logger {
if (obj) { if (obj) {
this.logger.warn(obj, message, ...meta); this.logger.warn(obj, message, ...meta);
} else { } else {
this.logger.warn(message, ...meta); this.logger.warn(null, message, ...meta);
} }
} }
@@ -59,7 +59,7 @@ export class PinoLogger implements Logger {
if (obj) { if (obj) {
this.logger.error(obj, message, ...meta); this.logger.error(obj, message, ...meta);
} else { } else {
this.logger.error(message, ...meta); this.logger.error(null, message, ...meta);
} }
} }
} }

View File

@@ -64,8 +64,24 @@ async function main() {
await client.login(options.mysaUsername, options.mysaPassword); await client.login(options.mysaUsername, options.mysaPassword);
} }
rootLogger.debug('Fetching devices and firmwares...');
const [devices, firmwares] = await Promise.all([client.getDevices(), client.getDeviceFirmwares()]); const [devices, firmwares] = await Promise.all([client.getDevices(), client.getDeviceFirmwares()]);
rootLogger.debug('Fetching serial numbers...');
const serialNumbers = new Map<string, string>();
for (const [deviceId] of Object.entries(devices.DevicesObj)) {
try {
const serial = await client.getDeviceSerialNumber(deviceId);
if (serial) {
serialNumbers.set(deviceId, serial);
}
} catch (error) {
rootLogger.error(error, `Failed to retrieve serial number for device ${deviceId}`);
}
}
rootLogger.debug('Initializing MQTT entities...');
const mqttSettings: MqttSettings = { const mqttSettings: MqttSettings = {
host: options.mqttHost, host: options.mqttHost,
port: options.mqttPort, port: options.mqttPort,
@@ -82,7 +98,9 @@ async function main() {
device, device,
mqttSettings, mqttSettings,
new PinoLogger(rootLogger.child({ module: 'thermostat', deviceId: device.Id })), new PinoLogger(rootLogger.child({ module: 'thermostat', deviceId: device.Id })),
firmwares.Firmware[device.Id] firmwares.Firmware[device.Id],
serialNumbers.get(device.Id),
options.temperatureUnit
) )
); );

View File

@@ -54,7 +54,6 @@ function getPackageVersion(): string {
* *
* @param value - The value to parse. * @param value - The value to parse.
* @returns The parsed integer value. * @returns The parsed integer value.
* @throws InvalidArgumentError if the value is not a valid integer.
*/ */
function parseRequiredInt(value: string) { function parseRequiredInt(value: string) {
const parsedValue = parseInt(value, 10); const parsedValue = parseInt(value, 10);
@@ -64,6 +63,8 @@ function parseRequiredInt(value: string) {
return parsedValue; return parsedValue;
} }
export const version = getPackageVersion();
const extraHelpText = ` const extraHelpText = `
Copyright (c) 2025 Pascal Bourque Copyright (c) 2025 Pascal Bourque
Licensed under the MIT License Licensed under the MIT License
@@ -72,7 +73,7 @@ Source code and documentation available at: https://github.com/bourquep/mysa2mqt
`; `;
export const options = new Command('mysa2mqtt') export const options = new Command('mysa2mqtt')
.version(getPackageVersion()) .version(version)
.description('Expose Mysa smart thermostats to home automation platforms via MQTT.') .description('Expose Mysa smart thermostats to home automation platforms via MQTT.')
.addHelpText('afterAll', extraHelpText) .addHelpText('afterAll', extraHelpText)
.addOption( .addOption(
@@ -142,5 +143,12 @@ export const options = new Command('mysa2mqtt')
.default('mysa2mqtt') .default('mysa2mqtt')
.helpGroup('MQTT') .helpGroup('MQTT')
) )
.addOption(
new Option('--temperature-unit <temperatureUnit>', 'temperature unit (C or F)')
.env('M2M_TEMPERATURE_UNIT')
.choices(['C', 'F'])
.default('C')
.helpGroup('Configuration')
)
.parse() .parse()
.opts(); .opts();

View File

@@ -21,13 +21,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
*/ */
import { Climate, ClimateAction, DeviceConfiguration, Logger, MqttSettings, Sensor } from 'mqtt2ha'; import {
Climate,
ClimateAction,
DeviceConfiguration,
Logger,
MqttSettings,
OriginConfiguration,
Sensor
} from 'mqtt2ha';
import { DeviceBase, FirmwareDevice, MysaApiClient, MysaDeviceMode, StateChange, Status } from 'mysa-js-sdk'; import { DeviceBase, FirmwareDevice, MysaApiClient, MysaDeviceMode, StateChange, Status } from 'mysa-js-sdk';
import { version } from './options';
export class Thermostat { export class Thermostat {
private isStarted = false; private isStarted = false;
private readonly mqttDevice: DeviceConfiguration; private readonly mqttDevice: DeviceConfiguration;
private readonly mqttOrigin: OriginConfiguration;
private readonly mqttClimate: Climate; private readonly mqttClimate: Climate;
private readonly mqttTemperature: Sensor;
private readonly mqttHumidity: Sensor;
private readonly mqttPower: Sensor; private readonly mqttPower: Sensor;
private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this); private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this);
@@ -38,14 +50,25 @@ export class Thermostat {
public readonly mysaDevice: DeviceBase, public readonly mysaDevice: DeviceBase,
private readonly mqttSettings: MqttSettings, private readonly mqttSettings: MqttSettings,
private readonly logger: Logger, private readonly logger: Logger,
public readonly mysaDeviceFirmware?: FirmwareDevice public readonly mysaDeviceFirmware?: FirmwareDevice,
public readonly mysaDeviceSerialNumber?: string,
public readonly temperatureUnit?: 'C' | 'F'
) { ) {
const is_celsius = (temperatureUnit ?? 'C') === 'C';
this.mqttDevice = { this.mqttDevice = {
identifiers: mysaDevice.Id, identifiers: mysaDevice.Id,
name: mysaDevice.Name, name: mysaDevice.Name,
manufacturer: 'Mysa', manufacturer: 'Mysa',
model: mysaDevice.Model, model: mysaDevice.Model,
sw_version: mysaDeviceFirmware?.InstalledVersion sw_version: mysaDeviceFirmware?.InstalledVersion,
serial_number: mysaDeviceSerialNumber
};
this.mqttOrigin = {
name: 'mysa2mqtt',
sw_version: version,
support_url: 'https://github.com/bourquep/mysa2mqtt'
}; };
this.mqttClimate = new Climate( this.mqttClimate = new Climate(
@@ -55,14 +78,15 @@ export class Thermostat {
component: { component: {
component: 'climate', component: 'climate',
device: this.mqttDevice, device: this.mqttDevice,
origin: this.mqttOrigin,
unique_id: `mysa_${mysaDevice.Id}_climate`, unique_id: `mysa_${mysaDevice.Id}_climate`,
name: 'Thermostat', name: 'Thermostat',
min_temp: mysaDevice.MinSetpoint, min_temp: mysaDevice.MinSetpoint,
max_temp: mysaDevice.MaxSetpoint, max_temp: mysaDevice.MaxSetpoint,
modes: ['off', 'heat'], // TODO: AC modes: ['off', 'heat'], // TODO: AC
precision: 0.1, precision: is_celsius ? 0.1 : 1.0,
temp_step: 0.5, temp_step: is_celsius ? 0.5 : 1.0,
temperature_unit: 'C', // TODO: Confirm that Mysa always works in C temperature_unit: 'C',
optimistic: true optimistic: true
} }
}, },
@@ -97,20 +121,66 @@ export class Thermostat {
if (message === '') { if (message === '') {
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, undefined); this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, undefined);
} else { } else {
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, parseFloat(message), undefined); let temperature = parseFloat(message);
if (!is_celsius) {
const snapHalfC = (c: number) => Math.round(c * 2) / 2;
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));
// Snap to 0.5 °C and clamp to device limits
const setC = snapHalfC(temperature);
temperature = clamp(setC, this.mysaDevice.MinSetpoint ?? 0, this.mysaDevice.MaxSetpoint ?? 100);
}
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, temperature, undefined);
} }
break; break;
} }
} }
); );
this.mqttTemperature = new Sensor({
mqtt: this.mqttSettings,
logger: this.logger,
component: {
component: 'sensor',
device: this.mqttDevice,
origin: this.mqttOrigin,
unique_id: `mysa_${mysaDevice.Id}_temperature`,
name: 'Current temperature',
device_class: 'temperature',
state_class: 'measurement',
unit_of_measurement: '°C',
suggested_display_precision: is_celsius ? 0.1 : 0.0,
force_update: true
}
});
this.mqttHumidity = new Sensor({
mqtt: this.mqttSettings,
logger: this.logger,
component: {
component: 'sensor',
device: this.mqttDevice,
origin: this.mqttOrigin,
unique_id: `mysa_${mysaDevice.Id}_humidity`,
name: 'Current humidity',
device_class: 'humidity',
state_class: 'measurement',
unit_of_measurement: '%',
suggested_display_precision: 0,
force_update: true
}
});
this.mqttPower = new Sensor({ this.mqttPower = new Sensor({
mqtt: this.mqttSettings, mqtt: this.mqttSettings,
logger: this.logger, logger: this.logger,
component: { component: {
component: 'sensor', component: 'sensor',
device: this.mqttDevice, device: this.mqttDevice,
origin: this.mqttOrigin,
unique_id: `mysa_${mysaDevice.Id}_power`, unique_id: `mysa_${mysaDevice.Id}_power`,
name: 'Current power',
device_class: 'power', device_class: 'power',
state_class: 'measurement', state_class: 'measurement',
unit_of_measurement: 'W', unit_of_measurement: 'W',
@@ -130,15 +200,24 @@ export class Thermostat {
try { try {
const deviceStates = await this.mysaApiClient.getDeviceStates(); const deviceStates = await this.mysaApiClient.getDeviceStates();
const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id]; const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id];
const tstatMode = state.TstatMode?.v;
this.mqttClimate.currentTemperature = state.CorrectedTemp.v; this.mqttClimate.currentTemperature = state.CorrectedTemp?.v;
this.mqttClimate.currentHumidity = state.Humidity.v; this.mqttClimate.currentHumidity = state.Humidity?.v;
this.mqttClimate.currentMode = state.TstatMode.v === 1 ? 'off' : state.TstatMode.v === 3 ? 'heat' : undefined; this.mqttClimate.currentMode = tstatMode === 1 ? 'off' : tstatMode === 3 ? 'heat' : undefined;
this.mqttClimate.currentAction = this.computeCurrentAction(undefined, state.Duty.v); this.mqttClimate.currentAction = this.computeCurrentAction(undefined, state.Duty?.v);
this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint.v : undefined; this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint?.v : undefined;
await this.mqttClimate.writeConfig(); await this.mqttClimate.writeConfig();
await this.mqttTemperature.setState(
'state_topic',
state.CorrectedTemp != null ? state.CorrectedTemp.v.toFixed(2) : 'None'
);
await this.mqttTemperature.writeConfig();
await this.mqttHumidity.setState('state_topic', state.Humidity != null ? state.Humidity.v.toFixed(2) : 'None');
await this.mqttHumidity.writeConfig();
// `state.Current.v` always has a non-zero value, even for thermostats that are off, so we can't use it to determine initial power state. // `state.Current.v` always has a non-zero value, even for thermostats that are off, so we can't use it to determine initial power state.
await this.mqttPower.setState('state_topic', 'None'); await this.mqttPower.setState('state_topic', 'None');
await this.mqttPower.writeConfig(); await this.mqttPower.writeConfig();
@@ -166,6 +245,8 @@ export class Thermostat {
this.mysaApiClient.emitter.off('stateChanged', this.mysaStateChangeHandler); this.mysaApiClient.emitter.off('stateChanged', this.mysaStateChangeHandler);
await this.mqttPower.setState('state_topic', 'None'); await this.mqttPower.setState('state_topic', 'None');
await this.mqttTemperature.setState('state_topic', 'None');
await this.mqttHumidity.setState('state_topic', 'None');
} }
private async handleMysaStatusUpdate(status: Status) { private async handleMysaStatusUpdate(status: Status) {
@@ -178,12 +259,15 @@ export class Thermostat {
this.mqttClimate.currentHumidity = status.humidity; this.mqttClimate.currentHumidity = status.humidity;
this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? status.setPoint : undefined; this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? status.setPoint : undefined;
if (status.current != null) { if (this.mysaDevice.Voltage != null && status.current != null) {
const watts = this.mysaDevice.Voltage * status.current; const watts = this.mysaDevice.Voltage * status.current;
await this.mqttPower.setState('state_topic', watts.toFixed(2)); await this.mqttPower.setState('state_topic', watts.toFixed(2));
} else { } else {
await this.mqttPower.setState('state_topic', 'None'); await this.mqttPower.setState('state_topic', 'None');
} }
await this.mqttTemperature.setState('state_topic', status.temperature.toFixed(2));
await this.mqttHumidity.setState('state_topic', status.humidity.toFixed(2));
} }
private async handleMysaStateChange(state: StateChange) { private async handleMysaStateChange(state: StateChange) {
@@ -200,6 +284,7 @@ export class Thermostat {
case 'heat': case 'heat':
this.mqttClimate.currentMode = 'heat'; this.mqttClimate.currentMode = 'heat';
this.mqttClimate.targetTemperature = state.setPoint;
break; break;
} }
} }

View File

@@ -8,6 +8,7 @@
"noEmit": true, "noEmit": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ES2022"],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }