18 Commits

Author SHA1 Message Date
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
6 changed files with 481 additions and 311 deletions

View File

@@ -1,6 +1,7 @@
# mysa2mqtt # mysa2mqtt
[![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 +18,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

658
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,28 +47,28 @@
}, },
"dependencies": { "dependencies": {
"commander": "14.0.0", "commander": "14.0.0",
"dotenv": "16.5.0", "dotenv": "17.2.1",
"mqtt2ha": "4.0.0", "mqtt2ha": "4.1.0",
"mysa-js-sdk": "1.1.2", "mysa-js-sdk": "1.3.0",
"pino": "9.7.0", "pino": "9.7.0",
"pino-pretty": "13.0.0" "pino-pretty": "13.0.0"
}, },
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "14.0.0", "@commander-js/extra-typings": "14.0.0",
"@eslint/js": "9.28.0", "@eslint/js": "9.34.0",
"@semantic-release/npm": "12.0.1", "@semantic-release/npm": "12.0.1",
"@types/node": "22.15.30", "@types/node": "24.0.3",
"conventional-changelog-conventionalcommits": "9.0.0", "conventional-changelog-conventionalcommits": "9.1.0",
"eslint": "9.28.0", "eslint": "9.29.0",
"eslint-plugin-jsdoc": "50.7.1", "eslint-plugin-jsdoc": "51.1.1",
"eslint-plugin-tsdoc": "0.4.0", "eslint-plugin-tsdoc": "0.4.0",
"prettier": "3.5.3", "prettier": "3.5.3",
"prettier-plugin-jsdoc": "1.3.2", "prettier-plugin-jsdoc": "1.3.2",
"prettier-plugin-organize-imports": "4.1.0", "prettier-plugin-organize-imports": "4.1.0",
"semantic-release": "24.2.5", "semantic-release": "24.2.5",
"tsup": "8.5.0", "tsup": "8.5.0",
"tsx": "4.19.4", "tsx": "4.20.3",
"typescript": "5.8.3", "typescript": "5.8.3",
"typescript-eslint": "8.33.1" "typescript-eslint": "8.41.0"
} }
} }

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(`Failed to retrieve serial number for device ${deviceId}`, error);
}
}
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,8 @@ 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)
) )
); );

View File

@@ -64,6 +64,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 +74,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(

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,22 @@ 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
) { ) {
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,6 +75,7 @@ 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,
@@ -104,13 +125,49 @@ export class Thermostat {
} }
); );
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: 1,
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',
@@ -136,9 +193,14 @@ export class Thermostat {
this.mqttClimate.currentMode = state.TstatMode.v === 1 ? 'off' : state.TstatMode.v === 3 ? 'heat' : undefined; this.mqttClimate.currentMode = state.TstatMode.v === 1 ? 'off' : state.TstatMode.v === 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.v.toFixed(2));
await this.mqttTemperature.writeConfig();
await this.mqttHumidity.setState('state_topic', state.Humidity.v.toFixed(2));
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 +228,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) {
@@ -184,6 +248,9 @@ export class Thermostat {
} 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 +267,7 @@ export class Thermostat {
case 'heat': case 'heat':
this.mqttClimate.currentMode = 'heat'; this.mqttClimate.currentMode = 'heat';
this.mqttClimate.targetTemperature = state.setPoint;
break; break;
} }
} }