homebridge-tesy-native 0.0.0-stage → 1.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 +12 -0
- package/LICENSE +21 -0
- package/NOTICE +6 -0
- package/README.md +158 -2
- package/config.example.json +9 -0
- package/config.schema.json +35 -0
- package/index.js +6 -0
- package/lib/accessory.js +223 -0
- package/lib/client.js +175 -0
- package/lib/platform.js +165 -0
- package/lib/settings.js +44 -0
- package/lib/state.js +54 -0
- package/package.json +25 -4
- package/scripts/check.js +15 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 2026-10-22
|
|
4
|
+
|
|
5
|
+
- Initial MyTESY cloud convector support based on the supplied Home Assistant integration.
|
|
6
|
+
- Native HomeKit thermostats with Heat/Off and 10–30 °C integer targets.
|
|
7
|
+
- Optional Comfort/Eco/Sleep switches, including return to manual mode.
|
|
8
|
+
- Shared HTTPS discovery/status requests and secure MQTT-over-WebSocket commands.
|
|
9
|
+
- Stable accessory cache IDs and per-device names, exclusions and preset visibility.
|
|
10
|
+
- Idle-safe availability, stale active-heating handling, bounded optimistic updates, command ordering and recovery after cloud failures.
|
|
11
|
+
- Credentials and device tokens excluded from accessory cache metadata and normal/debug output.
|
|
12
|
+
- Homebridge UI schema, source, lockfile, tests and packaging support.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kosztyk
|
|
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.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
This independently implemented Node.js plugin follows the MyTESY cloud
|
|
2
|
+
protocol used in the user-supplied Home Assistant TESY integration (1.1.0).
|
|
3
|
+
The supplied Python files and vendor JavaScript bundle are not distributed.
|
|
4
|
+
|
|
5
|
+
TESY and MyTESY are trademarks of their respective owners. This is an
|
|
6
|
+
unofficial community plugin, not a TESY product or endorsement.
|
package/README.md
CHANGED
|
@@ -1,3 +1,159 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Homebridge TESY Native
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MyTESY cloud convectors in Apple Home, implemented from the cloud protocol in the supplied Home Assistant TESY integration 1.1.0. Home Assistant does not need to be running: this plugin communicates with MyTESY directly.
|
|
4
|
+
|
|
5
|
+
Package: `homebridge-tesy-native` · Platform: `TesyNative` · Author: **Kosztyk** · Version: **1.0.0**
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Automatically discovers compatible convectors in your MyTESY account.
|
|
10
|
+
- Exposes each convector as a native HomeKit **thermostat**, with **Off / Heat**, current temperature and a **10–30 °C** target in **1 °C** steps.
|
|
11
|
+
- Shows active heating separately from a powered-on heater that is idle.
|
|
12
|
+
- Adds optional **Comfort**, **Eco** and **Sleep** switches, on the same accessory.
|
|
13
|
+
- Supports device naming and exclusions by MAC address, plus per-device preset visibility.
|
|
14
|
+
- Restores accessories with stable IDs after a restart, preserving room assignments.
|
|
15
|
+
- Keeps idle devices available even when their device timestamps stop advancing, following the supplied integration.
|
|
16
|
+
- Reuses a secure MQTT connection for commands and polls device status every 30 seconds by default.
|
|
17
|
+
- Bounds optimistic changes and returns to the reported cloud value if a command is not confirmed.
|
|
18
|
+
- Includes the Homebridge settings schema, ready-to-run JavaScript, tests, lockfile and a GitHub Actions test workflow in the source archive.
|
|
19
|
+
|
|
20
|
+
## Requirements and device scope
|
|
21
|
+
|
|
22
|
+
- Node.js **22.12+ within Node 22**, or **Node 24**.
|
|
23
|
+
- Homebridge **1.8+** or **2.x**. Validation uses Homebridge 1.11.1 and 2.4.0 on Node 24.
|
|
24
|
+
- A MyTESY account and compatible internet-connected convector already working with the supplied integration.
|
|
25
|
+
- Outbound HTTPS to `ad.mytesy.com` on port 443 and secure WebSocket access to `mqtt.tesy.com` on port 8083.
|
|
26
|
+
|
|
27
|
+
This version implements the new MyTESY convector protocol from your integration. It recognises snapshots containing `status`, `temp` and `current_temp`. Legacy TESY Cloud devices and electric water heaters use different commands and are not imported.
|
|
28
|
+
|
|
29
|
+
The supplied Home Assistant integration also calculates energy estimates and exposes diagnostic sensors. This plugin provides the HomeKit thermostat and presets; it does not reproduce those energy/history sensors or expose unverified writable controls for UV, lock or anti-frost.
|
|
30
|
+
|
|
31
|
+
## Install the provided package
|
|
32
|
+
|
|
33
|
+
Copy `homebridge-tesy-native-1.0.0.tgz` onto your Homebridge host, for example into `/tmp`, then run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
sudo npm install -g /tmp/homebridge-tesy-native-1.0.0.tgz
|
|
37
|
+
sudo hb-service restart
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The package contains the runtime files. There is no TypeScript compilation step. npm installs the MQTT dependency during installation. If your installation uses a different Homebridge restart method, restart it in the Homebridge UI instead.
|
|
41
|
+
|
|
42
|
+
After restarting, open **Plugins → TESY Native → Settings**, fill in the three account fields, save and restart Homebridge again.
|
|
43
|
+
|
|
44
|
+
## Account settings
|
|
45
|
+
|
|
46
|
+
Use the **same email, password value and user ID** configured in your working Home Assistant TESY integration. Its `user_id` field is called `userId` here. It is an account identifier, not a MAC address or an email address. The plugin also accepts `user_id` in manually written JSON for compatibility.
|
|
47
|
+
|
|
48
|
+
This plugin follows the integration's `/get-my-devices` authentication and does not try to obtain a user ID through a separate login flow. If the Home Assistant configuration uses a portal-provided password value, copy that exact value.
|
|
49
|
+
|
|
50
|
+
Add this object to the existing `platforms` array, or enter the values in the plugin's settings form:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"name": "TESY Native",
|
|
55
|
+
"username": "your-email@example.com",
|
|
56
|
+
"password": "YOUR_MYTESY_PASSWORD",
|
|
57
|
+
"userId": "YOUR_MYTESY_USER_ID",
|
|
58
|
+
"exposePresets": true,
|
|
59
|
+
"pollInterval": 30,
|
|
60
|
+
"platform": "TesyNative"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Leave the device overrides list empty to import all compatible convectors. A blank override row is ignored and cannot disable discovery.
|
|
65
|
+
|
|
66
|
+
## Device overrides
|
|
67
|
+
|
|
68
|
+
The discovery log prints the device name, MAC and model. Copy a MAC to override one device:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
"devices": [
|
|
72
|
+
{
|
|
73
|
+
"mac": "AA:BB:CC:DD:EE:FF",
|
|
74
|
+
"name": "Bedroom heater",
|
|
75
|
+
"exclude": false,
|
|
76
|
+
"exposePresets": true
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"mac": "11:22:33:44:55:66",
|
|
80
|
+
"exclude": true
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
MAC matching ignores letter case, colons and hyphens. Setting `exclude: true` removes that device's cached accessory; adding it again may require assigning its room again. Devices missing briefly from a cloud response retain their cached identity.
|
|
86
|
+
|
|
87
|
+
## Thermostat and preset behaviour
|
|
88
|
+
|
|
89
|
+
| Home action | MyTESY command | Behaviour |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| Select Heat | `onOff` with `status: on` | Enables the convector; does not claim that the element is already heating. |
|
|
92
|
+
| Select Off | `onOff` with `status: off` | Disables the convector. |
|
|
93
|
+
| Change target | `setTemp` with integer `temp` | Changes the setpoint; does not power the convector on. |
|
|
94
|
+
| Turn a preset on | `setMode` with `name: comfort`, `eco` or `sleep` | Selects that preset and updates all three switches. Preserves the power state. |
|
|
95
|
+
| Turn the selected preset off | `setMode` with `name: heating` | Returns to the manual heating mode defined in the supplied MyTESY portal bundle. Preserves the power state. |
|
|
96
|
+
| Turn an inactive preset off | No command | Leaves the active mode unchanged. |
|
|
97
|
+
|
|
98
|
+
Preset temperature/time settings continue to use the values configured in MyTESY. A preset switch can remain selected while the thermostat is Off, just as the preset remains configured in the source integration. No cooling or automatic cooling mode is advertised.
|
|
99
|
+
|
|
100
|
+
Apple Home may group the thermostat and switches into one accessory. Open its accessory settings and choose **Show as Separate Tiles** if you want separate tiles.
|
|
101
|
+
|
|
102
|
+
## Refresh and availability
|
|
103
|
+
|
|
104
|
+
| Setting | Default | Meaning |
|
|
105
|
+
| --- | ---: | --- |
|
|
106
|
+
| `pollInterval` | 30 seconds | Time between background cloud polls. Minimum 15 seconds. |
|
|
107
|
+
| `requestTimeout` | 8 seconds | Timeout for an HTTP request, MQTT connection or MQTT publish. |
|
|
108
|
+
| `offlineAfter` | 300 seconds | Time without a successful cloud response containing the device before HomeKit reads report a communication error. |
|
|
109
|
+
| `staleHeatingAfter` | 600 seconds | Maximum age of a device timestamp while it still claims active heating. |
|
|
110
|
+
| `exposePresets` | `true` | Adds the three preset switches. Can be overridden per device. |
|
|
111
|
+
| `debug` | `false` | Logs selected temperature/mode/status fields. |
|
|
112
|
+
|
|
113
|
+
Status refreshes are shared across accessories and never overlap. HomeKit reads use the most recent snapshot instead of making one request per characteristic.
|
|
114
|
+
|
|
115
|
+
An old idle/off device timestamp alone does not produce No Response. A stale timestamp that still claims active heating is treated as inactive, matching the supplied integration. Timestamps without a timezone offset are interpreted as UTC, as in its helper.
|
|
116
|
+
|
|
117
|
+
After a successful MQTT publish, the requested values are shown for up to 15 seconds while polls at approximately 1.5, 5 and 16 seconds look for confirmation. Confirmed values stop being optimistic immediately. If MyTESY keeps reporting the previous value, the plugin restores it and logs a warning. A QoS 0 publish callback confirms that the client sent the message; it is not a device acknowledgement. Failed or timed-out commands are never queued for later replay.
|
|
118
|
+
|
|
119
|
+
During a short cloud outage the last snapshot remains readable for the configured grace period. A sustained outage reports No Response. Account errors, network errors and recovery are logged without dumping credential-bearing URLs or device tokens.
|
|
120
|
+
|
|
121
|
+
## Source, testing and packaging
|
|
122
|
+
|
|
123
|
+
From the extracted source directory:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm ci
|
|
127
|
+
npm run check
|
|
128
|
+
npm test
|
|
129
|
+
npm pack
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The resulting `.tgz` includes the entry point, all runtime modules, settings schema and documentation. `package-lock.json`, the test suite and the CI workflow are in the source archive. Runtime dependencies are declared in `package.json`; `homebridge` is a development dependency, not a second Homebridge installation at runtime.
|
|
133
|
+
|
|
134
|
+
Tests use Homebridge's real accessory and characteristic classes. They cover cloud authentication/errors, MQTT command topics, WebSocket packet transport, safe temperatures, stale/idle status, queued writes, preset semantics, failed commands, confirmation expiry, outage recovery, discovery exclusions, token refresh and accessory restoration. They do not authenticate to a live MyTESY account or operate a physical heater.
|
|
135
|
+
|
|
136
|
+
## Publishing
|
|
137
|
+
|
|
138
|
+
The metadata targets `Kosztyk/homebridge-tesy-native`. Create that GitHub repository and upload this source if you want to publish it. The plugin name and `homebridge-plugin` / `supports-hap` keywords are included for Homebridge discovery after npm publication.
|
|
139
|
+
|
|
140
|
+
After pushing your source and testing it, publish from the source directory with your own npm account:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm login
|
|
144
|
+
npm run check
|
|
145
|
+
npm test
|
|
146
|
+
npm pack --dry-run
|
|
147
|
+
npm publish --access public
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The archive is ready for publication; creating it does not publish to GitHub or npm. Registry availability of the package name must be checked when publishing. No Homebridge verification status is claimed.
|
|
151
|
+
|
|
152
|
+
## References
|
|
153
|
+
|
|
154
|
+
- User-supplied `tesy.zip`, integration version 1.1.0: `api.py`, `const.py`, `coordinator.py`, `climate.py`, `status.py` and the included portal bundle.
|
|
155
|
+
- [Homebridge dynamic platform API](https://developers.homebridge.io/homebridge/interfaces/DynamicPlatformPlugin.html)
|
|
156
|
+
- [Homebridge plugin conventions](https://github.com/homebridge/plugins)
|
|
157
|
+
- [MQTT.js API](https://github.com/mqttjs/MQTT.js)
|
|
158
|
+
|
|
159
|
+
MIT license. Unofficial community software.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "TesyNative",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Connect MyTESY cloud convectors using the same account details as your Home Assistant TESY integration. Devices appear as thermostats with Heat/Off and a 10–30 °C setpoint.",
|
|
6
|
+
"schema": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["username", "password", "userId"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {"title": "Plugin name", "type": "string", "default": "TESY Native"},
|
|
11
|
+
"username": {"title": "MyTESY email", "type": "string", "format": "email", "required": true},
|
|
12
|
+
"password": {"title": "MyTESY password", "type": "string", "format": "password", "required": true},
|
|
13
|
+
"userId": {"title": "MyTESY user ID", "type": "string", "required": true, "description": "Use the user_id from your Home Assistant TESY integration. This is the MyTESY account ID, not the device MAC or your email."},
|
|
14
|
+
"exposePresets": {"title": "Show Comfort / Eco / Sleep switches", "type": "boolean", "default": true, "description": "Selecting a preset preserves the current heater power state. Turning the selected preset off returns to manual heating mode."},
|
|
15
|
+
"pollInterval": {"title": "Cloud refresh interval (seconds)", "type": "integer", "default": 30, "minimum": 15, "maximum": 3600},
|
|
16
|
+
"requestTimeout": {"title": "Request timeout (seconds)", "type": "integer", "default": 8, "minimum": 3, "maximum": 15},
|
|
17
|
+
"offlineAfter": {"title": "Cloud outage grace period (seconds)", "type": "integer", "default": 300, "minimum": 60, "maximum": 3600, "description": "Keep the last snapshot during a short cloud outage. Report No Response after this long without a successful snapshot containing the device."},
|
|
18
|
+
"staleHeatingAfter": {"title": "Stale active heating threshold (seconds)", "type": "integer", "default": 600, "minimum": 60, "maximum": 7200, "description": "Stop displaying active heating when its device timestamp is too old. Old idle/off timestamps alone do not mark a device offline."},
|
|
19
|
+
"debug": {"title": "Detailed device status logging", "type": "boolean", "default": false},
|
|
20
|
+
"devices": {
|
|
21
|
+
"title": "Optional device overrides", "type": "array",
|
|
22
|
+
"description": "Leave this empty to discover all compatible convectors. A blank override row is ignored.",
|
|
23
|
+
"items": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"properties": {
|
|
26
|
+
"mac": {"title": "Device MAC address", "type": "string", "placeholder": "AA:BB:CC:DD:EE:FF", "description": "Copy the MAC from the Homebridge discovery log."},
|
|
27
|
+
"name": {"title": "Name override (optional)", "type": "string"},
|
|
28
|
+
"exclude": {"title": "Exclude this device", "type": "boolean", "default": false},
|
|
29
|
+
"exposePresets": {"title": "Show preset switches for this device", "type": "boolean", "description": "If omitted, use the plugin-wide setting."}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/index.js
ADDED
package/lib/accessory.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PRESETS } = require('./settings');
|
|
4
|
+
const { finiteNumber, derivedState } = require('./state');
|
|
5
|
+
const { TesyError } = require('./client');
|
|
6
|
+
|
|
7
|
+
const COMMAND_GRACE_MS = 15000;
|
|
8
|
+
|
|
9
|
+
class TesyAccessory {
|
|
10
|
+
constructor(platform, accessory, metadata, options = {}) {
|
|
11
|
+
this.platform = platform;
|
|
12
|
+
this.accessory = accessory;
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.id = metadata.id;
|
|
15
|
+
this.name = metadata.name;
|
|
16
|
+
this.record = null;
|
|
17
|
+
this.lastSeen = null;
|
|
18
|
+
this.pending = new Map();
|
|
19
|
+
this.queue = Promise.resolve();
|
|
20
|
+
this.configure(metadata);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
configure(metadata) {
|
|
24
|
+
const { Service, Characteristic: C } = this.platform.api.hap;
|
|
25
|
+
const a = this.accessory;
|
|
26
|
+
a.displayName = this.name;
|
|
27
|
+
a.getService(Service.AccessoryInformation)
|
|
28
|
+
.setCharacteristic(C.Manufacturer, 'TESY')
|
|
29
|
+
.setCharacteristic(C.Model, String(metadata.model || 'Cloud Convector').slice(0, 64))
|
|
30
|
+
.setCharacteristic(C.SerialNumber, metadata.mac || this.id)
|
|
31
|
+
.setCharacteristic(C.FirmwareRevision, String(metadata.firmware || 'Unknown').slice(0, 64));
|
|
32
|
+
this.thermostat = a.getService(Service.Thermostat) || a.addService(Service.Thermostat, this.name);
|
|
33
|
+
this.thermostat.setPrimaryService(true);
|
|
34
|
+
this.thermostat.setCharacteristic(C.Name, this.name);
|
|
35
|
+
if (!this.thermostat.testCharacteristic(C.ConfiguredName)) this.thermostat.addOptionalCharacteristic(C.ConfiguredName);
|
|
36
|
+
this.thermostat.setCharacteristic(C.ConfiguredName, this.name);
|
|
37
|
+
this.thermostat.getCharacteristic(C.CurrentTemperature)
|
|
38
|
+
.setProps({ minValue: -40, maxValue: 100, minStep: 0.1 })
|
|
39
|
+
.onGet(() => this.readCurrentTemperature());
|
|
40
|
+
this.thermostat.getCharacteristic(C.TargetTemperature)
|
|
41
|
+
.setProps({ minValue: 10, maxValue: 30, minStep: 1 })
|
|
42
|
+
.onGet(() => this.readTargetTemperature())
|
|
43
|
+
.onSet(value => this.setTemperature(value));
|
|
44
|
+
this.thermostat.getCharacteristic(C.CurrentHeatingCoolingState).onGet(() => this.readCurrentMode());
|
|
45
|
+
this.thermostat.getCharacteristic(C.TargetHeatingCoolingState)
|
|
46
|
+
.setProps({ minValue: C.TargetHeatingCoolingState.OFF, maxValue: C.TargetHeatingCoolingState.HEAT,
|
|
47
|
+
validValues: [C.TargetHeatingCoolingState.OFF, C.TargetHeatingCoolingState.HEAT] })
|
|
48
|
+
.onGet(() => this.readTargetMode())
|
|
49
|
+
.onSet(value => this.setPower(value));
|
|
50
|
+
this.thermostat.getCharacteristic(C.TemperatureDisplayUnits)
|
|
51
|
+
.onGet(() => a.context.tesyDisplayUnits ?? C.TemperatureDisplayUnits.CELSIUS)
|
|
52
|
+
.onSet(value => {
|
|
53
|
+
if (![C.TemperatureDisplayUnits.CELSIUS, C.TemperatureDisplayUnits.FAHRENHEIT].includes(value)) throw this.invalidValue();
|
|
54
|
+
a.context.tesyDisplayUnits = value;
|
|
55
|
+
this.platform.api.updatePlatformAccessories([a]);
|
|
56
|
+
});
|
|
57
|
+
if (!this.thermostat.testCharacteristic(C.StatusFault)) this.thermostat.addOptionalCharacteristic(C.StatusFault);
|
|
58
|
+
this.thermostat.getCharacteristic(C.StatusFault).onGet(() => this.available() ? C.StatusFault.NO_FAULT : C.StatusFault.GENERAL_FAULT);
|
|
59
|
+
|
|
60
|
+
const expose = this.options.exposePresets ?? this.platform.config.exposePresets;
|
|
61
|
+
this.presets = new Map();
|
|
62
|
+
for (const mode of PRESETS) {
|
|
63
|
+
const subtype = `tesy:preset:${mode}`;
|
|
64
|
+
let service = a.getServiceById(Service.Switch, subtype);
|
|
65
|
+
if (!expose) {
|
|
66
|
+
if (service) a.removeService(service);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const name = `${this.name} ${mode[0].toUpperCase()}${mode.slice(1)}`;
|
|
70
|
+
service ||= a.addService(Service.Switch, name, subtype);
|
|
71
|
+
service.setCharacteristic(C.Name, name);
|
|
72
|
+
if (!service.testCharacteristic(C.ConfiguredName)) service.addOptionalCharacteristic(C.ConfiguredName);
|
|
73
|
+
service.setCharacteristic(C.ConfiguredName, name);
|
|
74
|
+
service.getCharacteristic(C.On)
|
|
75
|
+
.onGet(() => this.readPreset(mode))
|
|
76
|
+
.onSet(value => this.setPreset(mode, Boolean(value)));
|
|
77
|
+
this.presets.set(mode, service);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
available() {
|
|
82
|
+
return this.lastSeen !== null && this.platform.now() - this.lastSeen <= this.platform.config.offlineAfter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
communicationError() {
|
|
86
|
+
const { HapStatusError, HAPStatus } = this.platform.api.hap;
|
|
87
|
+
return new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
invalidValue() {
|
|
91
|
+
const { HapStatusError, HAPStatus } = this.platform.api.hap;
|
|
92
|
+
return new HapStatusError(HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assertAvailable() {
|
|
96
|
+
if (!this.available() || !this.record) throw this.communicationError();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
effectiveState() {
|
|
100
|
+
this.assertAvailable();
|
|
101
|
+
const state = { ...this.record.state };
|
|
102
|
+
const now = this.platform.now();
|
|
103
|
+
for (const [key, pending] of this.pending) {
|
|
104
|
+
if (now < pending.expires) state[key] = pending.value;
|
|
105
|
+
else {
|
|
106
|
+
this.pending.delete(key);
|
|
107
|
+
if (pending.report) this.platform.log.warn(`[${this.name}] Cloud has not confirmed ${key}; showing its reported value again.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return state;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
update(record, receivedAt) {
|
|
114
|
+
this.record = record;
|
|
115
|
+
this.lastSeen = receivedAt;
|
|
116
|
+
for (const [key, pending] of this.pending) {
|
|
117
|
+
if (record.state[key] === pending.value || String(record.state[key]) === String(pending.value)) this.pending.delete(key);
|
|
118
|
+
}
|
|
119
|
+
this.updateValues();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
readCurrentTemperature() {
|
|
123
|
+
const n = finiteNumber(this.effectiveState().current_temp);
|
|
124
|
+
if (n === undefined || n < -40 || n > 100) throw this.communicationError();
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
readTargetTemperature() {
|
|
129
|
+
const n = finiteNumber(this.effectiveState().temp);
|
|
130
|
+
if (n === undefined || n < 10 || n > 30) throw this.communicationError();
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
readCurrentMode() {
|
|
135
|
+
const C = this.platform.api.hap.Characteristic;
|
|
136
|
+
const derived = derivedState(this.effectiveState(), this.platform.now(), this.platform.config.staleHeatingAfter);
|
|
137
|
+
return derived.heating ? C.CurrentHeatingCoolingState.HEAT : C.CurrentHeatingCoolingState.OFF;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
readTargetMode() {
|
|
141
|
+
const C = this.platform.api.hap.Characteristic;
|
|
142
|
+
const derived = derivedState(this.effectiveState(), this.platform.now(), this.platform.config.staleHeatingAfter);
|
|
143
|
+
return derived.on ? C.TargetHeatingCoolingState.HEAT : C.TargetHeatingCoolingState.OFF;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
readPreset(mode) { return this.effectiveState().mode === mode; }
|
|
147
|
+
|
|
148
|
+
setTemperature(value) {
|
|
149
|
+
const n = finiteNumber(value);
|
|
150
|
+
if (n === undefined || n < 10 || n > 30) return Promise.reject(this.invalidValue());
|
|
151
|
+
const temp = Math.round(n);
|
|
152
|
+
return this.mutate(() => ({ command: 'setTemp', payload: { temp }, changes: { temp }, description: `Set temperature to ${temp} °C` }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setPower(value) {
|
|
156
|
+
const C = this.platform.api.hap.Characteristic;
|
|
157
|
+
if (![C.TargetHeatingCoolingState.OFF, C.TargetHeatingCoolingState.HEAT].includes(value)) return Promise.reject(this.invalidValue());
|
|
158
|
+
const on = value === C.TargetHeatingCoolingState.HEAT;
|
|
159
|
+
return this.mutate(() => {
|
|
160
|
+
const changes = { status: on ? 'on' : 'off' };
|
|
161
|
+
const current = derivedState(this.effectiveState(), this.platform.now(), this.platform.config.staleHeatingAfter);
|
|
162
|
+
// A redundant Heat command preserves fresh, observed heating. A power
|
|
163
|
+
// command never creates a new claim of active element heating.
|
|
164
|
+
if (!on || current.staleHeating) changes.heating = 'off';
|
|
165
|
+
return { command: 'onOff', payload: { status: on ? 'on' : 'off' }, changes, description: on ? 'Turned on' : 'Turned off' };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
setPreset(mode, on) {
|
|
170
|
+
if (!PRESETS.includes(mode)) return Promise.reject(this.invalidValue());
|
|
171
|
+
return this.mutate(() => {
|
|
172
|
+
if (!on && this.effectiveState().mode !== mode) return null;
|
|
173
|
+
const name = on ? mode : 'heating';
|
|
174
|
+
return { command: 'setMode', payload: { name }, changes: { mode: name }, description: `Selected ${on ? mode : 'manual heating'} mode` };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
mutate(build) {
|
|
179
|
+
const task = this.queue.then(async () => {
|
|
180
|
+
if (this.platform.stopped) throw this.communicationError();
|
|
181
|
+
this.assertAvailable();
|
|
182
|
+
const action = build();
|
|
183
|
+
if (!action) { this.updateValues(); return; }
|
|
184
|
+
try {
|
|
185
|
+
// Resolve metadata here, after preceding writes, so refreshed tokens win.
|
|
186
|
+
await this.platform.client.sendCommand(this.record.device, action.command, action.payload);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.platform.log.warn(`[${this.name}] Command failed: ${error instanceof TesyError ? error.message : 'TESY command could not be sent.'}`);
|
|
189
|
+
throw this.communicationError();
|
|
190
|
+
}
|
|
191
|
+
const expires = this.platform.now() + COMMAND_GRACE_MS;
|
|
192
|
+
for (const [key, val] of Object.entries(action.changes)) {
|
|
193
|
+
this.pending.set(key, { value: val, expires, report: key !== 'heating' });
|
|
194
|
+
}
|
|
195
|
+
this.platform.log.info(`[${this.name}] ${action.description}.`);
|
|
196
|
+
this.updateValues();
|
|
197
|
+
this.platform.scheduleCommandRefresh();
|
|
198
|
+
});
|
|
199
|
+
this.queue = task.catch(() => {});
|
|
200
|
+
return task;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
updateValues() {
|
|
204
|
+
const C = this.platform.api.hap.Characteristic;
|
|
205
|
+
const readings = [
|
|
206
|
+
[C.CurrentTemperature, () => this.readCurrentTemperature()],
|
|
207
|
+
[C.TargetTemperature, () => this.readTargetTemperature()],
|
|
208
|
+
[C.CurrentHeatingCoolingState, () => this.readCurrentMode()],
|
|
209
|
+
[C.TargetHeatingCoolingState, () => this.readTargetMode()],
|
|
210
|
+
];
|
|
211
|
+
for (const [type, read] of readings) {
|
|
212
|
+
try { this.thermostat.updateCharacteristic(type, read()); }
|
|
213
|
+
catch { this.thermostat.updateCharacteristic(type, this.communicationError()); }
|
|
214
|
+
}
|
|
215
|
+
this.thermostat.updateCharacteristic(C.StatusFault, this.available() ? C.StatusFault.NO_FAULT : C.StatusFault.GENERAL_FAULT);
|
|
216
|
+
for (const [mode, service] of this.presets) {
|
|
217
|
+
try { service.updateCharacteristic(C.On, this.readPreset(mode)); }
|
|
218
|
+
catch { service.updateCharacteristic(C.On, this.communicationError()); }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { TesyAccessory, COMMAND_GRACE_MS };
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomBytes } = require('node:crypto');
|
|
4
|
+
const { API_BASE, ORIGIN, MQTT_URL, PRESETS } = require('./settings');
|
|
5
|
+
|
|
6
|
+
class TesyError extends Error {
|
|
7
|
+
constructor(message, code = 'CLOUD') {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'TesyError';
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class TesyClient {
|
|
15
|
+
constructor(config, deps = {}) {
|
|
16
|
+
this.config = config;
|
|
17
|
+
this.fetch = deps.fetch || globalThis.fetch;
|
|
18
|
+
this.connect = deps.connect || ((url, options) => require('mqtt').connect(url, options));
|
|
19
|
+
this.appId = randomBytes(8).toString('hex');
|
|
20
|
+
this.mqtt = null;
|
|
21
|
+
this.connecting = null;
|
|
22
|
+
this.closed = false;
|
|
23
|
+
this.requests = new Set();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async request(path, body) {
|
|
27
|
+
if (this.closed) throw new TesyError('TESY client is shutting down.', 'CLOSED');
|
|
28
|
+
const credentials = {
|
|
29
|
+
userID: this.config.userId, userEmail: this.config.username,
|
|
30
|
+
userPass: this.config.password, lang: 'en',
|
|
31
|
+
};
|
|
32
|
+
const url = new URL(API_BASE + path);
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
this.requests.add(controller);
|
|
35
|
+
const timer = setTimeout(() => controller.abort(), this.config.requestTimeout);
|
|
36
|
+
const options = {
|
|
37
|
+
method: body ? 'POST' : 'GET', signal: controller.signal, redirect: 'error',
|
|
38
|
+
headers: { Origin: ORIGIN, Referer: `${ORIGIN}/${body ? 'dashboard' : ''}`, Accept: 'application/json, text/plain, */*' },
|
|
39
|
+
};
|
|
40
|
+
if (body) {
|
|
41
|
+
options.headers['Content-Type'] = 'application/json';
|
|
42
|
+
options.body = JSON.stringify({ ...body, ...credentials });
|
|
43
|
+
} else {
|
|
44
|
+
url.search = new URLSearchParams(credentials).toString();
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const response = await this.fetch(url, options);
|
|
48
|
+
if ([401, 403].includes(response.status)) throw new TesyError('MyTESY rejected the account credentials. Check email, password and user ID.', 'AUTH');
|
|
49
|
+
if (!response.ok) throw new TesyError(`MyTESY returned HTTP ${response.status}.`, 'HTTP');
|
|
50
|
+
if (body) {
|
|
51
|
+
// app-log is best effort; drain the response so the connection can be reused.
|
|
52
|
+
await response.text();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try { data = await response.json(); } catch { throw new TesyError('MyTESY returned an invalid JSON response.', 'FORMAT'); }
|
|
57
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new TesyError('MyTESY returned an unexpected device list.', 'FORMAT');
|
|
58
|
+
if (String(data.error) === '1') throw new TesyError('MyTESY rejected the account credentials. Check email, password and user ID.', 'AUTH');
|
|
59
|
+
if ((data.error !== undefined && !['0', '', 'false', 'null'].includes(String(data.error))) || data.errors) {
|
|
60
|
+
throw new TesyError('MyTESY reported an error while fetching the device list.', 'CLOUD');
|
|
61
|
+
}
|
|
62
|
+
return data;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (error instanceof TesyError) throw error;
|
|
65
|
+
// Raw fetch/MQTT errors may contain credentials or tokens in URLs/topics.
|
|
66
|
+
if (controller.signal.aborted) throw new TesyError('MyTESY request timed out or was cancelled.', 'TIMEOUT');
|
|
67
|
+
throw new TesyError('Cannot reach MyTESY. Check the internet connection and cloud service.', 'NETWORK');
|
|
68
|
+
} finally {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
this.requests.delete(controller);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
getDevices() { return this.request('/get-my-devices'); }
|
|
75
|
+
|
|
76
|
+
ensureMqtt() {
|
|
77
|
+
if (this.closed) return Promise.reject(new TesyError('TESY client is shutting down.', 'CLOSED'));
|
|
78
|
+
if (this.mqtt?.connected) return Promise.resolve(this.mqtt);
|
|
79
|
+
if (this.connecting) return this.connecting;
|
|
80
|
+
if (this.mqtt) this.mqtt.end(true);
|
|
81
|
+
let client;
|
|
82
|
+
try {
|
|
83
|
+
client = this.connect(MQTT_URL, {
|
|
84
|
+
clientId: `hb_tesy_${this.appId}`, username: 'client1', password: '123',
|
|
85
|
+
protocolVersion: 4, keepalive: 20, clean: true, reconnectPeriod: 0,
|
|
86
|
+
connectTimeout: this.config.requestTimeout, queueQoSZero: false, resubscribe: false,
|
|
87
|
+
rejectUnauthorized: true,
|
|
88
|
+
wsOptions: { rejectUnauthorized: true, headers: { Origin: ORIGIN } },
|
|
89
|
+
});
|
|
90
|
+
} catch { return Promise.reject(new TesyError('Cannot create a secure TESY MQTT connection.', 'MQTT')); }
|
|
91
|
+
this.mqtt = client;
|
|
92
|
+
// Always consume async errors, including after an idle connection is lost.
|
|
93
|
+
client.on('error', () => {});
|
|
94
|
+
client.on('close', () => { if (this.mqtt === client) this.mqtt = null; });
|
|
95
|
+
const pending = new Promise((resolve, reject) => {
|
|
96
|
+
let finished = false;
|
|
97
|
+
const finish = error => {
|
|
98
|
+
if (finished) return;
|
|
99
|
+
finished = true;
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
client.removeListener('connect', onConnect);
|
|
102
|
+
client.removeListener('error', onError);
|
|
103
|
+
client.removeListener('close', onClose);
|
|
104
|
+
if (error) {
|
|
105
|
+
if (this.mqtt === client) this.mqtt = null;
|
|
106
|
+
client.end(true);
|
|
107
|
+
reject(error);
|
|
108
|
+
} else resolve(client);
|
|
109
|
+
};
|
|
110
|
+
const onConnect = () => finish();
|
|
111
|
+
const onError = () => finish(new TesyError('TESY MQTT connection failed. Check access to mqtt.tesy.com:8083.', 'MQTT'));
|
|
112
|
+
const onClose = () => finish(new TesyError('TESY MQTT connection closed before it was ready.', 'MQTT'));
|
|
113
|
+
const timer = setTimeout(() => finish(new TesyError('TESY MQTT connection timed out.', 'TIMEOUT')), this.config.requestTimeout);
|
|
114
|
+
client.once('connect', onConnect);
|
|
115
|
+
client.once('error', onError);
|
|
116
|
+
client.once('close', onClose);
|
|
117
|
+
});
|
|
118
|
+
this.connecting = pending;
|
|
119
|
+
const clear = () => { if (this.connecting === pending) this.connecting = null; };
|
|
120
|
+
pending.then(clear, clear);
|
|
121
|
+
return pending;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async sendCommand(device, command, payload) {
|
|
125
|
+
const mac = String(device.mac || device.state?.mac || '').trim();
|
|
126
|
+
const model = String(device.model || '').trim();
|
|
127
|
+
const token = String(device.token || '').trim();
|
|
128
|
+
if ([mac, model, token].some(v => !v || /[\/+#\u0000]/.test(v))) {
|
|
129
|
+
throw new TesyError('This device has no valid MAC, model or control token in MyTESY. Refresh device discovery.', 'DEVICE');
|
|
130
|
+
}
|
|
131
|
+
const valid = (command === 'onOff' && ['on', 'off'].includes(payload.status))
|
|
132
|
+
|| (command === 'setTemp' && Number.isInteger(payload.temp) && payload.temp >= 10 && payload.temp <= 30)
|
|
133
|
+
|| (command === 'setMode' && [...PRESETS, 'heating'].includes(payload.name));
|
|
134
|
+
if (!valid) throw new TesyError('Unsupported TESY command or value.', 'COMMAND');
|
|
135
|
+
const client = await this.ensureMqtt();
|
|
136
|
+
if (this.closed || !client.connected) throw new TesyError('TESY MQTT is not connected.', 'MQTT');
|
|
137
|
+
await new Promise((resolve, reject) => {
|
|
138
|
+
let finished = false;
|
|
139
|
+
const finish = error => {
|
|
140
|
+
if (finished) return;
|
|
141
|
+
finished = true;
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
client.removeListener('close', onClose);
|
|
144
|
+
client.removeListener('error', onError);
|
|
145
|
+
if (error) {
|
|
146
|
+
if (this.mqtt === client) this.mqtt = null;
|
|
147
|
+
client.end(true);
|
|
148
|
+
reject(error);
|
|
149
|
+
} else resolve();
|
|
150
|
+
};
|
|
151
|
+
const onClose = () => finish(new TesyError('TESY MQTT disconnected while sending the command.', 'MQTT'));
|
|
152
|
+
const onError = () => finish(new TesyError('TESY MQTT could not send the command.', 'MQTT'));
|
|
153
|
+
const timer = setTimeout(() => finish(new TesyError('TESY command publish timed out.', 'TIMEOUT')), this.config.requestTimeout);
|
|
154
|
+
client.once('close', onClose);
|
|
155
|
+
client.once('error', onError);
|
|
156
|
+
try {
|
|
157
|
+
client.publish(`v1/${mac}/request/${model}/${token}/${command}`,
|
|
158
|
+
JSON.stringify({ app_id: this.appId, ...payload }), { qos: 0, retain: false },
|
|
159
|
+
error => finish(error ? new TesyError('TESY MQTT could not send the command.', 'MQTT') : undefined));
|
|
160
|
+
} catch { finish(new TesyError('TESY MQTT could not send the command.', 'MQTT')); }
|
|
161
|
+
});
|
|
162
|
+
// MQTT publishing is the control path; failure of the app's audit endpoint
|
|
163
|
+
// must not turn a successful publish into a HomeKit error or a retry.
|
|
164
|
+
void this.request('/app-log', { mac, command, payload }).catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
close() {
|
|
168
|
+
this.closed = true;
|
|
169
|
+
for (const controller of this.requests) controller.abort();
|
|
170
|
+
if (this.mqtt) this.mqtt.end(true);
|
|
171
|
+
this.mqtt = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { TesyClient, TesyError };
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { TesyClient, TesyError } = require('./client');
|
|
4
|
+
const { TesyAccessory } = require('./accessory');
|
|
5
|
+
const { PLUGIN_NAME, PLATFORM_NAME, configuration, normaliseMac } = require('./settings');
|
|
6
|
+
const { normaliseDevices } = require('./state');
|
|
7
|
+
|
|
8
|
+
class TesyPlatform {
|
|
9
|
+
constructor(log, config, api, deps = {}) {
|
|
10
|
+
this.log = log;
|
|
11
|
+
this.api = api;
|
|
12
|
+
this.config = configuration(config);
|
|
13
|
+
this.now = deps.now || Date.now;
|
|
14
|
+
this.client = deps.client || new TesyClient(this.config);
|
|
15
|
+
this.cached = new Map();
|
|
16
|
+
this.controllers = new Map();
|
|
17
|
+
this.commandTimers = new Set();
|
|
18
|
+
this.refreshing = null;
|
|
19
|
+
this.stopped = false;
|
|
20
|
+
this.launched = false;
|
|
21
|
+
this.lastError = null;
|
|
22
|
+
this.lastCount = null;
|
|
23
|
+
api.on('didFinishLaunching', () => { void this.start(); });
|
|
24
|
+
api.on('shutdown', () => this.stop());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override(id) { return this.config.devices.find(d => d.mac === id) || {}; }
|
|
28
|
+
|
|
29
|
+
configureAccessory(accessory) {
|
|
30
|
+
const metadata = accessory.context.tesy;
|
|
31
|
+
const id = normaliseMac(metadata?.mac || metadata?.id);
|
|
32
|
+
if (!id) {
|
|
33
|
+
this.log.warn(`Ignoring a TESY cache entry with no device ID: ${accessory.displayName}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.cached.set(id, accessory);
|
|
37
|
+
if (this.override(id).exclude === true) return;
|
|
38
|
+
const meta = { ...metadata, id, name: this.override(id).name?.trim() || metadata.name || accessory.displayName };
|
|
39
|
+
this.controllers.set(id, new TesyAccessory(this, accessory, meta, this.override(id)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start() {
|
|
43
|
+
if (this.launched || this.stopped) return;
|
|
44
|
+
this.launched = true;
|
|
45
|
+
for (const id of this.cached.keys()) if (this.override(id).exclude === true) this.removeExcluded(id);
|
|
46
|
+
if (!this.config.username || !this.config.password || !this.config.userId) {
|
|
47
|
+
this.log.error('Enter the MyTESY email, password and user ID in the TESY Native settings, then restart Homebridge.');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const tick = async () => {
|
|
51
|
+
await this.refresh();
|
|
52
|
+
if (this.stopped) return;
|
|
53
|
+
const delay = this.lastError === 'AUTH' ? Math.max(60000, this.config.pollInterval) : this.config.pollInterval;
|
|
54
|
+
this.pollTimer = setTimeout(() => { void tick(); }, delay);
|
|
55
|
+
this.pollTimer.unref?.();
|
|
56
|
+
};
|
|
57
|
+
await tick();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
removeExcluded(id) {
|
|
61
|
+
const accessory = this.cached.get(id);
|
|
62
|
+
if (accessory) {
|
|
63
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
64
|
+
this.log.info(`Excluded device: ${accessory.displayName}`);
|
|
65
|
+
}
|
|
66
|
+
this.controllers.delete(id);
|
|
67
|
+
this.cached.delete(id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
refresh() {
|
|
71
|
+
if (this.stopped) return Promise.resolve(false);
|
|
72
|
+
if (this.refreshing) return this.refreshing;
|
|
73
|
+
const task = this.refreshOnce();
|
|
74
|
+
this.refreshing = task;
|
|
75
|
+
const clear = () => { if (this.refreshing === task) this.refreshing = null; };
|
|
76
|
+
task.then(clear, clear);
|
|
77
|
+
return task;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async refreshOnce() {
|
|
81
|
+
try {
|
|
82
|
+
const raw = await this.client.getDevices();
|
|
83
|
+
if (this.stopped) return false;
|
|
84
|
+
const records = normaliseDevices(raw);
|
|
85
|
+
const now = this.now();
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
for (const [id, record] of records) {
|
|
88
|
+
const options = this.override(id);
|
|
89
|
+
if (options.exclude === true) { this.removeExcluded(id); continue; }
|
|
90
|
+
seen.add(id);
|
|
91
|
+
const name = typeof options.name === 'string' && options.name.trim() ? options.name.trim() : record.name;
|
|
92
|
+
const metadata = {
|
|
93
|
+
id, mac: record.mac, name,
|
|
94
|
+
model: String(record.device.model_type || record.device.model || 'Cloud Convector'),
|
|
95
|
+
firmware: String(record.device.firmware_version || 'Unknown'),
|
|
96
|
+
};
|
|
97
|
+
let accessory = this.cached.get(id);
|
|
98
|
+
const fresh = !accessory;
|
|
99
|
+
if (fresh) {
|
|
100
|
+
accessory = new this.api.platformAccessory(name, this.api.hap.uuid.generate(`tesy:${id}`), this.api.hap.Categories.THERMOSTAT);
|
|
101
|
+
this.cached.set(id, accessory);
|
|
102
|
+
}
|
|
103
|
+
let controller = this.controllers.get(id);
|
|
104
|
+
const changed = JSON.stringify(accessory.context.tesy) !== JSON.stringify(metadata);
|
|
105
|
+
// Only persist safe identity fields. Cloud tokens and account credentials
|
|
106
|
+
// stay in memory and never enter Homebridge's accessory cache.
|
|
107
|
+
accessory.context.tesy = metadata;
|
|
108
|
+
if (!controller) {
|
|
109
|
+
controller = new TesyAccessory(this, accessory, metadata, options);
|
|
110
|
+
this.controllers.set(id, controller);
|
|
111
|
+
} else if (changed) {
|
|
112
|
+
controller.name = name;
|
|
113
|
+
controller.configure(metadata);
|
|
114
|
+
}
|
|
115
|
+
controller.update(record, now);
|
|
116
|
+
if (fresh) {
|
|
117
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
118
|
+
this.log.info(`Added TESY thermostat: ${name} (${record.mac}, ${metadata.model})`);
|
|
119
|
+
} else if (changed) this.api.updatePlatformAccessories([accessory]);
|
|
120
|
+
if (this.config.debug) this.log.info(`[${name}] Temperature ${record.state.current_temp} °C, target ${record.state.temp} °C, status ${record.state.status}, heating ${record.state.heating}, mode ${record.state.mode}.`);
|
|
121
|
+
}
|
|
122
|
+
for (const [id, controller] of this.controllers) if (!seen.has(id)) controller.updateValues();
|
|
123
|
+
if (this.lastError) this.log.info('MyTESY cloud connection recovered.');
|
|
124
|
+
this.lastError = null;
|
|
125
|
+
if (this.lastCount !== seen.size) {
|
|
126
|
+
if (seen.size === 0) this.log.warn('No compatible MyTESY convectors found. Check the account, user ID and device exclusions. Legacy TESY Cloud devices and water heaters use another protocol.');
|
|
127
|
+
else this.log.info(`Found ${seen.size} TESY convector${seen.size === 1 ? '' : 's'}.`);
|
|
128
|
+
this.lastCount = seen.size;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = error instanceof TesyError ? error.code : 'INTERNAL';
|
|
133
|
+
if (this.lastError !== code) {
|
|
134
|
+
this.log.warn(`TESY refresh failed: ${error instanceof TesyError ? error.message : 'Could not process the MyTESY device list.'} Retrying automatically.`);
|
|
135
|
+
}
|
|
136
|
+
this.lastError = code;
|
|
137
|
+
for (const controller of this.controllers.values()) controller.updateValues();
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
scheduleCommandRefresh() {
|
|
143
|
+
if (this.stopped) return;
|
|
144
|
+
for (const timer of this.commandTimers) clearTimeout(timer);
|
|
145
|
+
this.commandTimers.clear();
|
|
146
|
+
for (const delay of [1500, 5000, 16000]) {
|
|
147
|
+
const timer = setTimeout(() => {
|
|
148
|
+
this.commandTimers.delete(timer);
|
|
149
|
+
void this.refresh();
|
|
150
|
+
}, delay);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
this.commandTimers.add(timer);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
stop() {
|
|
157
|
+
this.stopped = true;
|
|
158
|
+
clearTimeout(this.pollTimer);
|
|
159
|
+
for (const timer of this.commandTimers) clearTimeout(timer);
|
|
160
|
+
this.commandTimers.clear();
|
|
161
|
+
this.client.close();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { TesyPlatform };
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const PLUGIN_NAME = 'homebridge-tesy-native';
|
|
4
|
+
const PLATFORM_NAME = 'TesyNative';
|
|
5
|
+
const API_BASE = 'https://ad.mytesy.com/rest';
|
|
6
|
+
const ORIGIN = 'https://v4.mytesy.com';
|
|
7
|
+
const MQTT_URL = 'wss://mqtt.tesy.com:8083/';
|
|
8
|
+
const PRESETS = ['comfort', 'eco', 'sleep'];
|
|
9
|
+
|
|
10
|
+
function bounded(value, fallback, min, max) {
|
|
11
|
+
const n = value === '' || value === null ? NaN : Number(value);
|
|
12
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normaliseMac(value) {
|
|
16
|
+
const s = String(value ?? '').trim().replace(/[:-]/g, '').toUpperCase();
|
|
17
|
+
return /^[0-9A-F]{12}$/.test(s) ? s : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function configuration(config = {}) {
|
|
21
|
+
if (!config || typeof config !== 'object') config = {};
|
|
22
|
+
const userId = String(config.userId ?? config.user_id ?? '').trim();
|
|
23
|
+
return {
|
|
24
|
+
username: String(config.username ?? '').trim(),
|
|
25
|
+
password: String(config.password ?? ''),
|
|
26
|
+
userId,
|
|
27
|
+
pollInterval: bounded(config.pollInterval, 30, 15, 3600) * 1000,
|
|
28
|
+
requestTimeout: bounded(config.requestTimeout, 8, 3, 15) * 1000,
|
|
29
|
+
offlineAfter: bounded(config.offlineAfter, 300, 60, 3600) * 1000,
|
|
30
|
+
staleHeatingAfter: bounded(config.staleHeatingAfter, 600, 60, 7200) * 1000,
|
|
31
|
+
exposePresets: config.exposePresets !== false,
|
|
32
|
+
debug: config.debug === true,
|
|
33
|
+
devices: (Array.isArray(config.devices) ? config.devices : [])
|
|
34
|
+
.filter(d => d && typeof d === 'object' && normaliseMac(d.mac))
|
|
35
|
+
.map(d => ({
|
|
36
|
+
mac: normaliseMac(d.mac),
|
|
37
|
+
name: typeof d.name === 'string' ? d.name.trim() : undefined,
|
|
38
|
+
exclude: d.exclude === true,
|
|
39
|
+
exposePresets: typeof d.exposePresets === 'boolean' ? d.exposePresets : undefined,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { PLUGIN_NAME, PLATFORM_NAME, API_BASE, ORIGIN, MQTT_URL, PRESETS, normaliseMac, configuration };
|
package/lib/state.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normaliseMac } = require('./settings');
|
|
4
|
+
|
|
5
|
+
function stateOn(value) {
|
|
6
|
+
return value === true || value === 1 || (typeof value === 'string' && ['on', '1', 'true'].includes(value.toLowerCase().trim()));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function finiteNumber(value) {
|
|
10
|
+
if (value === null || value === undefined || typeof value === 'boolean' || String(value).trim() === '') return undefined;
|
|
11
|
+
const n = Number(value);
|
|
12
|
+
return Number.isFinite(n) ? n : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function timestamp(value) {
|
|
16
|
+
if (typeof value !== 'string' || !value.trim()) return undefined;
|
|
17
|
+
let text = value.trim();
|
|
18
|
+
// The HA helper treats dates without an explicit offset as UTC.
|
|
19
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)) text = text.replace(' ', 'T') + 'Z';
|
|
20
|
+
const n = Date.parse(text);
|
|
21
|
+
return Number.isFinite(n) ? n : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function derivedState(state, now, staleAfter) {
|
|
25
|
+
const updated = timestamp(state.updated_at);
|
|
26
|
+
const staleHeating = stateOn(state.heating) && (updated === undefined || now - updated > staleAfter);
|
|
27
|
+
const on = !staleHeating && (stateOn(state.status) || stateOn(state.heating));
|
|
28
|
+
return { on, heating: !staleHeating && stateOn(state.heating), staleHeating };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normaliseDevices(raw) {
|
|
32
|
+
const devices = new Map();
|
|
33
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
|
35
|
+
const state = value.state;
|
|
36
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) continue;
|
|
37
|
+
const mac = String(value.mac || state.mac || key).trim();
|
|
38
|
+
const id = normaliseMac(mac);
|
|
39
|
+
if (!id) continue;
|
|
40
|
+
// The supplied integration's commands are for new MyTESY convectors.
|
|
41
|
+
// Boilers and legacy power_status devices use a different protocol.
|
|
42
|
+
if (!Object.hasOwn(state, 'status') || !Object.hasOwn(state, 'temp') || !Object.hasOwn(state, 'current_temp')) continue;
|
|
43
|
+
const kind = String(value.type ?? value.device_type ?? '').toLowerCase();
|
|
44
|
+
if (kind.includes('boiler') || kind.includes('water')) continue;
|
|
45
|
+
devices.set(id, {
|
|
46
|
+
id, mac, device: { ...value, mac }, state: { ...state },
|
|
47
|
+
name: typeof value.deviceName === 'string' && value.deviceName.trim()
|
|
48
|
+
? value.deviceName.trim() : `Tesy Convector ${id.slice(-6)}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return devices;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { stateOn, finiteNumber, timestamp, derivedState, normaliseDevices };
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-tesy-native",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "TESY MyTESY cloud convectors for Homebridge: thermostats, temperature control and presets.",
|
|
5
|
+
"displayName": "TESY Native",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"type": "commonjs",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": "^22.12.0 || ^24.0.0",
|
|
10
|
+
"homebridge": "^1.8.0 || ^2.0.0"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["homebridge-plugin", "supports-hap", "homebridge", "homekit", "tesy", "mytesy", "convector", "thermostat", "heater"],
|
|
13
|
+
"author": {"name": "Kosztyk", "url": "https://github.com/Kosztyk"},
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {"type": "git", "url": "git+https://github.com/Kosztyk/homebridge-tesy-native.git"},
|
|
16
|
+
"homepage": "https://github.com/Kosztyk/homebridge-tesy-native#readme",
|
|
17
|
+
"bugs": {"url": "https://github.com/Kosztyk/homebridge-tesy-native/issues"},
|
|
18
|
+
"files": ["index.js", "lib/", "scripts/check.js", "config.schema.json", "config.example.json", "README.md", "CHANGELOG.md", "LICENSE", "NOTICE"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"check": "node scripts/check.js",
|
|
21
|
+
"test": "node --test test/*.test.js",
|
|
22
|
+
"prepack": "npm run check"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {"mqtt": "^5.16.1"},
|
|
25
|
+
"devDependencies": {"homebridge": "2.4.0"},
|
|
26
|
+
"publishConfig": {"access": "public"}
|
|
27
|
+
}
|
package/scripts/check.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { spawnSync } = require('node:child_process');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const root = path.resolve(__dirname, '..');
|
|
6
|
+
const files = ['index.js', ...fs.readdirSync(path.join(root, 'lib')).filter(n => n.endsWith('.js')).map(n => `lib/${n}`)];
|
|
7
|
+
for (const file of files) {
|
|
8
|
+
const r = spawnSync(process.execPath, ['--check', path.join(root, file)], { stdio: 'inherit' });
|
|
9
|
+
if (r.status !== 0) process.exit(r.status || 1);
|
|
10
|
+
}
|
|
11
|
+
for (const file of ['package.json', 'config.schema.json', 'config.example.json']) JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
|
|
12
|
+
const pkg = require(path.join(root, 'package.json'));
|
|
13
|
+
const schema = require(path.join(root, 'config.schema.json'));
|
|
14
|
+
if (!pkg.keywords.includes('homebridge-plugin') || schema.pluginAlias !== 'TesyNative' || schema.pluginType !== 'platform') throw Error('Invalid Homebridge plugin metadata.');
|
|
15
|
+
console.log('JavaScript syntax, JSON and plugin metadata are valid.');
|