mysa2mqtt 3.0.2 → 3.0.3
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 +23 -0
- package/LICENSE.txt +1 -1
- package/README.md +1 -1
- package/dist/capture.js +2 -2
- package/dist/{chunk-QBMDKIY7.js → chunk-O3OLOFHP.js} +1 -1
- package/dist/main.js +5 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# mysa2mqtt
|
|
2
2
|
|
|
3
|
+
## 3.0.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#226](https://github.com/bourquep/mysa2mqtt/pull/226) [`a00f308`](https://github.com/bourquep/mysa2mqtt/commit/a00f3083c4d843c312a55e14c7903f0c4f6598cf) Thanks [@souvik101990](https://github.com/souvik101990)! - Fix fan-speed control for AC-V1-0 (CodeNum=1117) thermostats that don't enumerate `SupportedCaps.fanSpeeds`.
|
|
8
|
+
|
|
9
|
+
These devices declare fan-speed support via `SupportedCaps.keys` (the FanSpeed ACState key, `4`) and report a live `FanSpeed` using canonical `fn` values (`1/2/4/6`), but their generic IR code set omits an explicit `SupportedCaps.fanSpeeds` list. As a result the fan was effectively uncontrollable:
|
|
10
|
+
|
|
11
|
+
- `buildFanModes` (mysa2mqtt) treated "no `fanSpeeds`" as "no fan support" and advertised only `['auto']`, hiding low/medium/high in Home Assistant.
|
|
12
|
+
- `buildFanSpeedSendMap` (mysa-js-sdk) fell back to the legacy `1/3/5/7` map, so a `high` command sent `fn: 7` — a value these canonical devices ignore — leaving the fan stuck at its current speed.
|
|
13
|
+
|
|
14
|
+
Now:
|
|
15
|
+
|
|
16
|
+
- `buildFanModes` advertises the canonical AC speeds (`auto/low/medium/high`) when a device omits `fanSpeeds` but declares fan support via `SupportedCaps.keys`.
|
|
17
|
+
- `buildFanSpeedSendMap` uses the canonical `1/2/4/6` map for CodeNum=1117 devices without an explicit `fanSpeeds` list.
|
|
18
|
+
|
|
19
|
+
Verified end-to-end against an AC-V1-0 unit: Home Assistant now advertises `auto/low/medium/high` and every fan command reaches the device (fan speed changes on the thermostat).
|
|
20
|
+
|
|
21
|
+
- [#230](https://github.com/bourquep/mysa2mqtt/pull/230) [`712d4c8`](https://github.com/bourquep/mysa2mqtt/commit/712d4c8b8d7a10056395afdffbacc68faef72e89) Thanks [@bourquep](https://github.com/bourquep)! - Fill in the Docker image metadata Docker Hub shows on the tag's Specifications tab: a full set of OCI labels (title, authors, vendor, url, documentation), a `copyright` label, `NODE_ENV=production`, and the source/revision/created labels and SBOM stamped by the release build.
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [[`a00f308`](https://github.com/bourquep/mysa2mqtt/commit/a00f3083c4d843c312a55e14c7903f0c4f6598cf), [`50d71a3`](https://github.com/bourquep/mysa2mqtt/commit/50d71a3b4bb6810e10ea54e11500905f2157f590)]:
|
|
24
|
+
- mysa-js-sdk@3.1.2
|
|
25
|
+
|
|
3
26
|
## 3.0.2
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/LICENSE.txt
CHANGED
package/README.md
CHANGED
package/dist/capture.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
3
|
mysa2mqtt
|
|
4
|
-
Copyright (C) 2025 Pascal Bourque
|
|
4
|
+
Copyright (C) 2025-2026 Pascal Bourque
|
|
5
5
|
|
|
6
6
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
7
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -22,6 +22,6 @@ 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{a as C}from"./chunk-
|
|
25
|
+
import{a as C}from"./chunk-O3OLOFHP.js";import{Command as O,InvalidArgumentError as b,Option as l}from"commander";import{configDotenv as M}from"dotenv";import{chmodSync as T,createWriteStream as E,existsSync as A}from"fs";import{MysaApiClient as D,UnauthenticatedError as I}from"mysa-js-sdk";import{pino as $}from"pino";M({path:[".env",".env.local"],override:!0});var t=new O("mysa2mqtt-capture").description("Capture the raw AWS IoT Device Shadow traffic of Mysa central-HVAC (ST-V1) thermostats, to help implement support for them. Logs in to Mysa, dumps the device metadata, then records every shadow message until you press Ctrl+C. Drive the thermostat from the Mysa app while it runs.").addOption(new l("-u, --mysa-username <mysaUsername>","Mysa account username (email)").env("M2M_MYSA_USERNAME").makeOptionMandatory()).addOption(new l("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory()).addOption(new l("-d, --device <deviceIdOrName...>","limit capture to these device id(s) or name(s). Repeatable. Defaults to every central-HVAC (ST-*) device")).addOption(new l("--all-devices","capture from every device on the account, not just central-HVAC ones")).addOption(new l("-e, --extra-topic <topicFilter...>","additional raw MQTT topic filter(s) to subscribe to, on top of the per-device shadow topics. Repeatable")).addOption(new l("-o, --output <file>","also write the metadata dump and every captured message to this file")).addOption(new l("--duration <seconds>","stop automatically after this many seconds (default: run until Ctrl+C)").argParser(o=>{let s=parseInt(o,10);if(String(s)!==o.trim()||s<0)throw new b("Must be a whole number of seconds (0 to run until Ctrl+C).");return s}).default(0)).addOption(new l("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info")).parse().opts(),n=$({name:"mysa2mqtt-capture",level:t.logLevel,transport:{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module,pid"}}}),a;function e(o){process.stdout.write(o+`
|
|
26
26
|
`),a==null||a.write(o+`
|
|
27
27
|
`)}async function N(o){try{await o.login()}catch(s){throw s instanceof I?new Error("Mysa rejected the credentials. Verify that the same username and password let you sign in to the Mysa mobile app, and beware of a shell or .env file mangling special characters ($, `) in the password.",{cause:s}):s}}function L(o){let s=Object.entries(o);if(t.device&&t.device.length>0){let r=t.device.map(c=>c.toLowerCase());return s.filter(([c,d])=>r.includes(c.toLowerCase())||d.Name!==void 0&&r.includes(d.Name.toLowerCase())).map(([c])=>c)}return t.allDevices?s.map(([r])=>r):s.filter(([,r])=>{var u;return(u=r.Model)==null?void 0:u.toUpperCase().startsWith("ST-")}).map(([r])=>r)}async function R(){var v,y;t.output&&(A(t.output)&&T(t.output,384),a=E(t.output,{flags:"w",mode:384}),a.on("error",i=>{n.error(i,`Failed to write to output file '${t.output}'`)}),n.info(`Writing metadata and captured messages to '${t.output}'`));let o=new D({username:t.mysaUsername,password:t.mysaPassword},{logger:new C(n.child({module:"mysa-js-sdk"}))});n.info("Logging in to Mysa..."),await N(o),n.info("Fetching devices, firmwares and states...");let[s,r,u]=await Promise.all([o.getDevices(),o.getDeviceFirmwares(),o.getDeviceStates()]),c=L(s.DevicesObj);if(c.length===0){n.error("No matching devices found. If your central-HVAC thermostat did not match the ST-* filter, re-run with `--all-devices` to list every device, or target it explicitly with `--device <id-or-name>`."),e("=== ALL DEVICES (no target matched) ==="),e(JSON.stringify(s.DevicesObj,null,2)),a==null||a.end();return}e("=================================================================="),e(" Mysa central-HVAC capture"),e(` Generated: ${new Date().toISOString()}`),e(" NOTE: this dump may contain account identifiers (Owner, Home,"),e(" AllowedUsers, Zone). Review before sharing publicly. Auth tokens"),e(" and passwords are NEVER included."),e("==================================================================");let d=[];for(let i of c){let p=s.DevicesObj[i],w=(v=r.Firmware)==null?void 0:v[i],S=(y=u.DeviceStatesObj)==null?void 0:y[i];e(""),e("------------------------------------------------------------------"),e(`DEVICE ${p.Name??"(unnamed)"} \u2014 model ${p.Model} \u2014 id ${i}`),e(`Firmware: ${(w==null?void 0:w.InstalledVersion)??"unknown"}`),e("------------------------------------------------------------------"),e("--- device metadata (REST /devices) ---"),e(JSON.stringify(p,null,2)),e("--- device state (REST /states) ---"),e(JSON.stringify(S??null,null,2)),d.push(`$aws/things/${i}/shadow/#`),d.push(`/v1/dev/${i}/#`)}t.extraTopic&&d.push(...t.extraTopic),e(""),e("=== SUBSCRIBING TO TOPIC FILTERS ===");for(let i of d)e(` ${i}`);let h=0,m=new Set;await o.startRawTopicCapture(d,(i,p)=>{h++,m.add(i),e(""),e(`<<< [${new Date().toISOString()}] ${i}`),e(p)}),e(""),e("=================================================================="),e(" Capture is running. Now, in the Mysa mobile app, exercise the"),e(" thermostat so every interaction is recorded:"),e(" 1. Turn the system OFF, then back ON."),e(" 2. Switch modes: Heat, Cool, Auto, Fan-only (whatever it has)."),e(" 3. Change the heat setpoint, then the cool setpoint."),e(" 4. In Auto, change both setpoints (and the deadband if shown)."),e(" 5. Change the fan mode (Auto / On / Circulate)."),e(" 6. Leave it idle a few minutes to catch periodic telemetry."),e(" Pause a few seconds between actions. Press Ctrl+C when done."),e("==================================================================");let g=!1,f=i=>{g||(g=!0,n.info(`Stopping capture (${i}). Captured ${h} message(s) across ${m.size} topic(s).`),m.size===0&&n.warn("No shadow messages were captured. Either the thermostat was not touched during the capture, or the account's AWS IoT policy does not allow subscribing to its shadow topics (check the log above for subscribe failures). Re-run with `--log-level debug` to see the per-filter subscribe results."),e(""),e(`=== END OF CAPTURE \u2014 ${h} message(s), ${m.size} distinct topic(s) ===`),a?a.end(()=>process.exit(0)):process.exit(0))};process.on("SIGINT",()=>f("Ctrl+C")),process.on("SIGTERM",()=>f("SIGTERM")),t.duration&&t.duration>0&&(n.info(`Will stop automatically after ${t.duration}s.`),setTimeout(()=>f(`--duration ${t.duration}s elapsed`),t.duration*1e3))}R().catch(o=>{n.error(o,"Capture failed"),a?a.end(()=>process.exit(1)):process.exit(1)});
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/*
|
|
3
3
|
mysa2mqtt
|
|
4
|
-
Copyright (C) 2025 Pascal Bourque
|
|
4
|
+
Copyright (C) 2025-2026 Pascal Bourque
|
|
5
5
|
|
|
6
6
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
7
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -22,10 +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{a as
|
|
26
|
-
Copyright (
|
|
25
|
+
import{a as E}from"./chunk-O3OLOFHP.js";import{writeFile as ne}from"fs/promises";import{MysaApiClient as oe,UnauthenticatedError as se}from"mysa-js-sdk";import{pino as me}from"pino";import{Command as Q,InvalidArgumentError as _,Option as l}from"commander";import{configDotenv as Y}from"dotenv";import{readFileSync as B}from"fs";import{dirname as j,join as X}from"path";import{fileURLToPath as V}from"url";Y({path:[".env",".env.local"],override:!0});function z(){try{let i=V(import.meta.url),e=j(i),t=X(e,"..","package.json");return JSON.parse(B(t,"utf-8")).version||"unknown"}catch{return"unknown"}}function D(i){let e=parseInt(i,10);if(isNaN(e))throw new _("Must be a number.");return e}function J(i){let e=D(i);if(String(e)!==i.trim())throw new _("Must be a whole number of seconds.");if(e!==0&&e<30)throw new _("Must be 0 (disabled) or at least 30 seconds.");return e}function K(i){let e=new Map;for(let t of i.split(",")){let a=t.trim();if(a.length===0)continue;let r=a.lastIndexOf("=");if(r<0)throw new _(`'${a}' is not a <device>=<watts> pair.`);let s=a.slice(0,r).trim(),m=Number(a.slice(r+1).trim());if(s.length===0)throw new _(`'${a}' is missing a device id or name.`);if(!Number.isFinite(m)||m<=0)throw new _(`'${a}' must specify a wattage greater than zero.`);e.set(s.toLowerCase(),m)}return e}var R=z(),Z=`
|
|
26
|
+
Copyright (C) 2025-2026 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
|
-
`,d=new Q("mysa2mqtt").version(b).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",Z).addOption(new l("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new l("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new l("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new l("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(I).default(1883).helpGroup("MQTT")).addOption(new l("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new l("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new l("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new l("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new l("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new l("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new l("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).addOption(new l("--heater-watts <heaterWatts>",'rated wattage of the heaters controlled by each thermostat, as a comma-separated list of <device>=<watts> pairs, where <device> is a device id or name (e.g. "Kitchen=1500,<device-id>=750"). Required for V2 thermostats to report power, as they do not measure current themselves').env("M2M_HEATER_WATTS").argParser(K).helpGroup("Configuration")).addOption(new l("--poll-interval-seconds <pollIntervalSeconds>","how often, in seconds, to refresh device state from the Mysa REST API. This keeps Home Assistant current even when the real-time connection cannot be established or is unstable. Set to 0 to disable, or to at least 30").env("M2M_POLL_INTERVAL_SECONDS").argParser(J).default(60).helpGroup("Configuration")).addOption(new l("--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{Climate as ee,Sensor as T}from"mqtt2ha";var D=["off","heat"],E=["off","heat","cool","dry","fan_only","auto"],te={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},ie=["auto","low","medium","high","max"],P={1:"auto",2:"low",3:"low",4:"medium",5:"medium",6:"high",7:"high",8:"max"},N=3e4,k=3e5,F=Math.ceil(Math.log2(k/N));function O(i){if(!(i!=null&&i.modes))return[...ie];let e=new Set;if(i.fanSpeeds)for(let t of i.fanSpeeds)e.add(t);for(let t of Object.values(i.modes))for(let a of t.fanSpeeds??[])e.add(a);return e.size===0?["auto"]:Array.from(new Set(Object.entries(P).filter(([t])=>e.has(Number(t))).map(([,t])=>t)))}var C=class{constructor(e,t,a,r,s,m,u,q){this.mysaApiClient=e;this.mysaDevice=t;this.mqttSettings=a;this.logger=r;this.mysaDeviceFirmware=s;this.mysaDeviceSerialNumber=m;this.temperatureUnit=u;this.heaterWatts=q;let f=(u??"C")==="C";this.mqttDevice={identifiers:t.Id,name:t.Name,manufacturer:"Mysa",model:t.Model,sw_version:s==null?void 0:s.InstalledVersion,serial_number:m},this.mqttOrigin={name:"mysa2mqtt",sw_version:b,support_url:"https://github.com/bourquep/mysa2mqtt"};let p=t.Model.startsWith("AC");this.deviceType=p?"AC":"BB";let v=/-v2-/i.test(t.Model),_=/^INF-/i.test(t.Model),c=!p&&(!(v||_)||q!=null);this.mqttClimate=new ee({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_climate`,name:"Thermostat",min_temp:t.MinSetpoint,max_temp:t.MaxSetpoint,modes:p?E:D,fan_modes:p?O(t.SupportedCaps):void 0,precision:f?.1:1,temp_step:f?.5:1,temperature_unit:"C",optimistic:!0}},p?["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()=>{},p?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(w,g)=>{switch(w){case"mode_command_topic":{let h=g,S=p?E.includes(h)?h:void 0:D.includes(h)?h:void 0;await this.setDeviceState(void 0,S);break}case"power_command_topic":await this.setDeviceState(void 0,g==="OFF"?"off":g==="ON"&&!p?"heat":void 0);break;case"temperature_command_topic":if(g==="")await this.setDeviceState(void 0,void 0);else{let h=parseFloat(g);if(!f){let S=A=>Math.round(A*2)/2,U=(A,G,W)=>Math.min(W,Math.max(G,A)),$=S(h);h=U($,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(h,void 0)}break;case"fan_mode_command_topic":{let h=g;if(!O(this.mysaDevice.SupportedCaps).includes(h))break;await this.setDeviceState(this.mqttClimate.targetTemperature,this.mqttClimate.currentMode,h);break}}}),this.mqttTemperature=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:f?.1:0,force_update:!0}}),this.mqttFloorTemperature=_?new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_floor_temperature`,name:"Floor temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:f?.1:0,force_update:!0}}):void 0,this.mqttHumidity=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}});let y=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}});this.mqttPower=c?y:void 0,this.mqttRetiredPower=c?void 0:y}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;heaterWatts;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttFloorTemperature;mqttHumidity;mqttPower;mqttRetiredPower;mysaStatusUpdateHandler=e=>{this.handleMysaStatusUpdate(e).catch(t=>{this.logger.error("Failed to handle Mysa status update",{error:t,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=e=>{this.handleMysaStateChange(e).catch(t=>{this.logger.error("Failed to handle Mysa state change",{error:t,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var e,t;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let r=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];r!=null&&await this.publishRestState(r),await this.mqttClimate.writeConfig(),await this.mqttTemperature.writeConfig(),await((e=this.mqttFloorTemperature)==null?void 0:e.writeConfig()),await this.mqttHumidity.writeConfig(),this.mqttPower!=null&&(await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig()),await((t=this.mqttRetiredPower)==null?void 0:t.removeConfig()),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(a){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),a}}}async refreshFromRest(e){if(!(!this.isStarted||e==null))try{await this.publishRestState(e)}catch(t){this.logger.error("Failed to apply REST state poll",{error:t,deviceId:this.mysaDevice.Id})}}async publishRestState(e){var t,a,r,s,m,u;this.mqttClimate.currentTemperature=(t=e.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(a=e.Humidity)==null?void 0:a.v,this.mqttClimate.currentMode=te[(r=e.TstatMode)==null?void 0:r.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=P[(s=e.FanSpeed)==null?void 0:s.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(m=e.Duty)==null?void 0:m.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(u=e.SetPoint)==null?void 0:u.v:void 0,await this.mqttTemperature.setState("state_topic",e.CorrectedTemp!=null?e.CorrectedTemp.v.toFixed(2):"None"),await this.mqttHumidity.setState("state_topic",e.Humidity!=null?e.Humidity.v.toFixed(2):"None")}async stop(){var e,t;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((e=this.mqttPower)==null?void 0:e.setState("state_topic","None")),await this.mqttTemperature.setState("state_topic","None"),await((t=this.mqttFloorTemperature)==null?void 0:t.setState("state_topic","None")),await this.mqttHumidity.setState("state_topic","None"))}async setDeviceState(e,t,a){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,e,t,a)}catch(r){this.logger.error("Failed to update Mysa device state",{error:r,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let e=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||e!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(t){this.isStarted&&e===this.realtimeGeneration&&this.scheduleRealtimeRetry(t)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(e){this.logger.warn("Failed to stop realtime updates",{error:e,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(e){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let t=Math.min(this.realtimeRetryAttempt,F),a=Math.min(k,N*2**t);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,F),this.logger.error("Failed to start realtime updates; retrying",{error:e,deviceId:this.mysaDevice.Id,retryDelayMs:a}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},a)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(e){var t;if(!(!this.isStarted||e.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(e.current,e.dutyCycle),this.mqttClimate.currentTemperature=e.temperature,this.mqttClimate.currentHumidity=e.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?e.setPoint:void 0,this.mqttPower!=null){let a=this.computeWatts(e);this.logger.debug("Computed power draw",{current:e.current,dutyCycle:e.dutyCycle,watts:a}),await this.mqttPower.setState("state_topic",a!=null?a.toFixed(2):"None")}await this.mqttTemperature.setState("state_topic",e.temperature.toFixed(2)),e.floorTemperature!=null&&await((t=this.mqttFloorTemperature)==null?void 0:t.setState("state_topic",e.floorTemperature.toFixed(2))),await this.mqttHumidity.setState("state_topic",e.humidity.toFixed(2))}}async handleMysaStateChange(e){if(!(!this.isStarted||e.deviceId!==this.mysaDevice.Id))switch(e.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=e.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=e.setPoint,e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break;case"dry":case"fan_only":this.mqttClimate.currentMode=e.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break;default:this.mqttClimate.currentMode!=="off"&&(this.mqttClimate.targetTemperature=e.setPoint),e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break}}computeWatts(e){if(e.current!=null&&this.mysaDevice.Voltage!=null)return this.mysaDevice.Voltage*e.current;if(e.dutyCycle!=null&&this.heaterWatts!=null)return this.heaterWatts*Math.min(Math.max(e.dutyCycle,0),1)}computeCurrentAction(e,t){let a=this.mqttClimate.currentMode;switch(E.includes(a)?a:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?e!=null?e>0?"heating":"idle":(t??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var x=3e4,H=3e5,se=Math.ceil(Math.log2(H/x)),o=oe({name:"mysa2mqtt",level:d.logLevel,transport:d.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function me(){o.info("Starting mysa2mqtt...");let i=new re({username:d.mysaUsername,password:d.mysaPassword},{logger:new R(o.child({module:"mysa-js-sdk"}))}),e=d.heartbeatFile;if(e){let n=0;i.emitter.on("rawRealtimeMessageReceived",()=>{let c=Date.now();c-n<1e4||(n=c,ae(e,`${new Date(c).toISOString()}
|
|
31
|
-
`).catch(y=>{o.warn(y,`Failed to write heartbeat file '${e}'`)}))})}o.info("Logging in..."),await
|
|
30
|
+
`,d=new Q("mysa2mqtt").version(R).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",Z).addOption(new l("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new l("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new l("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new l("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(D).default(1883).helpGroup("MQTT")).addOption(new l("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new l("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new l("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new l("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new l("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new l("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new l("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).addOption(new l("--heater-watts <heaterWatts>",'rated wattage of the heaters controlled by each thermostat, as a comma-separated list of <device>=<watts> pairs, where <device> is a device id or name (e.g. "Kitchen=1500,<device-id>=750"). Required for V2 thermostats to report power, as they do not measure current themselves').env("M2M_HEATER_WATTS").argParser(K).helpGroup("Configuration")).addOption(new l("--poll-interval-seconds <pollIntervalSeconds>","how often, in seconds, to refresh device state from the Mysa REST API. This keeps Home Assistant current even when the real-time connection cannot be established or is unstable. Set to 0 to disable, or to at least 30").env("M2M_POLL_INTERVAL_SECONDS").argParser(J).default(60).helpGroup("Configuration")).addOption(new l("--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{Climate as ee,Sensor as T}from"mqtt2ha";var I=["off","heat"],b=["off","heat","cool","dry","fan_only","auto"],te={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},ie=["auto","low","medium","high","max"],O={1:"auto",2:"low",3:"low",4:"medium",5:"medium",6:"high",7:"high",8:"max"},ae=4,re=["auto","low","medium","high"],N=3e4,k=3e5,F=Math.ceil(Math.log2(k/N));function P(i){var t;if(!(i!=null&&i.modes))return[...ie];let e=new Set;if(i.fanSpeeds)for(let a of i.fanSpeeds)e.add(a);for(let a of Object.values(i.modes))for(let r of a.fanSpeeds??[])e.add(r);return e.size===0?(t=i.keys)!=null&&t.includes(ae)?[...re]:["auto"]:Array.from(new Set(Object.entries(O).filter(([a])=>e.has(Number(a))).map(([,a])=>a)))}var C=class{constructor(e,t,a,r,s,m,u,q){this.mysaApiClient=e;this.mysaDevice=t;this.mqttSettings=a;this.logger=r;this.mysaDeviceFirmware=s;this.mysaDeviceSerialNumber=m;this.temperatureUnit=u;this.heaterWatts=q;let f=(u??"C")==="C";this.mqttDevice={identifiers:t.Id,name:t.Name,manufacturer:"Mysa",model:t.Model,sw_version:s==null?void 0:s.InstalledVersion,serial_number:m},this.mqttOrigin={name:"mysa2mqtt",sw_version:R,support_url:"https://github.com/bourquep/mysa2mqtt"};let p=t.Model.startsWith("AC");this.deviceType=p?"AC":"BB";let v=/-v2-/i.test(t.Model),M=/^INF-/i.test(t.Model),c=!p&&(!(v||M)||q!=null);this.mqttClimate=new ee({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_climate`,name:"Thermostat",min_temp:t.MinSetpoint,max_temp:t.MaxSetpoint,modes:p?b:I,fan_modes:p?P(t.SupportedCaps):void 0,precision:f?.1:1,temp_step:f?.5:1,temperature_unit:"C",optimistic:!0}},p?["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()=>{},p?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(w,g)=>{switch(w){case"mode_command_topic":{let h=g,S=p?b.includes(h)?h:void 0:I.includes(h)?h:void 0;await this.setDeviceState(void 0,S);break}case"power_command_topic":await this.setDeviceState(void 0,g==="OFF"?"off":g==="ON"&&!p?"heat":void 0);break;case"temperature_command_topic":if(g==="")await this.setDeviceState(void 0,void 0);else{let h=parseFloat(g);if(!f){let S=A=>Math.round(A*2)/2,H=(A,G,W)=>Math.min(W,Math.max(G,A)),$=S(h);h=H($,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(h,void 0)}break;case"fan_mode_command_topic":{let h=g;if(!P(this.mysaDevice.SupportedCaps).includes(h))break;await this.setDeviceState(this.mqttClimate.targetTemperature,this.mqttClimate.currentMode,h);break}}}),this.mqttTemperature=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:f?.1:0,force_update:!0}}),this.mqttFloorTemperature=M?new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_floor_temperature`,name:"Floor temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:f?.1:0,force_update:!0}}):void 0,this.mqttHumidity=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}});let y=new T({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${t.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}});this.mqttPower=c?y:void 0,this.mqttRetiredPower=c?void 0:y}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;heaterWatts;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttFloorTemperature;mqttHumidity;mqttPower;mqttRetiredPower;mysaStatusUpdateHandler=e=>{this.handleMysaStatusUpdate(e).catch(t=>{this.logger.error("Failed to handle Mysa status update",{error:t,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=e=>{this.handleMysaStateChange(e).catch(t=>{this.logger.error("Failed to handle Mysa state change",{error:t,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var e,t;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let r=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];r!=null&&await this.publishRestState(r),await this.mqttClimate.writeConfig(),await this.mqttTemperature.writeConfig(),await((e=this.mqttFloorTemperature)==null?void 0:e.writeConfig()),await this.mqttHumidity.writeConfig(),this.mqttPower!=null&&(await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig()),await((t=this.mqttRetiredPower)==null?void 0:t.removeConfig()),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(a){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),a}}}async refreshFromRest(e){if(!(!this.isStarted||e==null))try{await this.publishRestState(e)}catch(t){this.logger.error("Failed to apply REST state poll",{error:t,deviceId:this.mysaDevice.Id})}}async publishRestState(e){var t,a,r,s,m,u;this.mqttClimate.currentTemperature=(t=e.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(a=e.Humidity)==null?void 0:a.v,this.mqttClimate.currentMode=te[(r=e.TstatMode)==null?void 0:r.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=O[(s=e.FanSpeed)==null?void 0:s.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(m=e.Duty)==null?void 0:m.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(u=e.SetPoint)==null?void 0:u.v:void 0,await this.mqttTemperature.setState("state_topic",e.CorrectedTemp!=null?e.CorrectedTemp.v.toFixed(2):"None"),await this.mqttHumidity.setState("state_topic",e.Humidity!=null?e.Humidity.v.toFixed(2):"None")}async stop(){var e,t;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((e=this.mqttPower)==null?void 0:e.setState("state_topic","None")),await this.mqttTemperature.setState("state_topic","None"),await((t=this.mqttFloorTemperature)==null?void 0:t.setState("state_topic","None")),await this.mqttHumidity.setState("state_topic","None"))}async setDeviceState(e,t,a){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,e,t,a)}catch(r){this.logger.error("Failed to update Mysa device state",{error:r,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let e=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||e!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(t){this.isStarted&&e===this.realtimeGeneration&&this.scheduleRealtimeRetry(t)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(e){this.logger.warn("Failed to stop realtime updates",{error:e,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(e){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let t=Math.min(this.realtimeRetryAttempt,F),a=Math.min(k,N*2**t);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,F),this.logger.error("Failed to start realtime updates; retrying",{error:e,deviceId:this.mysaDevice.Id,retryDelayMs:a}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},a)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(e){var t;if(!(!this.isStarted||e.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(e.current,e.dutyCycle),this.mqttClimate.currentTemperature=e.temperature,this.mqttClimate.currentHumidity=e.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?e.setPoint:void 0,this.mqttPower!=null){let a=this.computeWatts(e);this.logger.debug("Computed power draw",{current:e.current,dutyCycle:e.dutyCycle,watts:a}),await this.mqttPower.setState("state_topic",a!=null?a.toFixed(2):"None")}await this.mqttTemperature.setState("state_topic",e.temperature.toFixed(2)),e.floorTemperature!=null&&await((t=this.mqttFloorTemperature)==null?void 0:t.setState("state_topic",e.floorTemperature.toFixed(2))),await this.mqttHumidity.setState("state_topic",e.humidity.toFixed(2))}}async handleMysaStateChange(e){if(!(!this.isStarted||e.deviceId!==this.mysaDevice.Id))switch(e.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=e.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=e.setPoint,e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break;case"dry":case"fan_only":this.mqttClimate.currentMode=e.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break;default:this.mqttClimate.currentMode!=="off"&&(this.mqttClimate.targetTemperature=e.setPoint),e.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=e.fanSpeed);break}}computeWatts(e){if(e.current!=null&&this.mysaDevice.Voltage!=null)return this.mysaDevice.Voltage*e.current;if(e.dutyCycle!=null&&this.heaterWatts!=null)return this.heaterWatts*Math.min(Math.max(e.dutyCycle,0),1)}computeCurrentAction(e,t){let a=this.mqttClimate.currentMode;switch(b.includes(a)?a:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?e!=null?e>0?"heating":"idle":(t??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var x=3e4,L=3e5,de=Math.ceil(Math.log2(L/x)),o=me({name:"mysa2mqtt",level:d.logLevel,transport:d.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function ce(){o.info("Starting mysa2mqtt...");let i=new oe({username:d.mysaUsername,password:d.mysaPassword},{logger:new E(o.child({module:"mysa-js-sdk"}))}),e=d.heartbeatFile;if(e){let n=0;i.emitter.on("rawRealtimeMessageReceived",()=>{let c=Date.now();c-n<1e4||(n=c,ne(e,`${new Date(c).toISOString()}
|
|
31
|
+
`).catch(y=>{o.warn(y,`Failed to write heartbeat file '${e}'`)}))})}o.info("Logging in..."),await fe(i),o.debug("Fetching devices and firmwares...");let[t,a]=await Promise.all([i.getDevices(),i.getDeviceFirmwares()]);o.debug("Fetching serial numbers...");let r=new Map;for(let[n]of Object.entries(t.DevicesObj))try{let c=await i.getDeviceSerialNumber(n);c&&r.set(n,c)}catch(c){o.error(c,`Failed to retrieve serial number for device ${n}`)}o.debug("Initializing MQTT entities...");let s={host:d.mqttHost,port:d.mqttPort,username:d.mqttUsername,password:d.mqttPassword,client_name:d.mqttClientName,state_prefix:d.mqttTopicPrefix},m=d.heaterWatts??new Map,u=new Set;function q(n){for(let c of[n.Id,n.Name]){let y=c==null?void 0:c.toLowerCase();if(y==null)continue;let w=m.get(y);if(w!=null)return u.add(y),w}}let f=Object.entries(t.DevicesObj).map(([,n])=>new C(i,n,s,new E(o.child({module:"thermostat",deviceId:n.Id})),a.Firmware[n.Id],r.get(n.Id),d.temperatureUnit,q(n))),p=m.size-u.size;p>0&&o.warn(`${p} of the ${m.size} heater wattage entries match no device id or name. Check the configured names and ids against your devices.`);let v=0,M=[];for(let n of f)await ye(n)?v+=1:M.push(n);if(f.length>0&&v===0)throw new Error("Failed to start any thermostats");for(let n of M)U(n);le(i,f)}function le(i,e){let t=d.pollIntervalSeconds;if(t<=0||e.length===0)return;let a=new Map(e.map(s=>[s.mysaDevice.Id,s]));o.info(`Polling device state over REST every ${t}s`);let r=!1;setInterval(()=>{if(r){o.debug("Skipping REST state poll; previous poll still in flight");return}r=!0,(async()=>{try{let s=await i.getDeviceStates();await Promise.all(Array.from(a,([m,u])=>u.refreshFromRest(s.DeviceStatesObj[m])))}catch(s){o.warn(s,"Periodic REST state poll failed")}finally{r=!1}})()},t*1e3)}var ue="NotAuthorizedException",pe="UserNotFoundException";function he(i){let e=i.cause;if(typeof e!="object"||e===null)return;let t=("code"in e?e.code:void 0)??("name"in e?e.name:void 0);return typeof t=="string"?t:void 0}async function fe(i){o.debug(`Authenticating with a password of ${d.mysaPassword.length} character(s).`);try{await i.login()}catch(e){let t=e instanceof se?he(e):void 0;throw t===ue?new Error("Mysa rejected the credentials. Verify that they let you sign in to the Mysa mobile app, then check that the password reaches mysa2mqtt intact: a shell expands $ and ` inside double quotes, and Docker Compose expands $ in `environment:` entries and in `env_file:` files, where a $ must be written as $$. An `env_file:` entry declared with `format: raw` is the exception -- it takes the password verbatim, so a $ stays a single $ there. Re-run with --log-level debug to log the length of the password that was received and compare it against your actual password.",{cause:e}):t===pe?new Error("Mysa does not recognize that username. Check --mysa-username (M2M_MYSA_USERNAME): it must be the email address you sign in to the Mysa mobile app with.",{cause:e}):e}}async function ye(i){try{return await i.start(),!0}catch(e){return o.error(e,`Failed to start thermostat ${i.mysaDevice.Id}`),!1}}function U(i,e=0){let t=Math.min(e,de),a=Math.min(L,x*2**t);o.info(`Retrying thermostat ${i.mysaDevice.Id} startup in ${a}ms`),setTimeout(()=>{ge(i,e+1)},a)}async function ge(i,e){try{await i.start(),o.info(`Started thermostat ${i.mysaDevice.Id} after retry`)}catch(t){o.error(t,`Failed to start thermostat ${i.mysaDevice.Id}`),U(i,e)}}function _e(i){let e=i==null?void 0:i.code,t=String((i==null?void 0:i.message)??i??"");if(e&&new Set(["NetworkError","ETIMEDOUT","ECONNRESET","ENOTFOUND","EAI_AGAIN"]).has(e)||/Network error|getaddrinfo|socket hang up/i.test(t))return!0;let r=/connect|connection|socket|network|request|handshake|tls|dns|host/i;return/time(?:d\s*)?out|ETIMEDOUT/i.test(t)&&r.test(t)}async function Me(){let t=5e3;for(let a=0;a<=10;a++)try{await ce();return}catch(r){(!_e(r)||a===10)&&(o.fatal(r,"Unexpected error"),process.exit(1));let m=a+1;o.warn({err:r,retry:m,maxRetries:10,retryInMs:t},`Transient error during startup (retry ${m}/10); retrying in ${t}ms`),await new Promise(u=>setTimeout(u,t)),t=Math.min(t*2,6e4)}}Me();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mysa2mqtt",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"commander": "15.0.0",
|
|
55
55
|
"dotenv": "17.4.2",
|
|
56
56
|
"mqtt2ha": "5.1.1",
|
|
57
|
-
"mysa-js-sdk": "3.1.
|
|
57
|
+
"mysa-js-sdk": "3.1.2",
|
|
58
58
|
"pino": "10.3.1",
|
|
59
59
|
"pino-pretty": "13.1.3"
|
|
60
60
|
},
|