mysa2mqtt 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # mysa2mqtt
2
+
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#183](https://github.com/bourquep/mysa2mqtt/pull/183) [`604a3b7`](https://github.com/bourquep/mysa2mqtt/commit/604a3b7df903d09f672b5fe30bacd663d1e9fe1f) Thanks [@vavallee](https://github.com/vavallee)! - Added `--heartbeat-file` / `M2M_HEARTBEAT_FILE`: when set, mysa2mqtt touches the given file on every message received from the Mysa cloud (throttled to one write per 10 seconds). External supervisors can watch the file's mtime to detect a wedged cloud connection and restart the process — for example a Kubernetes exec liveness probe checking that the file is fresher than 15 minutes.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#186](https://github.com/bourquep/mysa2mqtt/pull/186) [`ed84637`](https://github.com/bourquep/mysa2mqtt/commit/ed846373e866625f5c74ca8e98d110954595515b) Thanks [@vavallee](https://github.com/vavallee)! - Apply state changes that arrive without an operating mode instead of silently dropping them. Home Assistant no longer shows a stale target temperature (or fan speed) when Mysa pushes a modeless update.
12
+
13
+ - Updated dependencies [[`7affd92`](https://github.com/bourquep/mysa2mqtt/commit/7affd92614ee6f8ac160afacae7c7ea1c3a2a9e9), [`21991c0`](https://github.com/bourquep/mysa2mqtt/commit/21991c0731cb888dc69d15b3b0dc164aee4992f7)]:
14
+ - mqtt2ha@4.1.5
15
+ - mysa-js-sdk@2.1.2
16
+
17
+ ## 1.2.4
18
+
19
+ ### Patch Changes
20
+
21
+ - [#149](https://github.com/bourquep/mysa2mqtt/pull/149) [`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4) Thanks [@bourquep](https://github.com/bourquep)! - Moved development into the [mysa2mqtt monorepo](https://github.com/bourquep/mysa2mqtt).
22
+
23
+ There are no functional changes in this release. The package's repository and homepage links now point at the monorepo, and issues for all three packages are tracked at https://github.com/bourquep/mysa2mqtt/issues.
24
+
25
+ - Updated dependencies [[`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4)]:
26
+ - mysa-js-sdk@2.1.1
27
+ - mqtt2ha@4.1.4
28
+
29
+ ## Releases prior to 1.2.3
30
+
31
+ This package previously lived in a standalone repository and used semantic-release, which published its release notes to GitHub Releases rather than to a changelog file.
32
+
33
+ See the [release history](https://github.com/bourquep/mysa2mqtt/releases) for notes on versions up to and including 1.2.3. Those releases are tagged `v1.2.3`; releases from the monorepo onwards are tagged `mysa2mqtt@<version>`.
package/README.md CHANGED
@@ -1,9 +1,5 @@
1
1
  # mysa2mqtt
2
2
 
3
- <!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
4
- [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors-)
5
- <!-- ALL-CONTRIBUTORS-BADGE:END -->
6
-
7
3
  [![NPM Version](https://img.shields.io/npm/v/mysa2mqtt)](https://www.npmjs.com/package/mysa2mqtt)
8
4
  [![Docker Hub](https://img.shields.io/docker/pulls/bourquep/mysa2mqtt)](https://hub.docker.com/r/bourquep/mysa2mqtt)
9
5
  [![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)
@@ -160,6 +156,7 @@ take precedence over command-line defaults.
160
156
  | `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` |
161
157
  | `-s, --mysa-session-file` | `M2M_MYSA_SESSION_FILE` | `session.json` | Path to Mysa session file |
162
158
  | `-t, --temperature-unit` | `M2M_TEMPERATURE_UNIT` | `C` | Temperature unit (`C` = Celsius, `F` = Fahrenheit) |
159
+ | `--heartbeat-file` | `M2M_HEARTBEAT_FILE` | - | File touched on every message received from the Mysa cloud, for external liveness checks (e.g. a container liveness probe on its mtime) |
163
160
 
164
161
  ## Usage Examples
165
162
 
@@ -295,7 +292,7 @@ docker run -d --name mysa2mqtt \
295
292
  If you prefer to build your own image, create a `Dockerfile`:
296
293
 
297
294
  ```dockerfile
298
- FROM node:22-alpine
295
+ FROM node:24-alpine
299
296
 
300
297
  WORKDIR /app
301
298
 
@@ -325,7 +322,7 @@ docker run -d --name mysa2mqtt \
325
322
  -e M2M_MQTT_HOST=your-mqtt-broker \
326
323
  -e M2M_MYSA_USERNAME=your-email \
327
324
  -e M2M_MYSA_PASSWORD=your-password \
328
- node:22-alpine \
325
+ node:24-alpine \
329
326
  sh -c "npm install -g mysa2mqtt && mysa2mqtt"
330
327
  ```
331
328
 
@@ -356,7 +353,7 @@ docker-compose up -d
356
353
 
357
354
  ## Contributing
358
355
 
359
- If you want to contribute to this project, please read the [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines.
356
+ If you want to contribute to this project, please read the [CONTRIBUTING.md](../../CONTRIBUTING.md) file for guidelines.
360
357
 
361
358
  ## License
362
359
 
@@ -376,35 +373,17 @@ copyright notice and license text in any copy of the software or substantial por
376
373
 
377
374
  ## Acknowledgments
378
375
 
379
- - [mysa-js-sdk](https://github.com/bourquep/mysa-js-sdk) - Mysa API client library
376
+ - [mysa-js-sdk](https://github.com/bourquep/mysa2mqtt/tree/main/packages/mysa-js-sdk) - Mysa API client library
380
377
  - This library would not be possible without the amazing work by [@dlenski](https://github.com/dlenski) in his
381
378
  [mysotherm](https://github.com/dlenski/mysotherm) repository. He's the one who reversed-engineered the Mysa MQTT
382
379
  protocol which is being used by this library.
383
380
 
384
- - [mqtt2ha](https://github.com/bourquep/mqtt2ha) - MQTT to Home Assistant bridge library
381
+ - [mqtt2ha](https://github.com/bourquep/mysa2mqtt/tree/main/packages/mqtt2ha) - MQTT to Home Assistant bridge library
385
382
  - [Commander.js](https://github.com/tj/commander.js) - Command-line argument parsing
386
383
  - [Pino](https://github.com/pinojs/pino) - Fast JSON logger
387
384
 
388
385
  ## Contributors ✨
389
386
 
390
- Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
391
-
392
- <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
393
- <!-- prettier-ignore-start -->
394
- <!-- markdownlint-disable -->
395
- <table>
396
- <tbody>
397
- <tr>
398
- <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>
399
- <td align="center" valign="top" width="14.28%"><a href="https://github.com/Terwox"><img src="https://avatars.githubusercontent.com/u/17753313?v=4?s=100" width="100px;" alt="James Myers"/><br /><sub><b>James Myers</b></sub></a><br /><a href="https://github.com/bourquep/mysa2mqtt/commits?author=Terwox" title="Code">💻</a></td>
400
- </tr>
401
- </tbody>
402
- </table>
403
-
404
- <!-- markdownlint-restore -->
405
- <!-- prettier-ignore-end -->
406
-
407
- <!-- ALL-CONTRIBUTORS-LIST:END -->
408
-
409
- This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification.
410
- Contributions of any kind welcome!
387
+ This project follows the [all-contributors](https://github.com/all-contributors/all-contributors)
388
+ specification. See the [contributor list](https://github.com/bourquep/mysa2mqtt#contributors-) in the
389
+ repository README. Contributions of any kind welcome!
package/dist/main.js CHANGED
@@ -22,9 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
22
  SOFTWARE.
23
23
  */
24
24
 
25
- import{MysaApiClient as Z}from"mysa-js-sdk";import{pino as tt}from"pino";var g=class{constructor(t){this.logger=t}logger;debug(t,...e){let i=e.at(0);i?this.logger.debug(i,t,...e):this.logger.debug(null,t,...e)}info(t,...e){let i=e.at(0);i?this.logger.info(i,t,...e):this.logger.info(null,t,...e)}warn(t,...e){let i=e.at(0);i?this.logger.warn(i,t,...e):this.logger.warn(null,t,...e)}error(t,...e){let i=e.at(0);i?this.logger.error(i,t,...e):this.logger.error(null,t,...e)}};import{Command as L,InvalidArgumentError as k,Option as m}from"commander";import{configDotenv as H}from"dotenv";import{readFileSync as U}from"fs";import{dirname as G,join as Q}from"path";import{fileURLToPath as Y}from"url";H({path:[".env",".env.local"],override:!0});function j(){try{let a=Y(import.meta.url),t=G(a),e=Q(t,"..","package.json");return JSON.parse(U(e,"utf-8")).version||"unknown"}catch{return"unknown"}}function B(a){let t=parseInt(a,10);if(isNaN(t))throw new k("Must be a number.");return t}var v=j(),$=`
25
+ import{writeFile as Z}from"fs/promises";import{MysaApiClient as tt}from"mysa-js-sdk";import{pino as et}from"pino";var g=class{constructor(t){this.logger=t}logger;debug(t,...e){let i=e.at(0);i?this.logger.debug(i,t,...e):this.logger.debug(null,t,...e)}info(t,...e){let i=e.at(0);i?this.logger.info(i,t,...e):this.logger.info(null,t,...e)}warn(t,...e){let i=e.at(0);i?this.logger.warn(i,t,...e):this.logger.warn(null,t,...e)}error(t,...e){let i=e.at(0);i?this.logger.error(i,t,...e):this.logger.error(null,t,...e)}};import{Command as L,InvalidArgumentError as k,Option as c}from"commander";import{configDotenv as H}from"dotenv";import{readFileSync as U}from"fs";import{dirname as G,join as Q}from"path";import{fileURLToPath as Y}from"url";H({path:[".env",".env.local"],override:!0});function j(){try{let a=Y(import.meta.url),t=G(a),e=Q(t,"..","package.json");return JSON.parse(U(e,"utf-8")).version||"unknown"}catch{return"unknown"}}function B(a){let t=parseInt(a,10);if(isNaN(t))throw new k("Must be a number.");return t}var v=j(),$=`
26
26
  Copyright (c) 2025 Pascal Bourque
27
27
  Licensed under the MIT License
28
28
 
29
29
  Source code and documentation available at: https://github.com/bourquep/mysa2mqtt
30
- `,s=new L("mysa2mqtt").version(v).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",$).addOption(new m("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new m("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new m("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new m("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(B).default(1883).helpGroup("MQTT")).addOption(new m("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new m("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new m("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new m("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new m("-s, --mysa-session-file <mysaSessionFile>","Mysa session file").env("M2M_MYSA_SESSION_FILE").default("session.json").helpGroup("Configuration")).addOption(new m("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new m("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new m("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).parse().opts();import{readFile as J,rm as V,writeFile as X}from"fs/promises";async function w(a,t){try{t.info("Loading Mysa session...");let e=await J(a,"utf8");return JSON.parse(e)}catch{t.info("No valid Mysa session file found.")}}async function q(a,t,e){if(a)e.info("Saving Mysa session..."),await X(t,JSON.stringify(a));else try{e.debug("Removing Mysa session file..."),await V(t)}catch{}}import{Climate as W,Sensor as S}from"mqtt2ha";var C=["off","heat"],T=["off","heat","cool","dry","fan_only","auto"],z={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},A=["auto","low","medium","high","max"],K={1:"auto",3:"low",5:"medium",7:"high",8:"max"},D=3e4,b=3e5,R=Math.ceil(Math.log2(b/D)),_=class{constructor(t,e,i,c,l,u,p){this.mysaApiClient=t;this.mysaDevice=e;this.mqttSettings=i;this.logger=c;this.mysaDeviceFirmware=l;this.mysaDeviceSerialNumber=u;this.temperatureUnit=p;let o=(p??"C")==="C";this.mqttDevice={identifiers:e.Id,name:e.Name,manufacturer:"Mysa",model:e.Model,sw_version:l==null?void 0:l.InstalledVersion,serial_number:u},this.mqttOrigin={name:"mysa2mqtt",sw_version:v,support_url:"https://github.com/bourquep/mysa2mqtt"};let r=e.Model.startsWith("AC");this.deviceType=r?"AC":"BB",this.mqttClimate=new W({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_climate`,name:"Thermostat",min_temp:e.MinSetpoint,max_temp:e.MaxSetpoint,modes:r?T:C,fan_modes:r?A:void 0,precision:o?.1:1,temp_step:o?.5:1,temperature_unit:"C",optimistic:!0}},r?["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic","fan_mode_state_topic"]:["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic"],async()=>{},r?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(h,y)=>{switch(h){case"mode_command_topic":{let d=y,f=r?T.includes(d)?d:void 0:C.includes(d)?d:void 0;await this.setDeviceState(void 0,f);break}case"power_command_topic":await this.setDeviceState(void 0,y==="OFF"?"off":y==="ON"&&!r?"heat":void 0);break;case"temperature_command_topic":if(y==="")await this.setDeviceState(void 0,void 0);else{let d=parseFloat(y);if(!o){let f=M=>Math.round(M*2)/2,F=(M,N,x)=>Math.min(x,Math.max(N,M)),P=f(d);d=F(P,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(d,void 0)}break;case"fan_mode_command_topic":{let d=y,f=A.includes(d)?d:void 0;await this.setDeviceState(void 0,void 0,f);break}}}),this.mqttTemperature=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:o?.1:0,force_update:!0}}),this.mqttHumidity=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}}),this.mqttPower=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}})}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttHumidity;mqttPower;mysaStatusUpdateHandler=t=>{this.handleMysaStatusUpdate(t).catch(e=>{this.logger.error("Failed to handle Mysa status update",{error:e,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=t=>{this.handleMysaStateChange(t).catch(e=>{this.logger.error("Failed to handle Mysa state change",{error:e,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var t,e,i,c,l,u;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let o=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];this.mqttClimate.currentTemperature=(t=o.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(e=o.Humidity)==null?void 0:e.v,this.mqttClimate.currentMode=z[(i=o.TstatMode)==null?void 0:i.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=K[(c=o.FanSpeed)==null?void 0:c.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(l=o.Duty)==null?void 0:l.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(u=o.SetPoint)==null?void 0:u.v:void 0,await this.mqttClimate.writeConfig(),await this.mqttTemperature.setState("state_topic",o.CorrectedTemp!=null?o.CorrectedTemp.v.toFixed(2):"None"),await this.mqttTemperature.writeConfig(),await this.mqttHumidity.setState("state_topic",o.Humidity!=null?o.Humidity.v.toFixed(2):"None"),await this.mqttHumidity.writeConfig(),await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig(),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(p){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),p}}}async stop(){this.isStarted&&(this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),await this.stopRealtimeUpdates(),this.mysaApiClient.emitter.off("statusChanged",this.mysaStatusUpdateHandler),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"))}async setDeviceState(t,e,i){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,t,e,i)}catch(c){this.logger.error("Failed to update Mysa device state",{error:c,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let t=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||t!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(e){this.isStarted&&t===this.realtimeGeneration&&this.scheduleRealtimeRetry(e)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(t){this.logger.warn("Failed to stop realtime updates",{error:t,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(t){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let e=Math.min(this.realtimeRetryAttempt,R),i=Math.min(b,D*2**e);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,R),this.logger.error("Failed to start realtime updates; retrying",{error:t,deviceId:this.mysaDevice.Id,retryDelayMs:i}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},i)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(t.current,t.dutyCycle),this.mqttClimate.currentTemperature=t.temperature,this.mqttClimate.currentHumidity=t.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?t.setPoint:void 0,this.mysaDevice.Voltage!=null&&t.current!=null){let e=this.mysaDevice.Voltage*t.current;await this.mqttPower.setState("state_topic",e.toFixed(2))}else await this.mqttPower.setState("state_topic","None");await this.mqttTemperature.setState("state_topic",t.temperature.toFixed(2)),await this.mqttHumidity.setState("state_topic",t.humidity.toFixed(2))}}async handleMysaStateChange(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id))switch(t.mode){case"off":this.mqttClimate.currentMode="off",this.mqttClimate.currentAction="off",this.mqttClimate.targetTemperature=void 0,this.mqttClimate.currentFanMode=void 0;break;case"heat":case"cool":case"auto":this.mqttClimate.currentMode=t.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=t.setPoint,this.mqttClimate.currentFanMode=t.fanSpeed;break;case"dry":case"fan_only":this.mqttClimate.currentMode=t.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),this.mqttClimate.currentFanMode=t.fanSpeed;break}}computeCurrentAction(t,e){let i=this.mqttClimate.currentMode;switch(T.includes(i)?i:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?t!=null?t>0?"heating":"idle":(e??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var I=3e4,E=3e5,et=Math.ceil(Math.log2(E/I)),n=tt({name:"mysa2mqtt",level:s.logLevel,transport:s.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function it(){n.info("Starting mysa2mqtt...");let a=await w(s.mysaSessionFile,n),t=new Z(a,{logger:new g(n.child({module:"mysa-js-sdk"}))});t.emitter.on("sessionChanged",async r=>{await q(r,s.mysaSessionFile,n)}),t.isAuthenticated||(n.info("Logging in..."),await t.login(s.mysaUsername,s.mysaPassword)),n.debug("Fetching devices and firmwares...");let[e,i]=await Promise.all([t.getDevices(),t.getDeviceFirmwares()]);n.debug("Fetching serial numbers...");let c=new Map;for(let[r]of Object.entries(e.DevicesObj))try{let h=await t.getDeviceSerialNumber(r);h&&c.set(r,h)}catch(h){n.error(h,`Failed to retrieve serial number for device ${r}`)}n.debug("Initializing MQTT entities...");let l={host:s.mqttHost,port:s.mqttPort,username:s.mqttUsername,password:s.mqttPassword,client_name:s.mqttClientName,state_prefix:s.mqttTopicPrefix},u=Object.entries(e.DevicesObj).map(([,r])=>new _(t,r,l,new g(n.child({module:"thermostat",deviceId:r.Id})),i.Firmware[r.Id],c.get(r.Id),s.temperatureUnit)),p=0,o=[];for(let r of u)await at(r)?p+=1:o.push(r);if(u.length>0&&p===0)throw new Error("Failed to start any thermostats");for(let r of o)O(r)}async function at(a){try{return await a.start(),!0}catch(t){return n.error(t,`Failed to start thermostat ${a.mysaDevice.Id}`),!1}}function O(a,t=0){let e=Math.min(t,et),i=Math.min(E,I*2**e);n.info(`Retrying thermostat ${a.mysaDevice.Id} startup in ${i}ms`),setTimeout(()=>{rt(a,t+1)},i)}async function rt(a,t){try{await a.start(),n.info(`Started thermostat ${a.mysaDevice.Id} after retry`)}catch(e){n.error(e,`Failed to start thermostat ${a.mysaDevice.Id}`),O(a,t)}}it().catch(a=>{n.fatal(a,"Unexpected error"),process.exit(1)});
30
+ `,m=new L("mysa2mqtt").version(v).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",$).addOption(new c("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new c("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new c("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new c("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(B).default(1883).helpGroup("MQTT")).addOption(new c("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new c("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new c("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new c("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new c("-s, --mysa-session-file <mysaSessionFile>","Mysa session file").env("M2M_MYSA_SESSION_FILE").default("session.json").helpGroup("Configuration")).addOption(new c("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new c("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new c("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).addOption(new c("--heartbeat-file <heartbeatFile>","file touched on every message received from the Mysa cloud, for external liveness checks").env("M2M_HEARTBEAT_FILE").helpGroup("Configuration")).parse().opts();import{readFile as J,rm as V,writeFile as X}from"fs/promises";async function T(a,t){try{t.info("Loading Mysa session...");let e=await J(a,"utf8");return JSON.parse(e)}catch{t.info("No valid Mysa session file found.")}}async function q(a,t,e){if(a)e.info("Saving Mysa session..."),await X(t,JSON.stringify(a));else try{e.debug("Removing Mysa session file..."),await V(t)}catch{}}import{Climate as W,Sensor as S}from"mqtt2ha";var C=["off","heat"],w=["off","heat","cool","dry","fan_only","auto"],z={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},A=["auto","low","medium","high","max"],K={1:"auto",3:"low",5:"medium",7:"high",8:"max"},b=3e4,D=3e5,R=Math.ceil(Math.log2(D/b)),_=class{constructor(t,e,i,p,u,f,h){this.mysaApiClient=t;this.mysaDevice=e;this.mqttSettings=i;this.logger=p;this.mysaDeviceFirmware=u;this.mysaDeviceSerialNumber=f;this.temperatureUnit=h;let n=(h??"C")==="C";this.mqttDevice={identifiers:e.Id,name:e.Name,manufacturer:"Mysa",model:e.Model,sw_version:u==null?void 0:u.InstalledVersion,serial_number:f},this.mqttOrigin={name:"mysa2mqtt",sw_version:v,support_url:"https://github.com/bourquep/mysa2mqtt"};let l=e.Model.startsWith("AC");this.deviceType=l?"AC":"BB",this.mqttClimate=new W({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_climate`,name:"Thermostat",min_temp:e.MinSetpoint,max_temp:e.MaxSetpoint,modes:l?w:C,fan_modes:l?A:void 0,precision:n?.1:1,temp_step:n?.5:1,temperature_unit:"C",optimistic:!0}},l?["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic","fan_mode_state_topic"]:["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic"],async()=>{},l?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(r,s)=>{switch(r){case"mode_command_topic":{let d=s,y=l?w.includes(d)?d:void 0:C.includes(d)?d:void 0;await this.setDeviceState(void 0,y);break}case"power_command_topic":await this.setDeviceState(void 0,s==="OFF"?"off":s==="ON"&&!l?"heat":void 0);break;case"temperature_command_topic":if(s==="")await this.setDeviceState(void 0,void 0);else{let d=parseFloat(s);if(!n){let y=M=>Math.round(M*2)/2,F=(M,N,x)=>Math.min(x,Math.max(N,M)),P=y(d);d=F(P,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(d,void 0)}break;case"fan_mode_command_topic":{let d=s,y=A.includes(d)?d:void 0;await this.setDeviceState(void 0,void 0,y);break}}}),this.mqttTemperature=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:n?.1:0,force_update:!0}}),this.mqttHumidity=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}}),this.mqttPower=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}})}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttHumidity;mqttPower;mysaStatusUpdateHandler=t=>{this.handleMysaStatusUpdate(t).catch(e=>{this.logger.error("Failed to handle Mysa status update",{error:e,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=t=>{this.handleMysaStateChange(t).catch(e=>{this.logger.error("Failed to handle Mysa state change",{error:e,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var t,e,i,p,u,f;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let n=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];this.mqttClimate.currentTemperature=(t=n.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(e=n.Humidity)==null?void 0:e.v,this.mqttClimate.currentMode=z[(i=n.TstatMode)==null?void 0:i.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=K[(p=n.FanSpeed)==null?void 0:p.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(u=n.Duty)==null?void 0:u.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(f=n.SetPoint)==null?void 0:f.v:void 0,await this.mqttClimate.writeConfig(),await this.mqttTemperature.setState("state_topic",n.CorrectedTemp!=null?n.CorrectedTemp.v.toFixed(2):"None"),await this.mqttTemperature.writeConfig(),await this.mqttHumidity.setState("state_topic",n.Humidity!=null?n.Humidity.v.toFixed(2):"None"),await this.mqttHumidity.writeConfig(),await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig(),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(h){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),h}}}async stop(){this.isStarted&&(this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),await this.stopRealtimeUpdates(),this.mysaApiClient.emitter.off("statusChanged",this.mysaStatusUpdateHandler),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"))}async setDeviceState(t,e,i){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,t,e,i)}catch(p){this.logger.error("Failed to update Mysa device state",{error:p,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let t=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||t!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(e){this.isStarted&&t===this.realtimeGeneration&&this.scheduleRealtimeRetry(e)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(t){this.logger.warn("Failed to stop realtime updates",{error:t,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(t){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let e=Math.min(this.realtimeRetryAttempt,R),i=Math.min(D,b*2**e);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,R),this.logger.error("Failed to start realtime updates; retrying",{error:t,deviceId:this.mysaDevice.Id,retryDelayMs:i}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},i)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(t.current,t.dutyCycle),this.mqttClimate.currentTemperature=t.temperature,this.mqttClimate.currentHumidity=t.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?t.setPoint:void 0,this.mysaDevice.Voltage!=null&&t.current!=null){let e=this.mysaDevice.Voltage*t.current;await this.mqttPower.setState("state_topic",e.toFixed(2))}else await this.mqttPower.setState("state_topic","None");await this.mqttTemperature.setState("state_topic",t.temperature.toFixed(2)),await this.mqttHumidity.setState("state_topic",t.humidity.toFixed(2))}}async handleMysaStateChange(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id))switch(t.mode){case"off":this.mqttClimate.currentMode="off",this.mqttClimate.currentAction="off",this.mqttClimate.targetTemperature=void 0,this.mqttClimate.currentFanMode=void 0;break;case"heat":case"cool":case"auto":this.mqttClimate.currentMode=t.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=t.setPoint,this.mqttClimate.currentFanMode=t.fanSpeed;break;case"dry":case"fan_only":this.mqttClimate.currentMode=t.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),this.mqttClimate.currentFanMode=t.fanSpeed;break;default:this.mqttClimate.currentMode!=="off"&&(this.mqttClimate.targetTemperature=t.setPoint),t.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=t.fanSpeed);break}}computeCurrentAction(t,e){let i=this.mqttClimate.currentMode;switch(w.includes(i)?i:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?t!=null?t>0?"heating":"idle":(e??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var I=3e4,E=3e5,it=Math.ceil(Math.log2(E/I)),o=et({name:"mysa2mqtt",level:m.logLevel,transport:m.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function at(){o.info("Starting mysa2mqtt...");let a=await T(m.mysaSessionFile,o),t=new tt(a,{logger:new g(o.child({module:"mysa-js-sdk"}))});t.emitter.on("sessionChanged",async r=>{await q(r,m.mysaSessionFile,o)});let e=m.heartbeatFile;if(e){let r=0;t.emitter.on("rawRealtimeMessageReceived",()=>{let s=Date.now();s-r<1e4||(r=s,Z(e,`${new Date(s).toISOString()}
31
+ `).catch(d=>{o.warn(d,`Failed to write heartbeat file '${e}'`)}))})}t.isAuthenticated||(o.info("Logging in..."),await t.login(m.mysaUsername,m.mysaPassword)),o.debug("Fetching devices and firmwares...");let[i,p]=await Promise.all([t.getDevices(),t.getDeviceFirmwares()]);o.debug("Fetching serial numbers...");let u=new Map;for(let[r]of Object.entries(i.DevicesObj))try{let s=await t.getDeviceSerialNumber(r);s&&u.set(r,s)}catch(s){o.error(s,`Failed to retrieve serial number for device ${r}`)}o.debug("Initializing MQTT entities...");let f={host:m.mqttHost,port:m.mqttPort,username:m.mqttUsername,password:m.mqttPassword,client_name:m.mqttClientName,state_prefix:m.mqttTopicPrefix},h=Object.entries(i.DevicesObj).map(([,r])=>new _(t,r,f,new g(o.child({module:"thermostat",deviceId:r.Id})),p.Firmware[r.Id],u.get(r.Id),m.temperatureUnit)),n=0,l=[];for(let r of h)await rt(r)?n+=1:l.push(r);if(h.length>0&&n===0)throw new Error("Failed to start any thermostats");for(let r of l)O(r)}async function rt(a){try{return await a.start(),!0}catch(t){return o.error(t,`Failed to start thermostat ${a.mysaDevice.Id}`),!1}}function O(a,t=0){let e=Math.min(t,it),i=Math.min(E,I*2**e);o.info(`Retrying thermostat ${a.mysaDevice.Id} startup in ${i}ms`),setTimeout(()=>{nt(a,t+1)},i)}async function nt(a,t){try{await a.start(),o.info(`Started thermostat ${a.mysaDevice.Id} after retry`)}catch(e){o.error(e,`Failed to start thermostat ${a.mysaDevice.Id}`),O(a,t)}}at().catch(a=>{o.fatal(a,"Unexpected error"),process.exit(1)});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mysa2mqtt",
3
- "version": "1.2.3",
3
+ "version": "1.3.0",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
@@ -12,7 +12,8 @@
12
12
  "home-assistant"
13
13
  ],
14
14
  "publishConfig": {
15
- "provenance": true
15
+ "provenance": true,
16
+ "access": "public"
16
17
  },
17
18
  "author": {
18
19
  "name": "Pascal Bourque",
@@ -21,13 +22,15 @@
21
22
  "bugs": {
22
23
  "url": "https://github.com/bourquep/mysa2mqtt/issues"
23
24
  },
24
- "homepage": "https://github.com/bourquep/mysa2mqtt",
25
+ "homepage": "https://github.com/bourquep/mysa2mqtt/tree/main/packages/mysa2mqtt",
25
26
  "repository": {
26
27
  "type": "git",
27
- "url": "git+https://github.com/bourquep/mysa2mqtt.git"
28
+ "url": "git+https://github.com/bourquep/mysa2mqtt.git",
29
+ "directory": "packages/mysa2mqtt"
28
30
  },
29
31
  "files": [
30
32
  "README.md",
33
+ "CHANGELOG.md",
31
34
  "LICENSE.txt",
32
35
  "dist"
33
36
  ],
@@ -42,33 +45,18 @@
42
45
  "scripts": {
43
46
  "dev": "tsx src/main.ts",
44
47
  "lint": "eslint --max-warnings 0 src/**/*.ts",
45
- "style-lint": "prettier -c .",
48
+ "typecheck": "tsc --noEmit -p tsconfig.json",
46
49
  "build": "tsup"
47
50
  },
48
51
  "dependencies": {
49
52
  "commander": "15.0.0",
50
53
  "dotenv": "17.4.2",
51
- "mqtt2ha": "4.1.3",
52
- "mysa-js-sdk": "2.1.0",
54
+ "mqtt2ha": "4.1.5",
55
+ "mysa-js-sdk": "2.1.2",
53
56
  "pino": "10.3.1",
54
57
  "pino-pretty": "13.1.3"
55
58
  },
56
59
  "devDependencies": {
57
- "@commander-js/extra-typings": "15.0.0",
58
- "@eslint/js": "9.39.5",
59
- "@semantic-release/npm": "13.1.5",
60
- "@types/node": "24.13.3",
61
- "conventional-changelog-conventionalcommits": "10.2.1",
62
- "eslint": "9.39.5",
63
- "eslint-plugin-jsdoc": "61.7.1",
64
- "eslint-plugin-tsdoc": "0.5.2",
65
- "prettier": "3.9.5",
66
- "prettier-plugin-jsdoc": "1.8.1",
67
- "prettier-plugin-organize-imports": "4.3.0",
68
- "semantic-release": "25.0.8",
69
- "tsup": "8.5.1",
70
- "tsx": "4.23.1",
71
- "typescript": "5.9.3",
72
- "typescript-eslint": "8.64.0"
60
+ "@commander-js/extra-typings": "15.0.0"
73
61
  }
74
62
  }