mysa2mqtt 2.0.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 +33 -0
- package/README.md +55 -6
- package/dist/capture.js +27 -0
- package/dist/chunk-QBMDKIY7.js +24 -0
- package/dist/main.js +3 -3
- package/package.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
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
|
+
|
|
3
36
|
## 2.0.0
|
|
4
37
|
|
|
5
38
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ home automation platforms.
|
|
|
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
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
|
+
| `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
|
|
@@ -163,9 +163,10 @@ take precedence over command-line defaults.
|
|
|
163
163
|
V1 baseboard thermostats measure their own current draw, so their **Current power** sensor works with no extra
|
|
164
164
|
configuration.
|
|
165
165
|
|
|
166
|
-
**V2 thermostats (including V2 Lite) have no current sensor.** They only report
|
|
167
|
-
|
|
168
|
-
|
|
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:
|
|
169
170
|
|
|
170
171
|
```bash
|
|
171
172
|
M2M_HEATER_WATTS="Kitchen=1500,<device-id>=750"
|
|
@@ -176,8 +177,8 @@ thermostat controls. You can find both in the logs at startup.
|
|
|
176
177
|
|
|
177
178
|
A few things to be aware of:
|
|
178
179
|
|
|
179
|
-
- The **Current power** sensor is only created for devices that can actually report power. V2
|
|
180
|
-
configured, and AC devices (which report neither current nor
|
|
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.
|
|
181
182
|
- The reported value is an estimate. The duty cycle reflects whether the relay is energized right now, so the sensor
|
|
182
183
|
swings between 0 W and the full rated wattage rather than easing between them. Over time it still integrates to a
|
|
183
184
|
reasonable energy total in Home Assistant, but instantaneous readings are coarse.
|
|
@@ -417,6 +418,54 @@ Then run:
|
|
|
417
418
|
docker-compose up -d
|
|
418
419
|
```
|
|
419
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
|
+
|
|
420
469
|
## Contributing
|
|
421
470
|
|
|
422
471
|
If you want to contribute to this project, please read the [CONTRIBUTING.md](../../CONTRIBUTING.md) file for guidelines.
|
package/dist/capture.js
ADDED
|
@@ -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
|
|
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
|
-
`,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(
|
|
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": "
|
|
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": "
|
|
55
|
-
"mysa-js-sdk": "3.
|
|
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
|
},
|