mysa2mqtt 1.3.0 → 3.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 CHANGED
@@ -1,5 +1,62 @@
1
1
  # mysa2mqtt
2
2
 
3
+ ## 3.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - [#206](https://github.com/bourquep/mysa2mqtt/pull/206) [`49fb518`](https://github.com/bourquep/mysa2mqtt/commit/49fb5186e383fb112240a87c6bdeb0fd23712a63) Thanks [@bourquep](https://github.com/bourquep)! - Fix colliding MQTT discovery topics produced by `cleanString` ([#153](https://github.com/bourquep/mysa2mqtt/issues/153)).
8
+
9
+ Previously every unsupported character was replaced with a single hyphen, so distinct inputs collided (`cleanString('a/b') === cleanString('a b')`). Two entities whose names differed only in punctuation received the **same** discovery topic and silently overwrote each other in Home Assistant.
10
+
11
+ `cleanString` now uses a reversible, collision-free percent-style encoding: alphanumerics and underscores pass through unchanged, while every other character (including a literal hyphen) is escaped as `-XX`, where `XX` is the uppercase hex value of each UTF-8 byte. A hyphen is used as the escape sigil instead of `%` because Home Assistant only accepts `[A-Za-z0-9_-]` in discovery `node_id`/`object_id` segments.
12
+
13
+ **Breaking change / migration:** any topic segment derived from a device or entity name that contained characters outside `[A-Za-z0-9_]` will now have a different name (e.g. `Living-Room` becomes `Living-20Room`). Home Assistant will create new entities under the new topics. After upgrading, delete the now-orphaned MQTT devices/entities from Home Assistant (Settings → Devices & services → MQTT) so the stale duplicates are removed.
14
+
15
+ ### Minor Changes
16
+
17
+ - [#211](https://github.com/bourquep/mysa2mqtt/pull/211) [`5dc3270`](https://github.com/bourquep/mysa2mqtt/commit/5dc32709beee8fa7caf487f422c0ed97ebc7c4a2) Thanks [@bourquep](https://github.com/bourquep)! - Add in-floor heating thermostat (INF-V1-0) support ([#94](https://github.com/bourquep/mysa2mqtt/issues/94)).
18
+
19
+ - Publish a **Floor temperature** sensor for in-floor thermostats, reflecting the floor-probe reading. The ambient air temperature remains the climate's current temperature.
20
+ - Estimate power draw for in-floor thermostats from the `--heater-watts` rating (they report a heating-relay state rather than a current draw), gating the **Current power** sensor on that configuration just like V2 thermostats.
21
+
22
+ - [#208](https://github.com/bourquep/mysa2mqtt/pull/208) [`ebde3d2`](https://github.com/bourquep/mysa2mqtt/commit/ebde3d2319a906f7a933096c655de537e1faf0fe) Thanks [@bourquep](https://github.com/bourquep)! - Poll device state over REST periodically so Home Assistant stays current even when the real-time AWS IoT connection cannot be established (e.g. all-Lite fleets, whose WebSocket handshake fails with `AWS_ERROR_HTTP_WEBSOCKET_UPGRADE_FAILURE`) or is chronically unstable (e.g. INF-V1). Previously these fleets only ever received the single state snapshot taken at startup, then froze.
23
+
24
+ A single account-wide poll refreshes every thermostat, so the request cost does not grow with fleet size. Configure the cadence with `--poll-interval-seconds` (`M2M_POLL_INTERVAL_SECONDS`), which defaults to 60 seconds; set it to 0 to disable, or to at least 30.
25
+
26
+ - [#212](https://github.com/bourquep/mysa2mqtt/pull/212) [`a1000a7`](https://github.com/bourquep/mysa2mqtt/commit/a1000a7475c4487931edddab678ace6558a1d0e7) Thanks [@bourquep](https://github.com/bourquep)! - Add a `mysa2mqtt-capture` tool (and the underlying `MysaApiClient.startRawTopicCapture()` SDK method) to record the raw AWS IoT Device Shadow traffic of unsupported thermostats, most notably the central-HVAC ST-V1.
27
+
28
+ Unlike the real-time path, `startRawTopicCapture()` subscribes to arbitrary MQTT topic filters and relays every message verbatim (full topic + decoded payload) with no parsing, re-subscribing across reconnects. The `mysa2mqtt-capture` command uses it to dump a device's REST metadata and passively record every shadow message to a file, providing the raw material needed to implement support for a new device family. Run `npm run capture -w mysa2mqtt -- --help` for usage.
29
+
30
+ ### Patch Changes
31
+
32
+ - Updated dependencies [[`b5bf8f9`](https://github.com/bourquep/mysa2mqtt/commit/b5bf8f922c90a2342466e9ea1ba7e398ff0cd5d6), [`3229264`](https://github.com/bourquep/mysa2mqtt/commit/32292646a27ea8d58a43864bb9255553114df4b7), [`49fb518`](https://github.com/bourquep/mysa2mqtt/commit/49fb5186e383fb112240a87c6bdeb0fd23712a63), [`5dc3270`](https://github.com/bourquep/mysa2mqtt/commit/5dc32709beee8fa7caf487f422c0ed97ebc7c4a2), [`49d3017`](https://github.com/bourquep/mysa2mqtt/commit/49d3017fd301c1b79560d1a6403927a7c15de3be), [`a1000a7`](https://github.com/bourquep/mysa2mqtt/commit/a1000a7475c4487931edddab678ace6558a1d0e7)]:
33
+ - mysa-js-sdk@3.1.0
34
+ - mqtt2ha@5.0.0
35
+
36
+ ## 2.0.0
37
+
38
+ ### Major Changes
39
+
40
+ - [#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.
41
+
42
+ ### Minor Changes
43
+
44
+ - [#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.
45
+
46
+ 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.
47
+
48
+ `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.
49
+
50
+ ### Patch Changes
51
+
52
+ - [#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.
53
+
54
+ - [#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.
55
+
56
+ - 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)]:
57
+ - mysa-js-sdk@3.0.0
58
+ - mqtt2ha@4.2.0
59
+
3
60
  ## 1.3.0
4
61
 
5
62
  ### Minor Changes
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
- - **Session Management**: Persistent authentication sessions to minimize API calls
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,8 +22,8 @@ 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 consumption |
26
- | `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested |
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
+ | `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
27
  | `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions |
28
28
 
29
29
  ## Disclaimer
@@ -154,10 +154,37 @@ 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
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
+ ### 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) and in-floor thermostats (`INF-V1-0`) have no current sensor.** They only report
167
+ the state of their heating relay — V2 as a fractional duty cycle, in-floor as a binary on/off flag — so power can only
168
+ be estimated as `relay state × the rated wattage of the attached heaters`. Because that rating is a property of your
169
+ heaters and not of the thermostat, you have to supply it:
170
+
171
+ ```bash
172
+ M2M_HEATER_WATTS="Kitchen=1500,<device-id>=750"
173
+ ```
174
+
175
+ Each entry maps a device — by name or by device id, both case-insensitive — to the total wattage of the heaters that
176
+ thermostat controls. You can find both in the logs at startup.
177
+
178
+ A few things to be aware of:
179
+
180
+ - The **Current power** sensor is only created for devices that can actually report power. V2 and in-floor thermostats
181
+ you have not configured, and AC devices (which report neither current nor relay state), get no power entity at all.
182
+ - The reported value is an estimate. The duty cycle reflects whether the relay is energized right now, so the sensor
183
+ swings between 0 W and the full rated wattage rather than easing between them. Over time it still integrates to a
184
+ reasonable energy total in Home Assistant, but instantaneous readings are coarse.
185
+ - Do not use the thermostat's own maximum current rating here. That figure describes what the thermostat is rated to
186
+ switch, which is typically several times more than the heaters connected to it.
187
+
161
188
  ## Usage Examples
162
189
 
163
190
  ### Using Environment Variables (.env file)
@@ -226,8 +253,12 @@ When using Home Assistant, devices will be automatically discovered and appear i
226
253
 
227
254
  1. **Authentication Failures**
228
255
  - Verify your Mysa username and password
229
- - Check if session.json exists and is valid
230
- - Try deleting session.json to force re-authentication
256
+ - Confirm the same credentials work in the Mysa mobile app
257
+ - mysa2mqtt re-authenticates on its own when the session expires, so a persistent failure usually means either
258
+ invalid credentials or that it cannot reach Cognito or the Mysa API — check the application logs and your network,
259
+ and confirm the Mysa service itself is available
260
+ - If your password contains special characters, see
261
+ [Passwords with special characters](#passwords-with-special-characters) below
231
262
 
232
263
  2. **MQTT Connection Issues**
233
264
  - Verify MQTT broker hostname and port
@@ -239,6 +270,45 @@ When using Home Assistant, devices will be automatically discovered and appear i
239
270
  - Check logs for API errors
240
271
  - Verify your Mysa account has active devices
241
272
 
273
+ ### Passwords with special characters
274
+
275
+ `Incorrect username or password.` when the very same credentials work in the Mysa app almost always means the password
276
+ was altered on its way into mysa2mqtt. Every layer that can carry it treats some characters specially, so the value the
277
+ process receives is not the value you typed:
278
+
279
+ Taking `pa$w0rd` and `pa#w0rd` as example passwords:
280
+
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
+
288
+ Docker Compose is the most common culprit, and note that `env_file:` does **not** avoid it: Compose interpolates `$` in
289
+ those files too, so `pa$w0rd` silently becomes `pa` plus whatever `$w0rd` expands to (usually nothing). Doubling it to
290
+ `$$` passes a literal `$` through in both places.
291
+
292
+ The `#` rules differ between the two file formats, so a password that works in one may break in the other. Compose's
293
+ `env_file:` only treats `#` as a comment when whitespace precedes it, leaving `pa#w0rd` intact; the `.env` file
294
+ mysa2mqtt loads itself is parsed by [dotenv](https://github.com/motdotla/dotenv), which truncates `pa#w0rd` to `pa`.
295
+ Quoting the value is safe in both.
296
+
297
+ On Compose v2.30 and later you can opt an `env_file` out of both rules with `format: raw`, which passes every line
298
+ through verbatim — and therefore expects the value **unquoted** and `$` **undoubled**:
299
+
300
+ ```yaml
301
+ services:
302
+ mysa2mqtt:
303
+ env_file:
304
+ - path: secrets.env
305
+ format: raw
306
+ ```
307
+
308
+ To confirm what actually arrived, run with `--log-level debug`: mysa2mqtt logs the length of the password it received
309
+ (never the password itself). If that length does not match your real password, the value is being mangled by one of the
310
+ layers above rather than rejected by Mysa.
311
+
242
312
  ### Debug Mode
243
313
 
244
314
  Enable debug logging to get more detailed information:
@@ -283,7 +353,6 @@ docker run -d --name mysa2mqtt \
283
353
  -e M2M_MYSA_USERNAME=your-email \
284
354
  -e M2M_MYSA_PASSWORD=your-password \
285
355
  -e M2M_LOG_LEVEL=info \
286
- -v $(pwd)/session.json:/app/session.json \
287
356
  bourquep/mysa2mqtt:latest
288
357
  ```
289
358
 
@@ -341,8 +410,6 @@ services:
341
410
  - M2M_MYSA_USERNAME=your-email
342
411
  - M2M_MYSA_PASSWORD=your-password
343
412
  - M2M_LOG_LEVEL=info
344
- volumes:
345
- - ./session.json:/app/session.json
346
413
  ```
347
414
 
348
415
  Then run:
@@ -351,6 +418,54 @@ Then run:
351
418
  docker-compose up -d
352
419
  ```
353
420
 
421
+ ## Capturing data for a new thermostat type
422
+
423
+ Supporting a new Mysa model means knowing exactly how it talks to the cloud. The baseboard and AC thermostats use a
424
+ custom `/v1/dev/{id}/...` MQTT protocol, but the central-HVAC **ST-V1** thermostats use a completely different one — AWS
425
+ IoT Device Shadows (`$aws/things/{id}/shadow/...`) — which mysa2mqtt does not model yet. Implementing it requires seeing
426
+ the real messages the device reports and the app sends.
427
+
428
+ The `mysa2mqtt-capture` tool gathers exactly that. It logs in to your Mysa account, dumps the REST metadata for the
429
+ target device(s), then **passively records every shadow message** to a file until you stop it. It needs no MQTT broker —
430
+ it only reads from Mysa.
431
+
432
+ > [!NOTE]
433
+ > The capture is passive: a device only publishes to its shadow when something changes it. You must exercise the
434
+ > thermostat from the Mysa mobile app while the capture runs, otherwise nothing is recorded.
435
+
436
+ Run it from a clone of the repository (no build step needed):
437
+
438
+ ```bash
439
+ git clone https://github.com/bourquep/mysa2mqtt
440
+ cd mysa2mqtt
441
+ npm ci
442
+ # Pass the password via the environment so it stays out of your shell history and the process list.
443
+ export M2M_MYSA_PASSWORD='your-password'
444
+ npm run capture -w mysa2mqtt -- \
445
+ --mysa-username you@example.com \
446
+ --output mysa-capture.txt
447
+ ```
448
+
449
+ By default it targets every central-HVAC (`ST-*`) device on the account. Use `--device <id-or-name>` to target a
450
+ specific one, `--all-devices` to capture from everything, or `--log-level debug` to see the per-topic subscribe results.
451
+ Run `npm run capture -w mysa2mqtt -- --help` for the full option list.
452
+
453
+ While it runs, drive the thermostat from the Mysa app so every interaction is recorded:
454
+
455
+ 1. Turn the system **off**, then back **on**.
456
+ 2. Switch modes: **Heat**, **Cool**, **Auto**, **Fan-only** (whatever it offers).
457
+ 3. Change the **heat** setpoint, then the **cool** setpoint.
458
+ 4. In **Auto**, change both setpoints (and the deadband if shown).
459
+ 5. Change the **fan** mode (Auto / On / Circulate).
460
+ 6. Leave it idle a few minutes to catch periodic telemetry.
461
+
462
+ Pause a few seconds between actions, then press **Ctrl+C**. Attach the resulting file to a
463
+ [GitHub issue](https://github.com/bourquep/mysa2mqtt/issues).
464
+
465
+ > [!IMPORTANT]
466
+ > The metadata dump can contain account identifiers (`Owner`, `Home`, `AllowedUsers`, `Zone`). Review the file before
467
+ > sharing it publicly. Authentication tokens and your password are **never** included.
468
+
354
469
  ## Contributing
355
470
 
356
471
  If you want to contribute to this project, please read the [CONTRIBUTING.md](../../CONTRIBUTING.md) file for guidelines.
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ mysa2mqtt
4
+ Copyright (C) 2025 Pascal Bourque
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
23
+ */
24
+
25
+ import{a as C}from"./chunk-QBMDKIY7.js";import{Command as O,InvalidArgumentError as b,Option as l}from"commander";import{configDotenv as M}from"dotenv";import{chmodSync as T,createWriteStream as E,existsSync as A}from"fs";import{MysaApiClient as D,UnauthenticatedError as I}from"mysa-js-sdk";import{pino as $}from"pino";M({path:[".env",".env.local"],override:!0});var t=new O("mysa2mqtt-capture").description("Capture the raw AWS IoT Device Shadow traffic of Mysa central-HVAC (ST-V1) thermostats, to help implement support for them. Logs in to Mysa, dumps the device metadata, then records every shadow message until you press Ctrl+C. Drive the thermostat from the Mysa app while it runs.").addOption(new l("-u, --mysa-username <mysaUsername>","Mysa account username (email)").env("M2M_MYSA_USERNAME").makeOptionMandatory()).addOption(new l("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory()).addOption(new l("-d, --device <deviceIdOrName...>","limit capture to these device id(s) or name(s). Repeatable. Defaults to every central-HVAC (ST-*) device")).addOption(new l("--all-devices","capture from every device on the account, not just central-HVAC ones")).addOption(new l("-e, --extra-topic <topicFilter...>","additional raw MQTT topic filter(s) to subscribe to, on top of the per-device shadow topics. Repeatable")).addOption(new l("-o, --output <file>","also write the metadata dump and every captured message to this file")).addOption(new l("--duration <seconds>","stop automatically after this many seconds (default: run until Ctrl+C)").argParser(o=>{let s=parseInt(o,10);if(String(s)!==o.trim()||s<0)throw new b("Must be a whole number of seconds (0 to run until Ctrl+C).");return s}).default(0)).addOption(new l("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info")).parse().opts(),n=$({name:"mysa2mqtt-capture",level:t.logLevel,transport:{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module,pid"}}}),a;function e(o){process.stdout.write(o+`
26
+ `),a==null||a.write(o+`
27
+ `)}async function N(o){try{await o.login()}catch(s){throw s instanceof I?new Error("Mysa rejected the credentials. Verify that the same username and password let you sign in to the Mysa mobile app, and beware of a shell or .env file mangling special characters ($, `) in the password.",{cause:s}):s}}function L(o){let s=Object.entries(o);if(t.device&&t.device.length>0){let r=t.device.map(c=>c.toLowerCase());return s.filter(([c,d])=>r.includes(c.toLowerCase())||d.Name!==void 0&&r.includes(d.Name.toLowerCase())).map(([c])=>c)}return t.allDevices?s.map(([r])=>r):s.filter(([,r])=>{var u;return(u=r.Model)==null?void 0:u.toUpperCase().startsWith("ST-")}).map(([r])=>r)}async function R(){var v,y;t.output&&(A(t.output)&&T(t.output,384),a=E(t.output,{flags:"w",mode:384}),a.on("error",i=>{n.error(i,`Failed to write to output file '${t.output}'`)}),n.info(`Writing metadata and captured messages to '${t.output}'`));let o=new D({username:t.mysaUsername,password:t.mysaPassword},{logger:new C(n.child({module:"mysa-js-sdk"}))});n.info("Logging in to Mysa..."),await N(o),n.info("Fetching devices, firmwares and states...");let[s,r,u]=await Promise.all([o.getDevices(),o.getDeviceFirmwares(),o.getDeviceStates()]),c=L(s.DevicesObj);if(c.length===0){n.error("No matching devices found. If your central-HVAC thermostat did not match the ST-* filter, re-run with `--all-devices` to list every device, or target it explicitly with `--device <id-or-name>`."),e("=== ALL DEVICES (no target matched) ==="),e(JSON.stringify(s.DevicesObj,null,2)),a==null||a.end();return}e("=================================================================="),e(" Mysa central-HVAC capture"),e(` Generated: ${new Date().toISOString()}`),e(" NOTE: this dump may contain account identifiers (Owner, Home,"),e(" AllowedUsers, Zone). Review before sharing publicly. Auth tokens"),e(" and passwords are NEVER included."),e("==================================================================");let d=[];for(let i of c){let p=s.DevicesObj[i],w=(v=r.Firmware)==null?void 0:v[i],S=(y=u.DeviceStatesObj)==null?void 0:y[i];e(""),e("------------------------------------------------------------------"),e(`DEVICE ${p.Name??"(unnamed)"} \u2014 model ${p.Model} \u2014 id ${i}`),e(`Firmware: ${(w==null?void 0:w.InstalledVersion)??"unknown"}`),e("------------------------------------------------------------------"),e("--- device metadata (REST /devices) ---"),e(JSON.stringify(p,null,2)),e("--- device state (REST /states) ---"),e(JSON.stringify(S??null,null,2)),d.push(`$aws/things/${i}/shadow/#`),d.push(`/v1/dev/${i}/#`)}t.extraTopic&&d.push(...t.extraTopic),e(""),e("=== SUBSCRIBING TO TOPIC FILTERS ===");for(let i of d)e(` ${i}`);let h=0,m=new Set;await o.startRawTopicCapture(d,(i,p)=>{h++,m.add(i),e(""),e(`<<< [${new Date().toISOString()}] ${i}`),e(p)}),e(""),e("=================================================================="),e(" Capture is running. Now, in the Mysa mobile app, exercise the"),e(" thermostat so every interaction is recorded:"),e(" 1. Turn the system OFF, then back ON."),e(" 2. Switch modes: Heat, Cool, Auto, Fan-only (whatever it has)."),e(" 3. Change the heat setpoint, then the cool setpoint."),e(" 4. In Auto, change both setpoints (and the deadband if shown)."),e(" 5. Change the fan mode (Auto / On / Circulate)."),e(" 6. Leave it idle a few minutes to catch periodic telemetry."),e(" Pause a few seconds between actions. Press Ctrl+C when done."),e("==================================================================");let g=!1,f=i=>{g||(g=!0,n.info(`Stopping capture (${i}). Captured ${h} message(s) across ${m.size} topic(s).`),m.size===0&&n.warn("No shadow messages were captured. Either the thermostat was not touched during the capture, or the account's AWS IoT policy does not allow subscribing to its shadow topics (check the log above for subscribe failures). Re-run with `--log-level debug` to see the per-filter subscribe results."),e(""),e(`=== END OF CAPTURE \u2014 ${h} message(s), ${m.size} distinct topic(s) ===`),a?a.end(()=>process.exit(0)):process.exit(0))};process.on("SIGINT",()=>f("Ctrl+C")),process.on("SIGTERM",()=>f("SIGTERM")),t.duration&&t.duration>0&&(n.info(`Will stop automatically after ${t.duration}s.`),setTimeout(()=>f(`--duration ${t.duration}s elapsed`),t.duration*1e3))}R().catch(o=>{n.error(o,"Capture failed"),a?a.end(()=>process.exit(1)):process.exit(1)});
@@ -0,0 +1,24 @@
1
+ /*
2
+ mysa2mqtt
3
+ Copyright (C) 2025 Pascal Bourque
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+ */
23
+
24
+ var r=class{constructor(o){this.logger=o}logger;debug(o,...n){this.logger.debug(n.at(0)??null,o,...n.slice(1))}info(o,...n){this.logger.info(n.at(0)??null,o,...n.slice(1))}warn(o,...n){this.logger.warn(n.at(0)??null,o,...n.slice(1))}error(o,...n){this.logger.error(n.at(0)??null,o,...n.slice(1))}};export{r as a};
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{writeFile as Z}from"fs/promises";import{MysaApiClient as tt}from"mysa-js-sdk";import{pino as et}from"pino";var g=class{constructor(t){this.logger=t}logger;debug(t,...e){let i=e.at(0);i?this.logger.debug(i,t,...e):this.logger.debug(null,t,...e)}info(t,...e){let i=e.at(0);i?this.logger.info(i,t,...e):this.logger.info(null,t,...e)}warn(t,...e){let i=e.at(0);i?this.logger.warn(i,t,...e):this.logger.warn(null,t,...e)}error(t,...e){let i=e.at(0);i?this.logger.error(i,t,...e):this.logger.error(null,t,...e)}};import{Command as L,InvalidArgumentError as k,Option as c}from"commander";import{configDotenv as H}from"dotenv";import{readFileSync as U}from"fs";import{dirname as G,join as Q}from"path";import{fileURLToPath as Y}from"url";H({path:[".env",".env.local"],override:!0});function j(){try{let a=Y(import.meta.url),t=G(a),e=Q(t,"..","package.json");return JSON.parse(U(e,"utf-8")).version||"unknown"}catch{return"unknown"}}function B(a){let t=parseInt(a,10);if(isNaN(t))throw new k("Must be a number.");return t}var v=j(),$=`
25
+ import{a as A}from"./chunk-QBMDKIY7.js";import{writeFile as ie}from"fs/promises";import{MysaApiClient as ae,UnauthenticatedError as re}from"mysa-js-sdk";import{pino as ne}from"pino";import{Command as W,InvalidArgumentError as _,Option as l}from"commander";import{configDotenv as Q}from"dotenv";import{readFileSync as Y}from"fs";import{dirname as B,join as V}from"path";import{fileURLToPath as j}from"url";Q({path:[".env",".env.local"],override:!0});function z(){try{let i=j(import.meta.url),e=B(i),t=V(e,"..","package.json");return JSON.parse(Y(t,"utf-8")).version||"unknown"}catch{return"unknown"}}function E(i){let e=parseInt(i,10);if(isNaN(e))throw new _("Must be a number.");return e}function X(i){let e=E(i);if(String(e)!==i.trim())throw new _("Must be a whole number of seconds.");if(e!==0&&e<30)throw new _("Must be 0 (disabled) or at least 30 seconds.");return e}function J(i){let e=new Map;for(let t of i.split(",")){let a=t.trim();if(a.length===0)continue;let n=a.lastIndexOf("=");if(n<0)throw new _(`'${a}' is not a <device>=<watts> pair.`);let s=a.slice(0,n).trim(),c=Number(a.slice(n+1).trim());if(s.length===0)throw new _(`'${a}' is missing a device id or name.`);if(!Number.isFinite(c)||c<=0)throw new _(`'${a}' must specify a wattage greater than zero.`);e.set(s.toLowerCase(),c)}return e}var R=z(),K=`
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 L("mysa2mqtt").version(v).description("Expose Mysa smart thermostats to home automation platforms via MQTT.").addHelpText("afterAll",$).addOption(new c("-l, --log-level <logLevel>","log level").choices(["silent","fatal","error","warn","info","debug","trace"]).env("M2M_LOG_LEVEL").default("info").helpGroup("Configuration")).addOption(new c("-f, --log-format <logFormat>","log format").choices(["pretty","json"]).env("M2M_LOG_FORMAT").default("pretty").helpGroup("Configuration")).addOption(new c("-H, --mqtt-host <mqttHost>","hostname of the MQTT broker").env("M2M_MQTT_HOST").makeOptionMandatory().helpGroup("MQTT")).addOption(new c("-P, --mqtt-port <mqttPort>","port of the MQTT broker").env("M2M_MQTT_PORT").argParser(B).default(1883).helpGroup("MQTT")).addOption(new c("-U, --mqtt-username <mqttUsername>","username of the MQTT broker").env("M2M_MQTT_USERNAME").helpGroup("MQTT")).addOption(new c("-B, --mqtt-password <mqttPassword>","password of the MQTT broker").env("M2M_MQTT_PASSWORD").helpGroup("MQTT")).addOption(new c("-u, --mysa-username <mysaUsername>","Mysa account username").env("M2M_MYSA_USERNAME").makeOptionMandatory().helpGroup("Mysa")).addOption(new c("-p, --mysa-password <mysaPassword>","Mysa account password").env("M2M_MYSA_PASSWORD").makeOptionMandatory().helpGroup("Mysa")).addOption(new c("-s, --mysa-session-file <mysaSessionFile>","Mysa session file").env("M2M_MYSA_SESSION_FILE").default("session.json").helpGroup("Configuration")).addOption(new c("-N, --mqtt-client-name <mqttClientName>","name of the MQTT client").env("M2M_MQTT_CLIENT_NAME").default("mysa2mqtt").helpGroup("MQTT")).addOption(new c("-T, --mqtt-topic-prefix <mqttTopicPrefix>","prefix of the MQTT topic").env("M2M_MQTT_TOPIC_PREFIX").default("mysa2mqtt").helpGroup("MQTT")).addOption(new c("--temperature-unit <temperatureUnit>","temperature unit (C or F)").env("M2M_TEMPERATURE_UNIT").choices(["C","F"]).default("C").helpGroup("Configuration")).addOption(new c("--heartbeat-file <heartbeatFile>","file touched on every message received from the Mysa cloud, for external liveness checks").env("M2M_HEARTBEAT_FILE").helpGroup("Configuration")).parse().opts();import{readFile as J,rm as V,writeFile as X}from"fs/promises";async function T(a,t){try{t.info("Loading Mysa session...");let e=await J(a,"utf8");return JSON.parse(e)}catch{t.info("No valid Mysa session file found.")}}async function q(a,t,e){if(a)e.info("Saving Mysa session..."),await X(t,JSON.stringify(a));else try{e.debug("Removing Mysa session file..."),await V(t)}catch{}}import{Climate as W,Sensor as S}from"mqtt2ha";var C=["off","heat"],w=["off","heat","cool","dry","fan_only","auto"],z={1:"off",2:"auto",3:"heat",4:"cool",5:"fan_only",6:"dry"},A=["auto","low","medium","high","max"],K={1:"auto",3:"low",5:"medium",7:"high",8:"max"},b=3e4,D=3e5,R=Math.ceil(Math.log2(D/b)),_=class{constructor(t,e,i,p,u,f,h){this.mysaApiClient=t;this.mysaDevice=e;this.mqttSettings=i;this.logger=p;this.mysaDeviceFirmware=u;this.mysaDeviceSerialNumber=f;this.temperatureUnit=h;let n=(h??"C")==="C";this.mqttDevice={identifiers:e.Id,name:e.Name,manufacturer:"Mysa",model:e.Model,sw_version:u==null?void 0:u.InstalledVersion,serial_number:f},this.mqttOrigin={name:"mysa2mqtt",sw_version:v,support_url:"https://github.com/bourquep/mysa2mqtt"};let l=e.Model.startsWith("AC");this.deviceType=l?"AC":"BB",this.mqttClimate=new W({mqtt:this.mqttSettings,logger:this.logger,component:{component:"climate",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_climate`,name:"Thermostat",min_temp:e.MinSetpoint,max_temp:e.MaxSetpoint,modes:l?w:C,fan_modes:l?A:void 0,precision:n?.1:1,temp_step:n?.5:1,temperature_unit:"C",optimistic:!0}},l?["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic","fan_mode_state_topic"]:["action_topic","current_humidity_topic","current_temperature_topic","mode_state_topic","temperature_state_topic"],async()=>{},l?["mode_command_topic","power_command_topic","temperature_command_topic","fan_mode_command_topic"]:["mode_command_topic","power_command_topic","temperature_command_topic"],async(r,s)=>{switch(r){case"mode_command_topic":{let d=s,y=l?w.includes(d)?d:void 0:C.includes(d)?d:void 0;await this.setDeviceState(void 0,y);break}case"power_command_topic":await this.setDeviceState(void 0,s==="OFF"?"off":s==="ON"&&!l?"heat":void 0);break;case"temperature_command_topic":if(s==="")await this.setDeviceState(void 0,void 0);else{let d=parseFloat(s);if(!n){let y=M=>Math.round(M*2)/2,F=(M,N,x)=>Math.min(x,Math.max(N,M)),P=y(d);d=F(P,this.mysaDevice.MinSetpoint??0,this.mysaDevice.MaxSetpoint??100)}await this.setDeviceState(d,void 0)}break;case"fan_mode_command_topic":{let d=s,y=A.includes(d)?d:void 0;await this.setDeviceState(void 0,void 0,y);break}}}),this.mqttTemperature=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_temperature`,name:"Current temperature",device_class:"temperature",state_class:"measurement",unit_of_measurement:"\xB0C",suggested_display_precision:n?.1:0,force_update:!0}}),this.mqttHumidity=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_humidity`,name:"Current humidity",device_class:"humidity",state_class:"measurement",unit_of_measurement:"%",suggested_display_precision:0,force_update:!0}}),this.mqttPower=new S({mqtt:this.mqttSettings,logger:this.logger,component:{component:"sensor",device:this.mqttDevice,origin:this.mqttOrigin,unique_id:`mysa_${e.Id}_power`,name:"Current power",device_class:"power",state_class:"measurement",unit_of_measurement:"W",suggested_display_precision:0,force_update:!0}})}mysaApiClient;mysaDevice;mqttSettings;logger;mysaDeviceFirmware;mysaDeviceSerialNumber;temperatureUnit;isStarted=!1;realtimeGeneration=0;realtimeRetryAttempt=0;realtimeRetryTimer;mqttDevice;mqttOrigin;mqttClimate;mqttTemperature;mqttHumidity;mqttPower;mysaStatusUpdateHandler=t=>{this.handleMysaStatusUpdate(t).catch(e=>{this.logger.error("Failed to handle Mysa status update",{error:e,deviceId:this.mysaDevice.Id})})};mysaStateChangeHandler=t=>{this.handleMysaStateChange(t).catch(e=>{this.logger.error("Failed to handle Mysa state change",{error:e,deviceId:this.mysaDevice.Id})})};deviceType;async start(){var t,e,i,p,u,f;if(!this.isStarted){this.isStarted=!0,this.realtimeGeneration+=1;try{let n=(await this.mysaApiClient.getDeviceStates()).DeviceStatesObj[this.mysaDevice.Id];this.mqttClimate.currentTemperature=(t=n.CorrectedTemp)==null?void 0:t.v,this.mqttClimate.currentHumidity=(e=n.Humidity)==null?void 0:e.v,this.mqttClimate.currentMode=z[(i=n.TstatMode)==null?void 0:i.v]??this.mqttClimate.currentMode,this.mqttClimate.currentFanMode=K[(p=n.FanSpeed)==null?void 0:p.v]??this.mqttClimate.currentFanMode,this.mqttClimate.currentAction=this.computeCurrentAction(void 0,(u=n.Duty)==null?void 0:u.v),this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?(f=n.SetPoint)==null?void 0:f.v:void 0,await this.mqttClimate.writeConfig(),await this.mqttTemperature.setState("state_topic",n.CorrectedTemp!=null?n.CorrectedTemp.v.toFixed(2):"None"),await this.mqttTemperature.writeConfig(),await this.mqttHumidity.setState("state_topic",n.Humidity!=null?n.Humidity.v.toFixed(2):"None"),await this.mqttHumidity.writeConfig(),await this.mqttPower.setState("state_topic","None"),await this.mqttPower.writeConfig(),this.mysaApiClient.emitter.on("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.on("stateChanged",this.mysaStateChangeHandler),await this.startRealtimeUpdates()}catch(h){throw this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),h}}}async stop(){this.isStarted&&(this.isStarted=!1,this.realtimeGeneration+=1,this.clearRealtimeRetry(),await this.stopRealtimeUpdates(),this.mysaApiClient.emitter.off("statusChanged",this.mysaStatusUpdateHandler),this.mysaApiClient.emitter.off("stateChanged",this.mysaStateChangeHandler),await this.mqttPower.setState("state_topic","None"),await this.mqttTemperature.setState("state_topic","None"),await this.mqttHumidity.setState("state_topic","None"))}async setDeviceState(t,e,i){try{await this.mysaApiClient.setDeviceState(this.mysaDevice.Id,t,e,i)}catch(p){this.logger.error("Failed to update Mysa device state",{error:p,deviceId:this.mysaDevice.Id})}}async startRealtimeUpdates(){let t=this.realtimeGeneration;try{if(await this.mysaApiClient.startRealtimeUpdates(this.mysaDevice.Id),!this.isStarted||t!==this.realtimeGeneration){await this.stopRealtimeUpdates();return}this.realtimeRetryAttempt=0,this.logger.info("Started realtime updates",{deviceId:this.mysaDevice.Id})}catch(e){this.isStarted&&t===this.realtimeGeneration&&this.scheduleRealtimeRetry(e)}}async stopRealtimeUpdates(){try{await this.mysaApiClient.stopRealtimeUpdates(this.mysaDevice.Id)}catch(t){this.logger.warn("Failed to stop realtime updates",{error:t,deviceId:this.mysaDevice.Id})}}scheduleRealtimeRetry(t){if(!this.isStarted||this.realtimeRetryTimer!=null)return;let e=Math.min(this.realtimeRetryAttempt,R),i=Math.min(D,b*2**e);this.realtimeRetryAttempt=Math.min(this.realtimeRetryAttempt+1,R),this.logger.error("Failed to start realtime updates; retrying",{error:t,deviceId:this.mysaDevice.Id,retryDelayMs:i}),this.realtimeRetryTimer=setTimeout(()=>{this.realtimeRetryTimer=void 0,this.startRealtimeUpdates()},i)}clearRealtimeRetry(){this.realtimeRetryTimer!=null&&(clearTimeout(this.realtimeRetryTimer),this.realtimeRetryTimer=void 0)}async handleMysaStatusUpdate(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id)){if(this.mqttClimate.currentAction=this.computeCurrentAction(t.current,t.dutyCycle),this.mqttClimate.currentTemperature=t.temperature,this.mqttClimate.currentHumidity=t.humidity,this.mqttClimate.targetTemperature=this.mqttClimate.currentMode!=="off"?t.setPoint:void 0,this.mysaDevice.Voltage!=null&&t.current!=null){let e=this.mysaDevice.Voltage*t.current;await this.mqttPower.setState("state_topic",e.toFixed(2))}else await this.mqttPower.setState("state_topic","None");await this.mqttTemperature.setState("state_topic",t.temperature.toFixed(2)),await this.mqttHumidity.setState("state_topic",t.humidity.toFixed(2))}}async handleMysaStateChange(t){if(!(!this.isStarted||t.deviceId!==this.mysaDevice.Id))switch(t.mode){case"off":this.mqttClimate.currentMode="off",this.mqttClimate.currentAction="off",this.mqttClimate.targetTemperature=void 0,this.mqttClimate.currentFanMode=void 0;break;case"heat":case"cool":case"auto":this.mqttClimate.currentMode=t.mode,this.deviceType==="AC"&&(this.mqttClimate.currentAction=this.computeCurrentAction()),this.mqttClimate.targetTemperature=t.setPoint,this.mqttClimate.currentFanMode=t.fanSpeed;break;case"dry":case"fan_only":this.mqttClimate.currentMode=t.mode,this.mqttClimate.currentAction=this.computeCurrentAction(),this.mqttClimate.currentFanMode=t.fanSpeed;break;default:this.mqttClimate.currentMode!=="off"&&(this.mqttClimate.targetTemperature=t.setPoint),t.fanSpeed!==void 0&&(this.mqttClimate.currentFanMode=t.fanSpeed);break}}computeCurrentAction(t,e){let i=this.mqttClimate.currentMode;switch(w.includes(i)?i:void 0){case"off":return"off";case"heat":return this.deviceType==="BB"?t!=null?t>0?"heating":"idle":(e??0)>0?"heating":"idle":"heating";case"cool":return"cooling";case"fan_only":return"fan";case"dry":return"drying";default:return"idle"}}};var I=3e4,E=3e5,it=Math.ceil(Math.log2(E/I)),o=et({name:"mysa2mqtt",level:m.logLevel,transport:m.logFormat==="pretty"?{target:"pino-pretty",options:{colorize:!0,singleLine:!0,ignore:"hostname,module",messageFormat:"\x1B[33m[{module}]\x1B[39m {msg}"}}:void 0});async function at(){o.info("Starting mysa2mqtt...");let a=await T(m.mysaSessionFile,o),t=new tt(a,{logger:new g(o.child({module:"mysa-js-sdk"}))});t.emitter.on("sessionChanged",async r=>{await q(r,m.mysaSessionFile,o)});let e=m.heartbeatFile;if(e){let r=0;t.emitter.on("rawRealtimeMessageReceived",()=>{let s=Date.now();s-r<1e4||(r=s,Z(e,`${new Date(s).toISOString()}
31
- `).catch(d=>{o.warn(d,`Failed to write heartbeat file '${e}'`)}))})}t.isAuthenticated||(o.info("Logging in..."),await t.login(m.mysaUsername,m.mysaPassword)),o.debug("Fetching devices and firmwares...");let[i,p]=await Promise.all([t.getDevices(),t.getDeviceFirmwares()]);o.debug("Fetching serial numbers...");let u=new Map;for(let[r]of Object.entries(i.DevicesObj))try{let s=await t.getDeviceSerialNumber(r);s&&u.set(r,s)}catch(s){o.error(s,`Failed to retrieve serial number for device ${r}`)}o.debug("Initializing MQTT entities...");let f={host:m.mqttHost,port:m.mqttPort,username:m.mqttUsername,password:m.mqttPassword,client_name:m.mqttClientName,state_prefix:m.mqttTopicPrefix},h=Object.entries(i.DevicesObj).map(([,r])=>new _(t,r,f,new g(o.child({module:"thermostat",deviceId:r.Id})),p.Firmware[r.Id],u.get(r.Id),m.temperatureUnit)),n=0,l=[];for(let r of h)await rt(r)?n+=1:l.push(r);if(h.length>0&&n===0)throw new Error("Failed to start any thermostats");for(let r of l)O(r)}async function rt(a){try{return await a.start(),!0}catch(t){return o.error(t,`Failed to start thermostat ${a.mysaDevice.Id}`),!1}}function O(a,t=0){let e=Math.min(t,it),i=Math.min(E,I*2**e);o.info(`Retrying thermostat ${a.mysaDevice.Id} startup in ${i}ms`),setTimeout(()=>{nt(a,t+1)},i)}async function nt(a,t){try{await a.start(),o.info(`Started thermostat ${a.mysaDevice.Id} after retry`)}catch(e){o.error(e,`Failed to start thermostat ${a.mysaDevice.Id}`),O(a,t)}}at().catch(a=>{o.fatal(a,"Unexpected error"),process.exit(1)});
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 ue(i),o.debug("Fetching devices and firmwares...");let[t,a]=await Promise.all([i.getDevices(),i.getDeviceFirmwares()]);o.debug("Fetching serial numbers...");let n=new Map;for(let[r]of Object.entries(t.DevicesObj))try{let d=await i.getDeviceSerialNumber(r);d&&n.set(r,d)}catch(d){o.error(d,`Failed to retrieve serial number for device ${r}`)}o.debug("Initializing MQTT entities...");let s={host:m.mqttHost,port:m.mqttPort,username:m.mqttUsername,password:m.mqttPassword,client_name:m.mqttClientName,state_prefix:m.mqttTopicPrefix},c=m.heaterWatts??new Map,h=new Set;function q(r){for(let d of[r.Id,r.Name]){let y=d==null?void 0:d.toLowerCase();if(y==null)continue;let S=c.get(y);if(S!=null)return h.add(y),S}}let f=Object.entries(t.DevicesObj).map(([,r])=>new C(i,r,s,new A(o.child({module:"thermostat",deviceId:r.Id})),a.Firmware[r.Id],n.get(r.Id),m.temperatureUnit,q(r))),u=c.size-h.size;u>0&&o.warn(`${u} of the ${c.size} heater wattage entries match no device id or name. Check the configured names and ids against your devices.`);let w=0,v=[];for(let r of f)await pe(r)?w+=1:v.push(r);if(f.length>0&&w===0)throw new Error("Failed to start any thermostats");for(let r of v)k(r);me(i,f)}function me(i,e){let t=m.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 n=!1;setInterval(()=>{if(n){o.debug("Skipping REST state poll; previous poll still in flight");return}n=!0,(async()=>{try{let s=await i.getDeviceStates();await Promise.all(Array.from(a,([c,h])=>h.refreshFromRest(s.DeviceStatesObj[c])))}catch(s){o.warn(s,"Periodic REST state poll failed")}finally{n=!1}})()},t*1e3)}var de="NotAuthorizedException",ce="UserNotFoundException";function le(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 ue(i){o.debug(`Authenticating with a password of ${m.mysaPassword.length} character(s).`);try{await i.login()}catch(e){let t=e instanceof re?le(e):void 0;throw t===de?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===ce?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 pe(i){try{return await i.start(),!0}catch(e){return o.error(e,`Failed to start thermostat ${i.mysaDevice.Id}`),!1}}function k(i,e=0){let t=Math.min(e,oe),a=Math.min(H,x*2**t);o.info(`Retrying thermostat ${i.mysaDevice.Id} startup in ${a}ms`),setTimeout(()=>{he(i,e+1)},a)}async function he(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}`),k(i,e)}}se().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": "1.3.0",
3
+ "version": "3.0.0",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "description": "Expose Mysa smart thermostats to home automation platforms via MQTT.",
@@ -36,7 +36,8 @@
36
36
  ],
37
37
  "type": "module",
38
38
  "bin": {
39
- "mysa2mqtt": "dist/main.js"
39
+ "mysa2mqtt": "dist/main.js",
40
+ "mysa2mqtt-capture": "dist/capture.js"
40
41
  },
41
42
  "engines": {
42
43
  "node": ">=24.15.0"
@@ -44,6 +45,7 @@
44
45
  "browser": false,
45
46
  "scripts": {
46
47
  "dev": "tsx src/main.ts",
48
+ "capture": "tsx src/capture.ts",
47
49
  "lint": "eslint --max-warnings 0 src/**/*.ts",
48
50
  "typecheck": "tsc --noEmit -p tsconfig.json",
49
51
  "build": "tsup"
@@ -51,8 +53,8 @@
51
53
  "dependencies": {
52
54
  "commander": "15.0.0",
53
55
  "dotenv": "17.4.2",
54
- "mqtt2ha": "4.1.5",
55
- "mysa-js-sdk": "2.1.2",
56
+ "mqtt2ha": "5.0.0",
57
+ "mysa-js-sdk": "3.1.0",
56
58
  "pino": "10.3.1",
57
59
  "pino-pretty": "13.1.3"
58
60
  },