mirror of
https://github.com/bourquep/mysa2mqtt.git
synced 2025-10-22 07:28:07 +00:00
Compare commits
26 Commits
v1.0.0
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
![]() |
92e007df2d | ||
![]() |
3b5dafeda9 | ||
![]() |
35f2effe9c | ||
![]() |
c29eae97ed | ||
![]() |
6502b76b77 | ||
![]() |
39d0e64dc0 | ||
![]() |
49c7a0fd8e | ||
![]() |
36539b17b1 | ||
![]() |
e1bd2e3a91 | ||
![]() |
be163eddca | ||
![]() |
8a8ab7ab07 | ||
![]() |
a2f47220bd | ||
![]() |
ec166cce61 | ||
![]() |
d2f7c73d84 | ||
![]() |
1bfb7e3add | ||
![]() |
e9f2335c38 | ||
![]() |
96114d2e91 | ||
![]() |
21bc257b22 | ||
![]() |
16a82f93f4 | ||
![]() |
a95aee6c27 | ||
![]() |
20b2866ee4 | ||
![]() |
dd23fca857 | ||
![]() |
374dae1885 | ||
![]() |
2e2e64d2d0 | ||
![]() |
57502c5fb7 | ||
![]() |
4895828426 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -63,6 +63,8 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
permissions:
|
||||
contents: write # to be able to publish a GitHub release
|
||||
issues: write # to be able to comment on released issues
|
||||
|
18
README.md
18
README.md
@@ -1,6 +1,7 @@
|
||||
# mysa2mqtt
|
||||
|
||||
[](https://www.npmjs.com/package/mysa2mqtt)
|
||||
[](https://hub.docker.com/r/bourquep/mysa2mqtt)
|
||||
[](https://github.com/bourquep/mysa2mqtt/actions/workflows/github-code-scanning/codeql)
|
||||
[](https://github.com/bourquep/mysa2mqtt/actions/workflows/ci.yml)
|
||||
|
||||
@@ -17,13 +18,13 @@ home automation platforms.
|
||||
|
||||
## Supported hardware
|
||||
|
||||
| Model Number | Description | Supported |
|
||||
| ------------ | --------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| `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-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Should work but not tested; does not report power consumption |
|
||||
| `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) |
|
||||
| Model Number | Description | Supported |
|
||||
| ------------ | --------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| `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 | ⚠️ Partially working, in progress |
|
||||
| `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 |
|
||||
| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | 🚫 Not supported (yet) |
|
||||
|
||||
## Disclaimer
|
||||
|
||||
@@ -212,13 +213,11 @@ When using Home Assistant, devices will be automatically discovered and appear i
|
||||
### Common Issues
|
||||
|
||||
1. **Authentication Failures**
|
||||
|
||||
- Verify your Mysa username and password
|
||||
- Check if session.json exists and is valid
|
||||
- Try deleting session.json to force re-authentication
|
||||
|
||||
2. **MQTT Connection Issues**
|
||||
|
||||
- Verify MQTT broker hostname and port
|
||||
- Check MQTT credentials if authentication is required
|
||||
- Ensure the MQTT broker is accessible from your network
|
||||
@@ -363,7 +362,6 @@ copyright notice and license text in any copy of the software or substantial por
|
||||
## Acknowledgments
|
||||
|
||||
- [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
|
||||
[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.
|
||||
|
2298
package-lock.json
generated
2298
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -46,29 +46,29 @@
|
||||
"build": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "14.0.0",
|
||||
"dotenv": "16.5.0",
|
||||
"mqtt2ha": "4.0.0",
|
||||
"mysa-js-sdk": "1.1.2",
|
||||
"pino": "9.7.0",
|
||||
"pino-pretty": "13.0.0"
|
||||
"commander": "14.0.1",
|
||||
"dotenv": "17.2.3",
|
||||
"mqtt2ha": "4.1.2",
|
||||
"mysa-js-sdk": "1.3.3",
|
||||
"pino": "10.0.0",
|
||||
"pino-pretty": "13.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commander-js/extra-typings": "14.0.0",
|
||||
"@eslint/js": "9.28.0",
|
||||
"@semantic-release/npm": "12.0.1",
|
||||
"@types/node": "22.15.30",
|
||||
"conventional-changelog-conventionalcommits": "9.0.0",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-jsdoc": "50.7.1",
|
||||
"@eslint/js": "9.37.0",
|
||||
"@semantic-release/npm": "12.0.2",
|
||||
"@types/node": "24.6.2",
|
||||
"conventional-changelog-conventionalcommits": "9.1.0",
|
||||
"eslint": "9.37.0",
|
||||
"eslint-plugin-jsdoc": "60.8.1",
|
||||
"eslint-plugin-tsdoc": "0.4.0",
|
||||
"prettier": "3.5.3",
|
||||
"prettier-plugin-jsdoc": "1.3.2",
|
||||
"prettier-plugin-organize-imports": "4.1.0",
|
||||
"semantic-release": "24.2.5",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-jsdoc": "1.3.3",
|
||||
"prettier-plugin-organize-imports": "4.3.0",
|
||||
"semantic-release": "24.2.9",
|
||||
"tsup": "8.5.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.33.1"
|
||||
"tsx": "4.20.6",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.45.0"
|
||||
}
|
||||
}
|
||||
|
@@ -22,7 +22,7 @@ SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Logger } from 'mqtt2ha';
|
||||
import { pino } from 'pino';
|
||||
import pino from 'pino';
|
||||
|
||||
export class PinoLogger implements Logger {
|
||||
constructor(private readonly logger: pino.Logger) {}
|
||||
@@ -32,7 +32,7 @@ export class PinoLogger implements Logger {
|
||||
if (obj) {
|
||||
this.logger.debug(obj, message, ...meta);
|
||||
} else {
|
||||
this.logger.debug(message, ...meta);
|
||||
this.logger.debug(null, message, ...meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class PinoLogger implements Logger {
|
||||
if (obj) {
|
||||
this.logger.info(obj, message, ...meta);
|
||||
} else {
|
||||
this.logger.info(message, ...meta);
|
||||
this.logger.info(null, message, ...meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export class PinoLogger implements Logger {
|
||||
if (obj) {
|
||||
this.logger.warn(obj, message, ...meta);
|
||||
} else {
|
||||
this.logger.warn(message, ...meta);
|
||||
this.logger.warn(null, message, ...meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export class PinoLogger implements Logger {
|
||||
if (obj) {
|
||||
this.logger.error(obj, message, ...meta);
|
||||
} else {
|
||||
this.logger.error(message, ...meta);
|
||||
this.logger.error(null, message, ...meta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
19
src/main.ts
19
src/main.ts
@@ -64,8 +64,24 @@ async function main() {
|
||||
await client.login(options.mysaUsername, options.mysaPassword);
|
||||
}
|
||||
|
||||
rootLogger.debug('Fetching devices and firmwares...');
|
||||
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 = {
|
||||
host: options.mqttHost,
|
||||
port: options.mqttPort,
|
||||
@@ -82,7 +98,8 @@ async function main() {
|
||||
device,
|
||||
mqttSettings,
|
||||
new PinoLogger(rootLogger.child({ module: 'thermostat', deviceId: device.Id })),
|
||||
firmwares.Firmware[device.Id]
|
||||
firmwares.Firmware[device.Id],
|
||||
serialNumbers.get(device.Id)
|
||||
)
|
||||
);
|
||||
|
||||
|
@@ -54,7 +54,6 @@ function getPackageVersion(): string {
|
||||
*
|
||||
* @param value - The value to parse.
|
||||
* @returns The parsed integer value.
|
||||
* @throws InvalidArgumentError if the value is not a valid integer.
|
||||
*/
|
||||
function parseRequiredInt(value: string) {
|
||||
const parsedValue = parseInt(value, 10);
|
||||
@@ -64,6 +63,8 @@ function parseRequiredInt(value: string) {
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
export const version = getPackageVersion();
|
||||
|
||||
const extraHelpText = `
|
||||
Copyright (c) 2025 Pascal Bourque
|
||||
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')
|
||||
.version(getPackageVersion())
|
||||
.version(version)
|
||||
.description('Expose Mysa smart thermostats to home automation platforms via MQTT.')
|
||||
.addHelpText('afterAll', extraHelpText)
|
||||
.addOption(
|
||||
|
@@ -21,13 +21,25 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
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 { version } from './options';
|
||||
|
||||
export class Thermostat {
|
||||
private isStarted = false;
|
||||
private readonly mqttDevice: DeviceConfiguration;
|
||||
private readonly mqttOrigin: OriginConfiguration;
|
||||
private readonly mqttClimate: Climate;
|
||||
private readonly mqttTemperature: Sensor;
|
||||
private readonly mqttHumidity: Sensor;
|
||||
private readonly mqttPower: Sensor;
|
||||
|
||||
private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this);
|
||||
@@ -38,14 +50,22 @@ export class Thermostat {
|
||||
public readonly mysaDevice: DeviceBase,
|
||||
private readonly mqttSettings: MqttSettings,
|
||||
private readonly logger: Logger,
|
||||
public readonly mysaDeviceFirmware?: FirmwareDevice
|
||||
public readonly mysaDeviceFirmware?: FirmwareDevice,
|
||||
public readonly mysaDeviceSerialNumber?: string
|
||||
) {
|
||||
this.mqttDevice = {
|
||||
identifiers: mysaDevice.Id,
|
||||
name: mysaDevice.Name,
|
||||
manufacturer: 'Mysa',
|
||||
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(
|
||||
@@ -55,6 +75,7 @@ export class Thermostat {
|
||||
component: {
|
||||
component: 'climate',
|
||||
device: this.mqttDevice,
|
||||
origin: this.mqttOrigin,
|
||||
unique_id: `mysa_${mysaDevice.Id}_climate`,
|
||||
name: 'Thermostat',
|
||||
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({
|
||||
mqtt: this.mqttSettings,
|
||||
logger: this.logger,
|
||||
component: {
|
||||
component: 'sensor',
|
||||
device: this.mqttDevice,
|
||||
origin: this.mqttOrigin,
|
||||
unique_id: `mysa_${mysaDevice.Id}_power`,
|
||||
name: 'Current power',
|
||||
device_class: 'power',
|
||||
state_class: 'measurement',
|
||||
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.currentAction = this.computeCurrentAction(undefined, state.Duty.v);
|
||||
this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint.v : undefined;
|
||||
|
||||
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.
|
||||
await this.mqttPower.setState('state_topic', 'None');
|
||||
await this.mqttPower.writeConfig();
|
||||
@@ -166,6 +228,8 @@ export class Thermostat {
|
||||
this.mysaApiClient.emitter.off('stateChanged', this.mysaStateChangeHandler);
|
||||
|
||||
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) {
|
||||
@@ -184,6 +248,9 @@ export class Thermostat {
|
||||
} else {
|
||||
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) {
|
||||
@@ -200,6 +267,7 @@ export class Thermostat {
|
||||
|
||||
case 'heat':
|
||||
this.mqttClimate.currentMode = 'heat';
|
||||
this.mqttClimate.targetTemperature = state.setPoint;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@@ -8,6 +8,7 @@
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
Reference in New Issue
Block a user