mysa2mqtt 3.0.0 → 3.0.2
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 +30 -0
- package/README.md +32 -34
- package/dist/main.js +3 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# mysa2mqtt
|
|
2
2
|
|
|
3
|
+
## 3.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#121](https://github.com/bourquep/mysa2mqtt/pull/121) [`8fab111`](https://github.com/bourquep/mysa2mqtt/commit/8fab111568255ea6130123be8dd3fcf5cbb08b5b) Thanks [@souvik101990](https://github.com/souvik101990)! - Derive AC fan modes from `SupportedCaps` and preserve state on fan-mode changes (CodeNum=1117).
|
|
8
|
+
|
|
9
|
+
AC-V1-X thermostats (Mysa for Mini-Split) report their supported fan speeds through `SupportedCaps.fanSpeeds` and use canonical `fn` values (`[1, 2, 4, 6]`) that differ from the legacy universal mapping. `mysa2mqtt` now:
|
|
10
|
+
|
|
11
|
+
- recognizes the canonical `fn=2/4/6` values on the receive path so the current fan speed is reported instead of dropped;
|
|
12
|
+
- derives the advertised `fan_modes` from the device's actual `SupportedCaps` instead of a hardcoded list (devices without fan-speed support advertise only `auto`), deduplicating modes that map from both legacy and canonical raw values;
|
|
13
|
+
- rejects fan-mode commands the device doesn't support instead of silently reapplying the current state; and
|
|
14
|
+
- preserves the current target temperature and climate mode when changing fan mode, and keeps the current fan mode when a state update omits the fan speed.
|
|
15
|
+
|
|
16
|
+
`mysa-js-sdk` adds an optional per-mode `fanSpeeds` field to `SupportedCaps.modes` (the top-level `fanSpeeds` field was already present).
|
|
17
|
+
|
|
18
|
+
- [#129](https://github.com/bourquep/mysa2mqtt/pull/129) [`e81f182`](https://github.com/bourquep/mysa2mqtt/commit/e81f182cbeb84c8a5ca97f954746c7aba2f3a0e2) Thanks [@vavallee](https://github.com/vavallee)! - Retry startup on transient network errors instead of exiting immediately. DNS, TCP or TLS hiccups during the initial Cognito authentication (surfaced as generic `Network error` by `amazon-cognito-identity-js`) are now retried up to 10 times with exponential backoff before the process gives up. Configuration and programming errors still exit immediately.
|
|
19
|
+
|
|
20
|
+
- [#217](https://github.com/bourquep/mysa2mqtt/pull/217) [`0da4a1d`](https://github.com/bourquep/mysa2mqtt/commit/0da4a1d4e95dddf9638ec00747895008a10f7ba8) Thanks [@bourquep](https://github.com/bourquep)! - Updated dependencies to latest versions
|
|
21
|
+
|
|
22
|
+
- Updated dependencies [[`8fab111`](https://github.com/bourquep/mysa2mqtt/commit/8fab111568255ea6130123be8dd3fcf5cbb08b5b), [`169272d`](https://github.com/bourquep/mysa2mqtt/commit/169272d366dc76cb3d07831d752b83a7f9f57733), [`0da4a1d`](https://github.com/bourquep/mysa2mqtt/commit/0da4a1d4e95dddf9638ec00747895008a10f7ba8), [`f0dc8c1`](https://github.com/bourquep/mysa2mqtt/commit/f0dc8c1c812e469cde891b513a812e717d584aff), [`02ad499`](https://github.com/bourquep/mysa2mqtt/commit/02ad49990fd3f3b79f8c7d302fb461d29b5fb542), [`b89b238`](https://github.com/bourquep/mysa2mqtt/commit/b89b238741f94cd9557c2795929f0a7c7a60d341), [`6bd3f92`](https://github.com/bourquep/mysa2mqtt/commit/6bd3f920dc85697c2d9665b31e25b36767884ecd)]:
|
|
23
|
+
- mysa-js-sdk@3.1.1
|
|
24
|
+
- mqtt2ha@5.1.1
|
|
25
|
+
|
|
26
|
+
## 3.0.1
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- Updated dependencies [[`42b7008`](https://github.com/bourquep/mysa2mqtt/commit/42b7008a4b5f71a27f6b041a9fd0811a7efc0f4a), [`d578966`](https://github.com/bourquep/mysa2mqtt/commit/d5789661c7f134e183102291cdce09fc4364d8eb)]:
|
|
31
|
+
- mqtt2ha@5.1.0
|
|
32
|
+
|
|
3
33
|
## 3.0.0
|
|
4
34
|
|
|
5
35
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -13,18 +13,19 @@ home automation platforms.
|
|
|
13
13
|
- **MQTT Integration**: Exposes Mysa thermostats as MQTT devices compatible with Home Assistant's auto-discovery
|
|
14
14
|
- **Real-time Updates**: Live temperature, humidity, and power consumption monitoring
|
|
15
15
|
- **Full Control**: Set temperature, change modes (heat/off), and monitor thermostat status
|
|
16
|
-
- **Self-healing Authentication**: Re-authenticates automatically when the Mysa session expires, with no state to
|
|
16
|
+
- **Self-healing Authentication**: Re-authenticates automatically when the Mysa session expires, with no state to
|
|
17
|
+
persist
|
|
17
18
|
- **Configurable Logging**: Support for JSON and pretty-printed log formats with adjustable levels
|
|
18
19
|
|
|
19
20
|
## Supported hardware
|
|
20
21
|
|
|
21
|
-
| Model Number | Description | Supported
|
|
22
|
-
| ------------ | --------------------------------------------------------- |
|
|
23
|
-
| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working
|
|
24
|
-
| `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Partially working, in progress
|
|
25
|
-
| `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Partially working, in progress; does not measure power, but can report an estimate (see [Power reporting](#power-reporting))
|
|
22
|
+
| Model Number | Description | Supported |
|
|
23
|
+
| ------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working |
|
|
25
|
+
| `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Partially working, in progress |
|
|
26
|
+
| `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Partially working, in progress; does not measure power, but can report an estimate (see [Power reporting](#power-reporting)) |
|
|
26
27
|
| `INF-V1-0` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Partially working, in progress; controls and a floor-temperature sensor are supported, and power can be estimated (see [Power reporting](#power-reporting)) |
|
|
27
|
-
| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions
|
|
28
|
+
| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions |
|
|
28
29
|
|
|
29
30
|
## Disclaimer
|
|
30
31
|
|
|
@@ -59,8 +60,8 @@ npx mysa2mqtt --help
|
|
|
59
60
|
|
|
60
61
|
### Option 3: Home Assistant App (Addon)
|
|
61
62
|
|
|
62
|
-
@itsamenathan made an [app/addon](https://github.com/itsamenathan/mysa2mqtt-hass-addon) for
|
|
63
|
-
|
|
63
|
+
@itsamenathan made an [app/addon](https://github.com/itsamenathan/mysa2mqtt-hass-addon) for Home Assistant that wraps
|
|
64
|
+
this tool.
|
|
64
65
|
|
|
65
66
|
### Option 4: Development Setup
|
|
66
67
|
|
|
@@ -123,10 +124,9 @@ For development or custom modifications:
|
|
|
123
124
|
The application can be configured using either command-line arguments or environment variables. Environment variables
|
|
124
125
|
take precedence over command-line defaults.
|
|
125
126
|
|
|
126
|
-
> [!IMPORTANT]
|
|
127
|
-
>
|
|
128
|
-
>
|
|
129
|
-
> 21°C treated as 21°F) and commands may result in unexpected temperatures.
|
|
127
|
+
> [!IMPORTANT] The `M2M_TEMPERATURE_UNIT` option must match Home Assistant's unit system (Settings → General → Unit
|
|
128
|
+
> System) so setpoints and readings are interpreted correctly. If mismatched, climate entities will show incorrect
|
|
129
|
+
> values (e.g. 21°C treated as 21°F) and commands may result in unexpected temperatures.
|
|
130
130
|
|
|
131
131
|
### Required Configuration
|
|
132
132
|
|
|
@@ -150,13 +150,13 @@ take precedence over command-line defaults.
|
|
|
150
150
|
|
|
151
151
|
#### Application Settings
|
|
152
152
|
|
|
153
|
-
| CLI Option
|
|
154
|
-
|
|
|
155
|
-
| `-l, --log-level`
|
|
156
|
-
| `-f, --log-format`
|
|
157
|
-
| `-t, --temperature-unit`
|
|
158
|
-
| `--heater-watts`
|
|
159
|
-
| `--heartbeat-file`
|
|
153
|
+
| CLI Option | Environment Variable | Default | Description |
|
|
154
|
+
| ------------------------ | ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
155
|
+
| `-l, --log-level` | `M2M_LOG_LEVEL` | `info` | Log level: `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` |
|
|
156
|
+
| `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` |
|
|
157
|
+
| `-t, --temperature-unit` | `M2M_TEMPERATURE_UNIT` | `C` | Temperature unit (`C` = Celsius, `F` = Fahrenheit) |
|
|
158
|
+
| `--heater-watts` | `M2M_HEATER_WATTS` | - | Rated wattage of the heaters controlled by each thermostat, as a comma-separated list of `<device>=<watts>` pairs (see [Power reporting](#power-reporting)) |
|
|
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) |
|
|
160
160
|
|
|
161
161
|
### Power reporting
|
|
162
162
|
|
|
@@ -278,12 +278,12 @@ process receives is not the value you typed:
|
|
|
278
278
|
|
|
279
279
|
Taking `pa$w0rd` and `pa#w0rd` as example passwords:
|
|
280
280
|
|
|
281
|
-
| Where the password is set | Gotcha
|
|
282
|
-
| ----------------------------------------- |
|
|
283
|
-
| Shell (`export`, `docker run -e`, `-p …`) | `$` and backticks are expanded inside `"…"`
|
|
284
|
-
| Docker Compose `environment:` | `$` starts an interpolation (`$FOO`, `${FOO}`)
|
|
285
|
-
| Docker Compose `env_file:` | same `$` interpolation as `environment:`
|
|
286
|
-
| `.env` file (read by mysa2mqtt itself) | `$` is safe, but a `#` anywhere in an unquoted value comments the rest out | `M2M_MYSA_PASSWORD="pa#w0rd"`
|
|
281
|
+
| Where the password is set | Gotcha | Write it as |
|
|
282
|
+
| ----------------------------------------- | -------------------------------------------------------------------------- | ------------------------------ |
|
|
283
|
+
| Shell (`export`, `docker run -e`, `-p …`) | `$` and backticks are expanded inside `"…"` | `-p 'pa$w0rd'` (single quotes) |
|
|
284
|
+
| Docker Compose `environment:` | `$` starts an interpolation (`$FOO`, `${FOO}`) | `M2M_MYSA_PASSWORD=pa$$w0rd` |
|
|
285
|
+
| Docker Compose `env_file:` | same `$` interpolation as `environment:` | `M2M_MYSA_PASSWORD=pa$$w0rd` |
|
|
286
|
+
| `.env` file (read by mysa2mqtt itself) | `$` is safe, but a `#` anywhere in an unquoted value comments the rest out | `M2M_MYSA_PASSWORD="pa#w0rd"` |
|
|
287
287
|
|
|
288
288
|
Docker Compose is the most common culprit, and note that `env_file:` does **not** avoid it: Compose interpolates `$` in
|
|
289
289
|
those files too, so `pa$w0rd` silently becomes `pa` plus whatever `$w0rd` expands to (usually nothing). Doubling it to
|
|
@@ -429,8 +429,7 @@ The `mysa2mqtt-capture` tool gathers exactly that. It logs in to your Mysa accou
|
|
|
429
429
|
target device(s), then **passively records every shadow message** to a file until you stop it. It needs no MQTT broker —
|
|
430
430
|
it only reads from Mysa.
|
|
431
431
|
|
|
432
|
-
> [!NOTE]
|
|
433
|
-
> The capture is passive: a device only publishes to its shadow when something changes it. You must exercise the
|
|
432
|
+
> [!NOTE] The capture is passive: a device only publishes to its shadow when something changes it. You must exercise the
|
|
434
433
|
> thermostat from the Mysa mobile app while the capture runs, otherwise nothing is recorded.
|
|
435
434
|
|
|
436
435
|
Run it from a clone of the repository (no build step needed):
|
|
@@ -462,9 +461,8 @@ While it runs, drive the thermostat from the Mysa app so every interaction is re
|
|
|
462
461
|
Pause a few seconds between actions, then press **Ctrl+C**. Attach the resulting file to a
|
|
463
462
|
[GitHub issue](https://github.com/bourquep/mysa2mqtt/issues).
|
|
464
463
|
|
|
465
|
-
> [!IMPORTANT]
|
|
466
|
-
>
|
|
467
|
-
> sharing it publicly. Authentication tokens and your password are **never** included.
|
|
464
|
+
> [!IMPORTANT] The metadata dump can contain account identifiers (`Owner`, `Home`, `AllowedUsers`, `Zone`). Review the
|
|
465
|
+
> file before sharing it publicly. Authentication tokens and your password are **never** included.
|
|
468
466
|
|
|
469
467
|
## Contributing
|
|
470
468
|
|
|
@@ -499,6 +497,6 @@ copyright notice and license text in any copy of the software or substantial por
|
|
|
499
497
|
|
|
500
498
|
## Contributors ✨
|
|
501
499
|
|
|
502
|
-
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors)
|
|
503
|
-
|
|
504
|
-
|
|
500
|
+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. See the
|
|
501
|
+
[contributor list](https://github.com/bourquep/mysa2mqtt#contributors-) in the repository README. Contributions of any
|
|
502
|
+
kind welcome!
|
package/dist/main.js
CHANGED
|
@@ -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
|
|
25
|
+
import{a as R}from"./chunk-QBMDKIY7.js";import{writeFile as ae}from"fs/promises";import{MysaApiClient as re,UnauthenticatedError as ne}from"mysa-js-sdk";import{pino as oe}from"pino";import{Command as Q,InvalidArgumentError as M,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 I(i){let e=parseInt(i,10);if(isNaN(e))throw new M("Must be a number.");return e}function J(i){let e=I(i);if(String(e)!==i.trim())throw new M("Must be a whole number of seconds.");if(e!==0&&e<30)throw new M("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 M(`'${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 M(`'${a}' is missing a device id or name.`);if(!Number.isFinite(m)||m<=0)throw new M(`'${a}' must specify a wattage greater than zero.`);e.set(s.toLowerCase(),m)}return e}var b=z(),Z=`
|
|
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
|
-
`,m=new W("mysa2mqtt").version(R).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",K).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(E).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(J).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(X).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 Z,Sensor as T}from"mqtt2ha";var I=["off","heat"],D=["off","heat","cool","dry","fan_only","auto"],ee={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},P=["auto","low","medium","high","max"],te={1:"auto",3:"low",5:"medium",7:"high",8:"max"},O=3e4,N=3e5,F=Math.ceil(Math.log2(N/O)),C=class{constructor(e,t,a,n,s,c,h,q){this.mysaApiClient=e;this.mysaDevice=t;this.mqttSettings=a;this.logger=n;this.mysaDeviceFirmware=s;this.mysaDeviceSerialNumber=c;this.temperatureUnit=h;this.heaterWatts=q;let f=(h??"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:c},this.mqttOrigin={name:"mysa2mqtt",sw_version:R,support_url:"https://github.com/bourquep/mysa2mqtt"};let u=t.Model.startsWith("AC");this.deviceType=u?"AC":"BB";let w=/-v2-/i.test(t.Model),v=/^INF-/i.test(t.Model),d=!u&&(!(w||v)||q!=null);this.mqttClimate=new Z({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:u?D:I,fan_modes:u?P:void 0,precision:f?.1:1,temp_step:f?.5:1,temperature_unit:"C",optimistic:!0}},u?["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()=>{},u?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(S,g)=>{switch(S){case"mode_command_topic":{let p=g,M=u?D.includes(p)?p:void 0:I.includes(p)?p:void 0;await this.setDeviceState(void 0,M);break}case"power_command_topic":await this.setDeviceState(void 0,g==="OFF"?"off":g==="ON"&&!u?"heat":void 0);break;case"temperature_command_topic":if(g==="")await this.setDeviceState(void 0,void 0);else{let p=parseFloat(g);if(!f){let M=b=>Math.round(b*2)/2,L=(b,$,G)=>Math.min(G,Math.max($,b)),U=M(p);p=L(U,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(p,void 0)}break;case"fan_mode_command_topic":{let p=g,M=P.includes(p)?p:void 0;await this.setDeviceState(void 0,void 0,M);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=v?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=d?y:void 0,this.mqttRetiredPower=d?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 n=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];n!=null&&await this.publishRestState(n),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,n,s,c,h;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=ee[(n=e.TstatMode)==null?void 0:n.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=te[(s=e.FanSpeed)==null?void 0:s.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(c=e.Duty)==null?void 0:c.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(h=e.SetPoint)==null?void 0:h.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(n){this.logger.error("Failed to update Mysa device state",{error:n,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(N,O*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,this.mqttClimate.currentFanMode=e.fanSpeed;break;case"dry":case"fan_only":this.mqttClimate.currentMode=e.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),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(D.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,oe=Math.ceil(Math.log2(H/x)),o=ne({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 se(){o.info("Starting mysa2mqtt...");let i=new ae({username:m.mysaUsername,password:m.mysaPassword},{logger:new A(o.child({module:"mysa-js-sdk"}))}),e=m.heartbeatFile;if(e){let r=0;i.emitter.on("rawRealtimeMessageReceived",()=>{let d=Date.now();d-r<1e4||(r=d,ie(e,`${new Date(d).toISOString()}
|
|
31
|
-
`).catch(y=>{o.warn(y,`Failed to write heartbeat file '${e}'`)}))})}o.info("Logging in..."),await
|
|
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 pe(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 R(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,_=[];for(let n of f)await he(n)?v+=1:_.push(n);if(f.length>0&&v===0)throw new Error("Failed to start any thermostats");for(let n of _)L(n);de(i,f)}function de(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 ce="NotAuthorizedException",le="UserNotFoundException";function ue(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 pe(i){o.debug(`Authenticating with a password of ${d.mysaPassword.length} character(s).`);try{await i.login()}catch(e){let t=e instanceof ne?ue(e):void 0;throw t===ce?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===le?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 he(i){try{return await i.start(),!0}catch(e){return o.error(e,`Failed to start thermostat ${i.mysaDevice.Id}`),!1}}function L(i,e=0){let t=Math.min(e,se),a=Math.min(H,x*2**t);o.info(`Retrying thermostat ${i.mysaDevice.Id} startup in ${a}ms`),setTimeout(()=>{fe(i,e+1)},a)}async function fe(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}`),L(i,e)}}function ye(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 ge(){let t=5e3;for(let a=0;a<=10;a++)try{await me();return}catch(r){(!ye(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)}}ge();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mysa2mqtt",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"commander": "15.0.0",
|
|
55
55
|
"dotenv": "17.4.2",
|
|
56
|
-
"mqtt2ha": "5.
|
|
57
|
-
"mysa-js-sdk": "3.1.
|
|
56
|
+
"mqtt2ha": "5.1.1",
|
|
57
|
+
"mysa-js-sdk": "3.1.1",
|
|
58
58
|
"pino": "10.3.1",
|
|
59
59
|
"pino-pretty": "13.1.3"
|
|
60
60
|
},
|