mysa2mqtt 1.2.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -12
- package/README.md +75 -8
- package/dist/main.js +3 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,26 +1,57 @@
|
|
|
1
1
|
# mysa2mqtt
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- [#199](https://github.com/bourquep/mysa2mqtt/pull/199) [`b384d79`](https://github.com/bourquep/mysa2mqtt/commit/b384d7950d757bc85af7580eaa26435190b47364) Thanks [@bourquep](https://github.com/bourquep)! - mysa2mqtt no longer persists a session file, and re-authenticates automatically when the Mysa session expires instead of crashing with "Refresh Token has expired". The `-s, --mysa-session-file` option and its `M2M_MYSA_SESSION_FILE` environment variable are removed: drop any `session.json` volume mount from your `docker run` command or compose file, and delete the leftover file.
|
|
8
|
+
|
|
9
|
+
### Minor Changes
|
|
10
|
+
|
|
11
|
+
- [#200](https://github.com/bourquep/mysa2mqtt/pull/200) [`61dc2a2`](https://github.com/bourquep/mysa2mqtt/commit/61dc2a2b395397e9e5245098bfd31b49b501fd7d) Thanks [@bourquep](https://github.com/bourquep)! - V2 thermostats can now report power. These devices have no current sensor and only report the duty cycle of their heating relay, which is why their **Current power** sensor has always been unavailable. Set the new `--heater-watts` option (`M2M_HEATER_WATTS`) to the rated wattage of the heaters each thermostat controls — for example `M2M_HEATER_WATTS="Kitchen=1500,<device-id>=750"`, matching devices by name or id — and power is estimated as `duty cycle × rated wattage`. V1 thermostats measure their own current and continue to work with no configuration.
|
|
12
|
+
|
|
13
|
+
The power sensor is now only created for devices that can actually report a value. If you have AC devices, or V2 thermostats for which you have not configured a wattage, their **Current power** entity is removed from Home Assistant on startup; it only ever showed as unavailable.
|
|
14
|
+
|
|
15
|
+
`mqtt2ha` gains a `Discoverable.removeConfig()` method, which clears an entity's retained discovery topic so Home Assistant drops the entity. This is what makes the removal above take effect: because `writeConfig()` retains its payload, an entity published by an earlier run persists until its topic is explicitly cleared.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#201](https://github.com/bourquep/mysa2mqtt/pull/201) [`10ee91c`](https://github.com/bourquep/mysa2mqtt/commit/10ee91c1981b77ef1e9f76abd3d24ba6d9a19d77) Thanks [@bourquep](https://github.com/bourquep)! - A Mysa login rejected for a bad password now reports that a `$` in it is expanded by shells and by Docker Compose (in both `environment:` entries and default-format `env_file:` files, where it must be written `$$`, but not in an `env_file:` declared with `format: raw`), and that an unquoted `#` truncates a `.env` value, instead of surfacing a bare `Incorrect username or password.` stack trace. An unrecognized account gets username-specific guidance instead, since none of the password escaping rules apply to it. Transport and Cognito service failures keep propagating without that guidance, since `UnauthenticatedError` now carries the underlying failure as its `cause`. Debug logging also reports the length of the password that was actually received -- never the password or the account it belongs to -- so a mangled value can be spotted at a glance.
|
|
20
|
+
|
|
21
|
+
- [#192](https://github.com/bourquep/mysa2mqtt/pull/192) [`4917fd0`](https://github.com/bourquep/mysa2mqtt/commit/4917fd0166244c168b95abf7d87ad09815e02233) Thanks [@vavallee](https://github.com/vavallee)! - PinoLogger no longer passes the first metadata value twice (once as pino's merge object and again as an interpolation argument), and falsy-but-valid values like 0 or an empty string are now forwarded instead of being routed through the null branch.
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [[`b384d79`](https://github.com/bourquep/mysa2mqtt/commit/b384d7950d757bc85af7580eaa26435190b47364), [`4911b04`](https://github.com/bourquep/mysa2mqtt/commit/4911b04c15349e6d508717bf0880346fa1ed9b80), [`10ee91c`](https://github.com/bourquep/mysa2mqtt/commit/10ee91c1981b77ef1e9f76abd3d24ba6d9a19d77), [`7169263`](https://github.com/bourquep/mysa2mqtt/commit/7169263fcb4b2aeb51c7eae4c112972c3e2fdb08), [`527ef25`](https://github.com/bourquep/mysa2mqtt/commit/527ef25886aad766a2fd71fc92002d8de126b364), [`61dc2a2`](https://github.com/bourquep/mysa2mqtt/commit/61dc2a2b395397e9e5245098bfd31b49b501fd7d)]:
|
|
24
|
+
- mysa-js-sdk@3.0.0
|
|
25
|
+
- mqtt2ha@4.2.0
|
|
26
|
+
|
|
27
|
+
## 1.3.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- [#183](https://github.com/bourquep/mysa2mqtt/pull/183) [`604a3b7`](https://github.com/bourquep/mysa2mqtt/commit/604a3b7df903d09f672b5fe30bacd663d1e9fe1f) Thanks [@vavallee](https://github.com/vavallee)! - Added `--heartbeat-file` / `M2M_HEARTBEAT_FILE`: when set, mysa2mqtt touches the given file on every message received from the Mysa cloud (throttled to one write per 10 seconds). External supervisors can watch the file's mtime to detect a wedged cloud connection and restart the process — for example a Kubernetes exec liveness probe checking that the file is fresher than 15 minutes.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- [#186](https://github.com/bourquep/mysa2mqtt/pull/186) [`ed84637`](https://github.com/bourquep/mysa2mqtt/commit/ed846373e866625f5c74ca8e98d110954595515b) Thanks [@vavallee](https://github.com/vavallee)! - Apply state changes that arrive without an operating mode instead of silently dropping them. Home Assistant no longer shows a stale target temperature (or fan speed) when Mysa pushes a modeless update.
|
|
36
|
+
|
|
37
|
+
- Updated dependencies [[`7affd92`](https://github.com/bourquep/mysa2mqtt/commit/7affd92614ee6f8ac160afacae7c7ea1c3a2a9e9), [`21991c0`](https://github.com/bourquep/mysa2mqtt/commit/21991c0731cb888dc69d15b3b0dc164aee4992f7)]:
|
|
38
|
+
- mqtt2ha@4.1.5
|
|
39
|
+
- mysa-js-sdk@2.1.2
|
|
40
|
+
|
|
3
41
|
## 1.2.4
|
|
4
42
|
|
|
5
43
|
### Patch Changes
|
|
6
44
|
|
|
7
|
-
- [#149](https://github.com/bourquep/mysa2mqtt/pull/149)
|
|
8
|
-
[`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4) Thanks
|
|
9
|
-
[@bourquep](https://github.com/bourquep)! - Moved development into the
|
|
10
|
-
[mysa2mqtt monorepo](https://github.com/bourquep/mysa2mqtt).
|
|
45
|
+
- [#149](https://github.com/bourquep/mysa2mqtt/pull/149) [`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4) Thanks [@bourquep](https://github.com/bourquep)! - Moved development into the [mysa2mqtt monorepo](https://github.com/bourquep/mysa2mqtt).
|
|
11
46
|
|
|
12
|
-
There are no functional changes in this release. The package's repository and homepage links now point at the
|
|
13
|
-
monorepo, and issues for all three packages are tracked at https://github.com/bourquep/mysa2mqtt/issues.
|
|
47
|
+
There are no functional changes in this release. The package's repository and homepage links now point at the monorepo, and issues for all three packages are tracked at https://github.com/bourquep/mysa2mqtt/issues.
|
|
14
48
|
|
|
15
|
-
- Updated dependencies
|
|
16
|
-
[[`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4)]:
|
|
49
|
+
- Updated dependencies [[`89e2950`](https://github.com/bourquep/mysa2mqtt/commit/89e2950c4874db14ea9b682380c63984aaf7a9f4)]:
|
|
17
50
|
- mysa-js-sdk@2.1.1
|
|
18
51
|
- mqtt2ha@4.1.4
|
|
19
52
|
|
|
20
53
|
## Releases prior to 1.2.3
|
|
21
54
|
|
|
22
|
-
This package previously lived in a standalone repository and used semantic-release, which published its release notes to
|
|
23
|
-
GitHub Releases rather than to a changelog file.
|
|
55
|
+
This package previously lived in a standalone repository and used semantic-release, which published its release notes to GitHub Releases rather than to a changelog file.
|
|
24
56
|
|
|
25
|
-
See the [release history](https://github.com/bourquep/mysa2mqtt/releases) for notes on versions up to and including
|
|
26
|
-
1.2.3. Those releases are tagged `v1.2.3`; releases from the monorepo onwards are tagged `mysa2mqtt@<version>`.
|
|
57
|
+
See the [release history](https://github.com/bourquep/mysa2mqtt/releases) for notes on versions up to and including 1.2.3. Those releases are tagged `v1.2.3`; releases from the monorepo onwards are tagged `mysa2mqtt@<version>`.
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ 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
|
-
- **
|
|
16
|
+
- **Self-healing Authentication**: Re-authenticates automatically when the Mysa session expires, with no state to persist
|
|
17
17
|
- **Configurable Logging**: Support for JSON and pretty-printed log formats with adjustable levels
|
|
18
18
|
|
|
19
19
|
## Supported hardware
|
|
@@ -22,7 +22,7 @@ home automation platforms.
|
|
|
22
22
|
| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
23
23
|
| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working |
|
|
24
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 report power
|
|
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)) |
|
|
26
26
|
| `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested |
|
|
27
27
|
| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions |
|
|
28
28
|
|
|
@@ -154,8 +154,35 @@ take precedence over command-line defaults.
|
|
|
154
154
|
| ------------------------- | ----------------------- | -------------- | ----------------------------------------------------------------------- |
|
|
155
155
|
| `-l, --log-level` | `M2M_LOG_LEVEL` | `info` | Log level: `silent`, `fatal`, `error`, `warn`, `info`, `debug`, `trace` |
|
|
156
156
|
| `-f, --log-format` | `M2M_LOG_FORMAT` | `pretty` | Log format: `pretty`, `json` |
|
|
157
|
-
| `-s, --mysa-session-file` | `M2M_MYSA_SESSION_FILE` | `session.json` | Path to Mysa session file |
|
|
158
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
|
+
|
|
161
|
+
### Power reporting
|
|
162
|
+
|
|
163
|
+
V1 baseboard thermostats measure their own current draw, so their **Current power** sensor works with no extra
|
|
164
|
+
configuration.
|
|
165
|
+
|
|
166
|
+
**V2 thermostats (including V2 Lite) have no current sensor.** They only report the duty cycle of their heating relay,
|
|
167
|
+
so power can only be estimated as `duty cycle × the rated wattage of the attached heaters`. Because that rating is a
|
|
168
|
+
property of your heaters and not of the thermostat, you have to supply it:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
M2M_HEATER_WATTS="Kitchen=1500,<device-id>=750"
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Each entry maps a device — by name or by device id, both case-insensitive — to the total wattage of the heaters that
|
|
175
|
+
thermostat controls. You can find both in the logs at startup.
|
|
176
|
+
|
|
177
|
+
A few things to be aware of:
|
|
178
|
+
|
|
179
|
+
- The **Current power** sensor is only created for devices that can actually report power. V2 thermostats you have not
|
|
180
|
+
configured, and AC devices (which report neither current nor duty cycle), get no power entity at all.
|
|
181
|
+
- The reported value is an estimate. The duty cycle reflects whether the relay is energized right now, so the sensor
|
|
182
|
+
swings between 0 W and the full rated wattage rather than easing between them. Over time it still integrates to a
|
|
183
|
+
reasonable energy total in Home Assistant, but instantaneous readings are coarse.
|
|
184
|
+
- Do not use the thermostat's own maximum current rating here. That figure describes what the thermostat is rated to
|
|
185
|
+
switch, which is typically several times more than the heaters connected to it.
|
|
159
186
|
|
|
160
187
|
## Usage Examples
|
|
161
188
|
|
|
@@ -225,8 +252,12 @@ When using Home Assistant, devices will be automatically discovered and appear i
|
|
|
225
252
|
|
|
226
253
|
1. **Authentication Failures**
|
|
227
254
|
- Verify your Mysa username and password
|
|
228
|
-
-
|
|
229
|
-
-
|
|
255
|
+
- Confirm the same credentials work in the Mysa mobile app
|
|
256
|
+
- mysa2mqtt re-authenticates on its own when the session expires, so a persistent failure usually means either
|
|
257
|
+
invalid credentials or that it cannot reach Cognito or the Mysa API — check the application logs and your network,
|
|
258
|
+
and confirm the Mysa service itself is available
|
|
259
|
+
- If your password contains special characters, see
|
|
260
|
+
[Passwords with special characters](#passwords-with-special-characters) below
|
|
230
261
|
|
|
231
262
|
2. **MQTT Connection Issues**
|
|
232
263
|
- Verify MQTT broker hostname and port
|
|
@@ -238,6 +269,45 @@ When using Home Assistant, devices will be automatically discovered and appear i
|
|
|
238
269
|
- Check logs for API errors
|
|
239
270
|
- Verify your Mysa account has active devices
|
|
240
271
|
|
|
272
|
+
### Passwords with special characters
|
|
273
|
+
|
|
274
|
+
`Incorrect username or password.` when the very same credentials work in the Mysa app almost always means the password
|
|
275
|
+
was altered on its way into mysa2mqtt. Every layer that can carry it treats some characters specially, so the value the
|
|
276
|
+
process receives is not the value you typed:
|
|
277
|
+
|
|
278
|
+
Taking `pa$w0rd` and `pa#w0rd` as example passwords:
|
|
279
|
+
|
|
280
|
+
| Where the password is set | Gotcha | Write it as |
|
|
281
|
+
| ----------------------------------------- | ------------------------------------------------------------ | ----------------------------- |
|
|
282
|
+
| Shell (`export`, `docker run -e`, `-p …`) | `$` and backticks are expanded inside `"…"` | `-p 'pa$w0rd'` (single quotes) |
|
|
283
|
+
| Docker Compose `environment:` | `$` starts an interpolation (`$FOO`, `${FOO}`) | `M2M_MYSA_PASSWORD=pa$$w0rd` |
|
|
284
|
+
| Docker Compose `env_file:` | same `$` interpolation as `environment:` | `M2M_MYSA_PASSWORD=pa$$w0rd` |
|
|
285
|
+
| `.env` file (read by mysa2mqtt itself) | `$` is safe, but a `#` anywhere in an unquoted value comments the rest out | `M2M_MYSA_PASSWORD="pa#w0rd"` |
|
|
286
|
+
|
|
287
|
+
Docker Compose is the most common culprit, and note that `env_file:` does **not** avoid it: Compose interpolates `$` in
|
|
288
|
+
those files too, so `pa$w0rd` silently becomes `pa` plus whatever `$w0rd` expands to (usually nothing). Doubling it to
|
|
289
|
+
`$$` passes a literal `$` through in both places.
|
|
290
|
+
|
|
291
|
+
The `#` rules differ between the two file formats, so a password that works in one may break in the other. Compose's
|
|
292
|
+
`env_file:` only treats `#` as a comment when whitespace precedes it, leaving `pa#w0rd` intact; the `.env` file
|
|
293
|
+
mysa2mqtt loads itself is parsed by [dotenv](https://github.com/motdotla/dotenv), which truncates `pa#w0rd` to `pa`.
|
|
294
|
+
Quoting the value is safe in both.
|
|
295
|
+
|
|
296
|
+
On Compose v2.30 and later you can opt an `env_file` out of both rules with `format: raw`, which passes every line
|
|
297
|
+
through verbatim — and therefore expects the value **unquoted** and `$` **undoubled**:
|
|
298
|
+
|
|
299
|
+
```yaml
|
|
300
|
+
services:
|
|
301
|
+
mysa2mqtt:
|
|
302
|
+
env_file:
|
|
303
|
+
- path: secrets.env
|
|
304
|
+
format: raw
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
To confirm what actually arrived, run with `--log-level debug`: mysa2mqtt logs the length of the password it received
|
|
308
|
+
(never the password itself). If that length does not match your real password, the value is being mangled by one of the
|
|
309
|
+
layers above rather than rejected by Mysa.
|
|
310
|
+
|
|
241
311
|
### Debug Mode
|
|
242
312
|
|
|
243
313
|
Enable debug logging to get more detailed information:
|
|
@@ -282,7 +352,6 @@ docker run -d --name mysa2mqtt \
|
|
|
282
352
|
-e M2M_MYSA_USERNAME=your-email \
|
|
283
353
|
-e M2M_MYSA_PASSWORD=your-password \
|
|
284
354
|
-e M2M_LOG_LEVEL=info \
|
|
285
|
-
-v $(pwd)/session.json:/app/session.json \
|
|
286
355
|
bourquep/mysa2mqtt:latest
|
|
287
356
|
```
|
|
288
357
|
|
|
@@ -340,8 +409,6 @@ services:
|
|
|
340
409
|
- M2M_MYSA_USERNAME=your-email
|
|
341
410
|
- M2M_MYSA_PASSWORD=your-password
|
|
342
411
|
- M2M_LOG_LEVEL=info
|
|
343
|
-
volumes:
|
|
344
|
-
- ./session.json:/app/session.json
|
|
345
412
|
```
|
|
346
413
|
|
|
347
414
|
Then run:
|
package/dist/main.js
CHANGED
|
@@ -22,9 +22,10 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
|
22
22
|
SOFTWARE.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import{
|
|
25
|
+
import{writeFile as Z}from"fs/promises";import{MysaApiClient as ee,UnauthenticatedError as te}from"mysa-js-sdk";import{pino as ie}from"pino";var v=class{constructor(e){this.logger=e}logger;debug(e,...t){this.logger.debug(t.at(0)??null,e,...t.slice(1))}info(e,...t){this.logger.info(t.at(0)??null,e,...t.slice(1))}warn(e,...t){this.logger.warn(t.at(0)??null,e,...t.slice(1))}error(e,...t){this.logger.error(t.at(0)??null,e,...t.slice(1))}};import{Command as U,InvalidArgumentError as T,Option as u}from"commander";import{configDotenv as $}from"dotenv";import{readFileSync as G}from"fs";import{dirname as Q,join as W}from"path";import{fileURLToPath as Y}from"url";$({path:[".env",".env.local"],override:!0});function B(){try{let i=Y(import.meta.url),e=Q(i),t=W(e,"..","package.json");return JSON.parse(G(t,"utf-8")).version||"unknown"}catch{return"unknown"}}function j(i){let e=parseInt(i,10);if(isNaN(e))throw new T("Must be a number.");return e}function V(i){let e=new Map;for(let t of i.split(",")){let a=t.trim();if(a.length===0)continue;let c=a.lastIndexOf("=");if(c<0)throw new T(`'${a}' is not a <device>=<watts> pair.`);let p=a.slice(0,c).trim(),l=Number(a.slice(c+1).trim());if(p.length===0)throw new T(`'${a}' is missing a device id or name.`);if(!Number.isFinite(l)||l<=0)throw new T(`'${a}' must specify a wattage greater than zero.`);e.set(p.toLowerCase(),l)}return e}var q=B(),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
|
-
`,s=new L("mysa2mqtt").version(v).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",$).addOption(new m("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new m("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new m("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new m("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(B).default(1883).helpGroup("MQTT")).addOption(new m("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new m("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new m("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new m("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new m("-s, --mysa-session-file <mysaSessionFile>","Mysa session file").env("M2M_MYSA_SESSION_FILE").default("session.json").helpGroup("Configuration")).addOption(new m("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new m("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new m("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).parse().opts();import{readFile as J,rm as V,writeFile as X}from"fs/promises";async function w(a,t){try{t.info("Loading Mysa session...");let e=await J(a,"utf8");return JSON.parse(e)}catch{t.info("No valid Mysa session file found.")}}async function q(a,t,e){if(a)e.info("Saving Mysa session..."),await X(t,JSON.stringify(a));else try{e.debug("Removing Mysa session file..."),await V(t)}catch{}}import{Climate as W,Sensor as S}from"mqtt2ha";var C=["off","heat"],T=["off","heat","cool","dry","fan_only","auto"],z={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},A=["auto","low","medium","high","max"],K={1:"auto",3:"low",5:"medium",7:"high",8:"max"},D=3e4,b=3e5,R=Math.ceil(Math.log2(b/D)),_=class{constructor(t,e,i,c,l,u,p){this.mysaApiClient=t;this.mysaDevice=e;this.mqttSettings=i;this.logger=c;this.mysaDeviceFirmware=l;this.mysaDeviceSerialNumber=u;this.temperatureUnit=p;let o=(p??"C")==="C";this.mqttDevice={identifiers:e.Id,name:e.Name,manufacturer:"Mysa",model:e.Model,sw_version:l==null?void 0:l.InstalledVersion,serial_number:u},this.mqttOrigin={name:"mysa2mqtt",sw_version:v,support_url:"https://github.com/bourquep/mysa2mqtt"};let r=e.Model.startsWith("AC");this.deviceType=r?"AC":"BB",this.mqttClimate=new W({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_climate`,name:"Thermostat",min_temp:e.MinSetpoint,max_temp:e.MaxSetpoint,modes:r?T:C,fan_modes:r?A:void 0,precision:o?.1:1,temp_step:o?.5:1,temperature_unit:"C",optimistic:!0}},r?["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic","fan_mode_state_topic"]:["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic"],async()=>{},r?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(h,y)=>{switch(h){case"mode_command_topic":{let d=y,f=r?T.includes(d)?d:void 0:C.includes(d)?d:void 0;await this.setDeviceState(void 0,f);break}case"power_command_topic":await this.setDeviceState(void 0,y==="OFF"?"off":y==="ON"&&!r?"heat":void 0);break;case"temperature_command_topic":if(y==="")await this.setDeviceState(void 0,void 0);else{let d=parseFloat(y);if(!o){let f=M=>Math.round(M*2)/2,F=(M,N,x)=>Math.min(x,Math.max(N,M)),P=f(d);d=F(P,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(d,void 0)}break;case"fan_mode_command_topic":{let d=y,f=A.includes(d)?d:void 0;await this.setDeviceState(void 0,void 0,f);break}}}),this.mqttTemperature=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:o?.1:0,force_update:!0}}),this.mqttHumidity=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}}),this.mqttPower=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}})}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttHumidity;mqttPower;mysaStatusUpdateHandler=t=>{this.handleMysaStatusUpdate(t).catch(e=>{this.logger.error("Failed to handle Mysa status update",{error:e,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=t=>{this.handleMysaStateChange(t).catch(e=>{this.logger.error("Failed to handle Mysa state change",{error:e,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var t,e,i,c,l,u;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let o=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];this.mqttClimate.currentTemperature=(t=o.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(e=o.Humidity)==null?void 0:e.v,this.mqttClimate.currentMode=z[(i=o.TstatMode)==null?void 0:i.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=K[(c=o.FanSpeed)==null?void 0:c.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(l=o.Duty)==null?void 0:l.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(u=o.SetPoint)==null?void 0:u.v:void 0,await this.mqttClimate.writeConfig(),await this.mqttTemperature.setState("state_topic",o.CorrectedTemp!=null?o.CorrectedTemp.v.toFixed(2):"None"),await this.mqttTemperature.writeConfig(),await this.mqttHumidity.setState("state_topic",o.Humidity!=null?o.Humidity.v.toFixed(2):"None"),await this.mqttHumidity.writeConfig(),await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig(),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(p){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),p}}}async stop(){this.isStarted&&(this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),await this.stopRealtimeUpdates(),this.mysaApiClient.emitter.off("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.off("stateChanged",this.mysaStateChangeHandler),await this.mqttPower.setState("state_topic","None"),await this.mqttTemperature.setState("state_topic","None"),await this.mqttHumidity.setState("state_topic","None"))}async setDeviceState(t,e,i){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,t,e,i)}catch(c){this.logger.error("Failed to update Mysa device state",{error:c,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let t=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||t!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(e){this.isStarted&&t===this.realtimeGeneration&&this.scheduleRealtimeRetry(e)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(t){this.logger.warn("Failed to stop realtime updates",{error:t,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(t){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let e=Math.min(this.realtimeRetryAttempt,R),i=Math.min(b,D*2**e);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,R),this.logger.error("Failed to start realtime updates; retrying",{error:t,deviceId:this.mysaDevice.Id,retryDelayMs:i}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},i)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(t.current,t.dutyCycle),this.mqttClimate.currentTemperature=t.temperature,this.mqttClimate.currentHumidity=t.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?t.setPoint:void 0,this.mysaDevice.Voltage!=null&&t.current!=null){let e=this.mysaDevice.Voltage*t.current;await this.mqttPower.setState("state_topic",e.toFixed(2))}else await this.mqttPower.setState("state_topic","None");await this.mqttTemperature.setState("state_topic",t.temperature.toFixed(2)),await this.mqttHumidity.setState("state_topic",t.humidity.toFixed(2))}}async handleMysaStateChange(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id))switch(t.mode){case"off":this.mqttClimate.currentMode="off",this.mqttClimate.currentAction="off",this.mqttClimate.targetTemperature=void 0,this.mqttClimate.currentFanMode=void 0;break;case"heat":case"cool":case"auto":this.mqttClimate.currentMode=t.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=t.setPoint,this.mqttClimate.currentFanMode=t.fanSpeed;break;case"dry":case"fan_only":this.mqttClimate.currentMode=t.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),this.mqttClimate.currentFanMode=t.fanSpeed;break}}computeCurrentAction(t,e){let i=this.mqttClimate.currentMode;switch(T.includes(i)?i:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?t!=null?t>0?"heating":"idle":(e??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var I=3e4,E=3e5,et=Math.ceil(Math.log2(E/I)),n=tt({name:"mysa2mqtt",level:s.logLevel,transport:s.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function it(){n.info("Starting mysa2mqtt...");let a=await w(s.mysaSessionFile,n),t=new Z(a,{logger:new g(n.child({module:"mysa-js-sdk"}))});t.emitter.on("sessionChanged",async r=>{await q(r,s.mysaSessionFile,n)}),t.isAuthenticated||(n.info("Logging in..."),await t.login(s.mysaUsername,s.mysaPassword)),n.debug("Fetching devices and firmwares...");let[e,i]=await Promise.all([t.getDevices(),t.getDeviceFirmwares()]);n.debug("Fetching serial numbers...");let c=new Map;for(let[r]of Object.entries(e.DevicesObj))try{let h=await t.getDeviceSerialNumber(r);h&&c.set(r,h)}catch(h){n.error(h,`Failed to retrieve serial number for device ${r}`)}n.debug("Initializing MQTT entities...");let l={host:s.mqttHost,port:s.mqttPort,username:s.mqttUsername,password:s.mqttPassword,client_name:s.mqttClientName,state_prefix:s.mqttTopicPrefix},u=Object.entries(e.DevicesObj).map(([,r])=>new _(t,r,l,new g(n.child({module:"thermostat",deviceId:r.Id})),i.Firmware[r.Id],c.get(r.Id),s.temperatureUnit)),p=0,o=[];for(let r of u)await at(r)?p+=1:o.push(r);if(u.length>0&&p===0)throw new Error("Failed to start any thermostats");for(let r of o)O(r)}async function at(a){try{return await a.start(),!0}catch(t){return n.error(t,`Failed to start thermostat ${a.mysaDevice.Id}`),!1}}function O(a,t=0){let e=Math.min(t,et),i=Math.min(E,I*2**e);n.info(`Retrying thermostat ${a.mysaDevice.Id} startup in ${i}ms`),setTimeout(()=>{rt(a,t+1)},i)}async function rt(a,t){try{await a.start(),n.info(`Started thermostat ${a.mysaDevice.Id} after retry`)}catch(e){n.error(e,`Failed to start thermostat ${a.mysaDevice.Id}`),O(a,t)}}it().catch(a=>{n.fatal(a,"Unexpected error"),process.exit(1)});
|
|
30
|
+
`,s=new U("mysa2mqtt").version(q).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",z).addOption(new u("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new u("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new u("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new u("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(j).default(1883).helpGroup("MQTT")).addOption(new u("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new u("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new u("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new u("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new u("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new u("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new u("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).addOption(new u("--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(V).helpGroup("Configuration")).addOption(new u("--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 X,Sensor as A}from"mqtt2ha";var R=["off","heat"],b=["off","heat","cool","dry","fan_only","auto"],J={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},D=["auto","low","medium","high","max"],K={1:"auto",3:"low",5:"medium",7:"high",8:"max"},I=3e4,O=3e5,E=Math.ceil(Math.log2(O/I)),C=class{constructor(e,t,a,c,p,l,y,g){this.mysaApiClient=e;this.mysaDevice=t;this.mqttSettings=a;this.logger=c;this.mysaDeviceFirmware=p;this.mysaDeviceSerialNumber=l;this.temperatureUnit=y;this.heaterWatts=g;let n=(y??"C")==="C";this.mqttDevice={identifiers:t.Id,name:t.Name,manufacturer:"Mysa",model:t.Model,sw_version:p==null?void 0:p.InstalledVersion,serial_number:l},this.mqttOrigin={name:"mysa2mqtt",sw_version:q,support_url:"https://github.com/bourquep/mysa2mqtt"};let f=t.Model.startsWith("AC");this.deviceType=f?"AC":"BB";let w=/-v2-/i.test(t.Model),_=!f&&(!w||g!=null);this.mqttClimate=new X({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:f?b:R,fan_modes:f?D:void 0,precision:n?.1:1,temp_step:n?.5:1,temperature_unit:"C",optimistic:!0}},f?["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()=>{},f?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(m,h)=>{switch(m){case"mode_command_topic":{let d=h,M=f?b.includes(d)?d:void 0:R.includes(d)?d:void 0;await this.setDeviceState(void 0,M);break}case"power_command_topic":await this.setDeviceState(void 0,h==="OFF"?"off":h==="ON"&&!f?"heat":void 0);break;case"temperature_command_topic":if(h==="")await this.setDeviceState(void 0,void 0);else{let d=parseFloat(h);if(!n){let M=S=>Math.round(S*2)/2,x=(S,H,L)=>Math.min(L,Math.max(H,S)),k=M(d);d=x(k,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(d,void 0)}break;case"fan_mode_command_topic":{let d=h,M=D.includes(d)?d:void 0;await this.setDeviceState(void 0,void 0,M);break}}}),this.mqttTemperature=new A({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:n?.1:0,force_update:!0}}),this.mqttHumidity=new A({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 r=new A({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=_?r:void 0,this.mqttRetiredPower=_?void 0:r}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;heaterWatts;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;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,a,c,p,l,y;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let n=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];this.mqttClimate.currentTemperature=(e=n.CorrectedTemp)==null?void 0:e.v,this.mqttClimate.currentHumidity=(t=n.Humidity)==null?void 0:t.v,this.mqttClimate.currentMode=J[(a=n.TstatMode)==null?void 0:a.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=K[(c=n.FanSpeed)==null?void 0:c.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(p=n.Duty)==null?void 0:p.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(l=n.SetPoint)==null?void 0:l.v:void 0,await this.mqttClimate.writeConfig(),await this.mqttTemperature.setState("state_topic",n.CorrectedTemp!=null?n.CorrectedTemp.v.toFixed(2):"None"),await this.mqttTemperature.writeConfig(),await this.mqttHumidity.setState("state_topic",n.Humidity!=null?n.Humidity.v.toFixed(2):"None"),await this.mqttHumidity.writeConfig(),this.mqttPower!=null&&(await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig()),await((y=this.mqttRetiredPower)==null?void 0:y.removeConfig()),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(g){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),g}}}async stop(){var e;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 this.mqttHumidity.setState("state_topic","None"))}async setDeviceState(e,t,a){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,e,t,a)}catch(c){this.logger.error("Failed to update Mysa device state",{error:c,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,E),a=Math.min(O,I*2**t);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,E),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){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 t=this.computeWatts(e);this.logger.debug("Computed power draw",{current:e.current,dutyCycle:e.dutyCycle,watts:t}),await this.mqttPower.setState("state_topic",t!=null?t.toFixed(2):"None")}await this.mqttTemperature.setState("state_topic",e.temperature.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(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 P=3e4,F=3e5,ae=Math.ceil(Math.log2(F/P)),o=ie({name:"mysa2mqtt",level:s.logLevel,transport:s.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function re(){o.info("Starting mysa2mqtt...");let i=new ee({username:s.mysaUsername,password:s.mysaPassword},{logger:new v(o.child({module:"mysa-js-sdk"}))}),e=s.heartbeatFile;if(e){let r=0;i.emitter.on("rawRealtimeMessageReceived",()=>{let m=Date.now();m-r<1e4||(r=m,Z(e,`${new Date(m).toISOString()}
|
|
31
|
+
`).catch(h=>{o.warn(h,`Failed to write heartbeat file '${e}'`)}))})}o.info("Logging in..."),await me(i),o.debug("Fetching devices and firmwares...");let[t,a]=await Promise.all([i.getDevices(),i.getDeviceFirmwares()]);o.debug("Fetching serial numbers...");let c=new Map;for(let[r]of Object.entries(t.DevicesObj))try{let m=await i.getDeviceSerialNumber(r);m&&c.set(r,m)}catch(m){o.error(m,`Failed to retrieve serial number for device ${r}`)}o.debug("Initializing MQTT entities...");let p={host:s.mqttHost,port:s.mqttPort,username:s.mqttUsername,password:s.mqttPassword,client_name:s.mqttClientName,state_prefix:s.mqttTopicPrefix},l=s.heaterWatts??new Map,y=new Set;function g(r){for(let m of[r.Id,r.Name]){let h=m==null?void 0:m.toLowerCase();if(h==null)continue;let d=l.get(h);if(d!=null)return y.add(h),d}}let n=Object.entries(t.DevicesObj).map(([,r])=>new C(i,r,p,new v(o.child({module:"thermostat",deviceId:r.Id})),a.Firmware[r.Id],c.get(r.Id),s.temperatureUnit,g(r))),f=l.size-y.size;f>0&&o.warn(`${f} of the ${l.size} heater wattage entries match no device id or name. Check the configured names and ids against your devices.`);let w=0,_=[];for(let r of n)await de(r)?w+=1:_.push(r);if(n.length>0&&w===0)throw new Error("Failed to start any thermostats");for(let r of _)N(r)}var ne="NotAuthorizedException",oe="UserNotFoundException";function se(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 me(i){o.debug(`Authenticating with a password of ${s.mysaPassword.length} character(s).`);try{await i.login()}catch(e){let t=e instanceof te?se(e):void 0;throw t===ne?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===oe?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 de(i){try{return await i.start(),!0}catch(e){return o.error(e,`Failed to start thermostat ${i.mysaDevice.Id}`),!1}}function N(i,e=0){let t=Math.min(e,ae),a=Math.min(F,P*2**t);o.info(`Retrying thermostat ${i.mysaDevice.Id} startup in ${a}ms`),setTimeout(()=>{ce(i,e+1)},a)}async function ce(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}`),N(i,e)}}re().catch(i=>{o.fatal(i,"Unexpected error"),process.exit(1)});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mysa2mqtt",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"commander": "15.0.0",
|
|
53
53
|
"dotenv": "17.4.2",
|
|
54
|
-
"mqtt2ha": "4.
|
|
55
|
-
"mysa-js-sdk": "
|
|
54
|
+
"mqtt2ha": "4.2.0",
|
|
55
|
+
"mysa-js-sdk": "3.0.0",
|
|
56
56
|
"pino": "10.3.1",
|
|
57
57
|
"pino-pretty": "13.1.3"
|
|
58
58
|
},
|