homebridge-tesy-heater-mqtt 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 ADDED
@@ -0,0 +1,84 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2025-12-15
11
+
12
+ ### Added
13
+ - **Platform plugin architecture** - complete rewrite from Accessory to Platform
14
+ - **Automatic device discovery** - finds all Tesy heaters in your account automatically
15
+ - **Multi-device support** - manages multiple heaters with single configuration
16
+ - Shared MQTT connection for all devices
17
+ - Device name discovery from Tesy Cloud
18
+ - Persistent device caching across restarts
19
+ - Automatic device addition/removal based on account
20
+ - GitHub Actions CI/CD workflows
21
+ - Automated testing on push and PR (Node.js 18, 20, 22, 24)
22
+ - Automated NPM publishing on version tags
23
+ - `.npmignore` file to exclude dev files from package
24
+ - Debug logging for device discovery
25
+ - Support for Node.js v24
26
+
27
+ ### Changed
28
+ - **BREAKING**: Configuration format changed from `accessories` to `platforms`
29
+ - **BREAKING**: No longer requires `device_id` parameter (automatic discovery)
30
+ - Simplified configuration - only credentials needed
31
+ - Updated README with Platform configuration
32
+ - Added setup guide for GitHub Actions
33
+ - Improved logging with per-device context
34
+ - Default pullInterval changed from 10000ms to 60000ms (1 minute)
35
+ - Updated config.schema.json for Platform type with helpful UI
36
+
37
+ ### Fixed
38
+ - Device name retrieval from correct API field (`deviceData.deviceName`)
39
+ - MQTT connection now shared across all devices
40
+ - Better error handling for missing credentials
41
+
42
+ ### Technical
43
+ - Single MQTT connection for all devices (better resource usage)
44
+ - Devices cached by Homebridge (faster startup)
45
+ - Platform meets Homebridge Verified Plugin requirements
46
+ - Cleaner codebase with better separation of concerns
47
+
48
+ ## [0.0.1] - 2025-12-15
49
+
50
+ ### Added
51
+ - Initial release based on homebridge-tesy-heater
52
+ - Support for Tesy API v4
53
+ - MQTT control protocol implementation
54
+ - Real-time device control via MQTT WebSocket
55
+ - Temperature setter and getter
56
+ - On/Off control
57
+ - Current temperature reporting
58
+ - Target temperature configuration
59
+ - Unit tests with ~35% coverage
60
+ - Test scripts for MQTT debugging
61
+ - Support for Homebridge v2.x
62
+
63
+ ### Changed
64
+ - Migrated from Tesy API v3 to v4
65
+ - Replaced REST API control with MQTT
66
+ - Updated configuration for Homebridge v2 compatibility
67
+ - Modernized Node.js compatibility (18.x, 20.x, 22.x)
68
+
69
+ ### Fixed
70
+ - Missing getter for HeatingThresholdTemperature characteristic
71
+ - Device not responding to REST API commands
72
+ - Compatibility issues with latest Homebridge versions
73
+
74
+ ### Technical Details
75
+ - MQTT broker: wss://mqtt.tesy.com:8083/mqtt
76
+ - Command topics: v1/{MAC}/request/{MODEL}/{TOKEN}/{COMMAND}
77
+ - Response topics: v1/{MAC}/response/{MODEL}/{TOKEN}/{COMMAND}
78
+ - Supported commands: onOff, setTemp, setMode
79
+
80
+ ### Tested Devices
81
+ - Tesy CN 06 100 EА CLOUD AS W
82
+
83
+ [Unreleased]: https://github.com/svjakrm/homebridge-tesy-heater-mqtt/compare/v0.0.1...HEAD
84
+ [0.0.1]: https://github.com/svjakrm/homebridge-tesy-heater-mqtt/releases/tag/v0.0.1
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Original work Copyright (c) 2021 Dobriyan Benov
4
+ Modified work Copyright (c) 2025 Aleksey Molchanov
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.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # homebridge-tesy-heater-mqtt
2
+
3
+ [![Test](https://github.com/svjakrm/homebridge-tesy-heater-mqtt/actions/workflows/test.yml/badge.svg)](https://github.com/svjakrm/homebridge-tesy-heater-mqtt/actions/workflows/test.yml)
4
+ [![npm version](https://badge.fury.io/js/homebridge-tesy-heater-mqtt.svg)](https://badge.fury.io/js/homebridge-tesy-heater-mqtt)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ Homebridge plugin for Tesy heaters using MQTT control (Tesy API v4).
8
+
9
+ ## Overview
10
+
11
+ This plugin allows you to control your Tesy smart heaters through Apple HomeKit using Homebridge. It uses the latest Tesy API v4 with MQTT for real-time device control.
12
+
13
+ **Features:**
14
+ - **Automatic device discovery** - finds all Tesy heaters in your account
15
+ - **Multi-device support** - manages multiple heaters with one configuration
16
+ - Turn heater on/off
17
+ - Set target temperature
18
+ - View current temperature
19
+ - View heating status
20
+ - Temperature range configuration
21
+ - Real-time MQTT control
22
+ - Persistent device caching
23
+
24
+ ## Compatibility
25
+
26
+ This plugin has been tested and confirmed working with:
27
+ - **Tesy CN 06 100 EА CLOUD AS W**
28
+
29
+ It will most likely work with other **Tesy CN06 series** devices and possibly with other [Tesy FinEco Cloud](https://tesy.bg/produkti/otoplenie-i-grija-za-vyzduha/elektricheski-konvektori/view_group/1) convectors that support the Tesy Cloud v4 API.
30
+
31
+ If you successfully use this plugin with other Tesy models, please let us know!
32
+
33
+ ## Credits
34
+
35
+ This project is based on [homebridge-tesy-heater](https://github.com/benov84/homebridge-tesy-heater) by [Dobriyan Benov](https://github.com/benov84). The original plugin was updated to work with Tesy API v4 and MQTT control protocol.
36
+
37
+ **Major changes from original:**
38
+ - Migrated from Tesy API v3 to API v4
39
+ - Implemented MQTT control for device commands
40
+ - Added `mqtt` dependency for real-time communication
41
+ - Removed obsolete authentication methods
42
+ - Added getter for `HeatingThresholdTemperature` characteristic
43
+
44
+ ## Installation
45
+
46
+ ### Option 1: Install from npm (when published)
47
+ ```bash
48
+ npm install -g homebridge-tesy-heater-mqtt
49
+ ```
50
+
51
+ ### Option 2: Install from GitHub
52
+ ```bash
53
+ npm install -g https://github.com/svjakrm/homebridge-tesy-heater-mqtt.git
54
+ ```
55
+
56
+ ### Option 3: Manual installation
57
+ ```bash
58
+ git clone https://github.com/svjakrm/homebridge-tesy-heater-mqtt.git
59
+ cd homebridge-tesy-heater-mqtt
60
+ npm install -g .
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ Add this platform to your Homebridge `config.json`:
66
+
67
+ ```json
68
+ {
69
+ "platforms": [
70
+ {
71
+ "platform": "TesyHeater",
72
+ "name": "TesyHeater",
73
+ "userid": "YOUR_USER_ID",
74
+ "username": "your.email@example.com",
75
+ "password": "your_password",
76
+ "maxTemp": 30,
77
+ "minTemp": 10,
78
+ "pullInterval": 60000
79
+ }
80
+ ]
81
+ }
82
+ ```
83
+
84
+ **Note:** The plugin will **automatically discover** all Tesy heaters linked to your account. No need to specify device IDs!
85
+
86
+ ### Configuration Parameters
87
+
88
+ | Parameter | Required | Description | Default |
89
+ |-----------|----------|-------------|---------|
90
+ | `platform` | **Yes** | Must be `TesyHeater` | - |
91
+ | `name` | **Yes** | Platform name | `TesyHeater` |
92
+ | `userid` | **Yes** | Your Tesy account user ID | - |
93
+ | `username` | **Yes** | Your Tesy account email | - |
94
+ | `password` | **Yes** | Your Tesy account password | - |
95
+ | `maxTemp` | No | Maximum temperature (°C) | `30` |
96
+ | `minTemp` | No | Minimum temperature (°C) | `10` |
97
+ | `pullInterval` | No | Status update interval (ms) | `60000` |
98
+
99
+ ### How to Get Configuration Values
100
+
101
+ **Get your credentials** (`userid`, `username`, `password`):
102
+ - These are your Tesy Cloud account credentials
103
+ - `userid` can be found in the Tesy Cloud web interface or via API
104
+ - `username` is your email used to log in to Tesy Cloud
105
+ - `password` is your Tesy Cloud password
106
+
107
+ **To find your User ID**, run this command:
108
+ ```bash
109
+ curl -s 'https://ad.mytesy.com/rest/get-my-devices?userEmail=YOUR_EMAIL&userPass=YOUR_PASSWORD&lang=en' | python3 -m json.tool
110
+ ```
111
+
112
+ The response will show your `userid` and all your devices. Example:
113
+ ```json
114
+ {
115
+ "AA:BB:CC:DD:EE:FF": {
116
+ "deviceName": "Living Room Heater",
117
+ "token": "abc1234",
118
+ "state": {
119
+ "id": 123456,
120
+ "mac": "AA:BB:CC:DD:EE:FF",
121
+ "status": "on",
122
+ "temp": 20,
123
+ ...
124
+ }
125
+ }
126
+ }
127
+ ```
128
+
129
+ **That's it!** The plugin will automatically discover all devices in your account.
130
+
131
+ ## Technical Details
132
+
133
+ ### How it Works
134
+
135
+ 1. **Device Discovery**: Fetches all devices from Tesy API v4 (`/rest/get-my-devices`) on startup
136
+ 2. **Status Updates**: Polls device status periodically (default: 60 seconds)
137
+ 3. **Device Control**: Single MQTT connection (`wss://mqtt.tesy.com:8083`) shared by all devices
138
+ 4. **MQTT Topics**: `v1/{MAC}/request/{MODEL}/{TOKEN}/{COMMAND}`
139
+ 5. **Commands**: `onOff` (power), `setTemp` (temperature), `setMode` (heating mode)
140
+ 6. **Caching**: Devices persist across Homebridge restarts
141
+
142
+ ### MQTT Protocol
143
+
144
+ The plugin automatically:
145
+ - Connects to Tesy MQTT broker using shared credentials
146
+ - Retrieves device-specific token and model from API
147
+ - Publishes commands to device-specific MQTT topics
148
+ - Subscribes to response topics for acknowledgments
149
+
150
+ ## Troubleshooting
151
+
152
+ ### Device not responding to commands
153
+ - Check that your device is online in Tesy Cloud app
154
+ - Verify your credentials are correct
155
+ - Check Homebridge logs for MQTT connection errors
156
+
157
+ ### Temperature slider not showing in Home app
158
+ - Make sure you're running the latest version of this plugin
159
+ - Restart Homebridge after updating configuration
160
+ - Remove and re-add the accessory in Home app if needed
161
+
162
+ ### HomeKit shows "No Response"
163
+ - Check your internet connection
164
+ - Verify Homebridge is running
165
+ - Check if device is online in Tesy Cloud
166
+
167
+ ## Development
168
+
169
+ ### Testing MQTT Commands
170
+
171
+ Test scripts are included in the repository:
172
+
173
+ ```bash
174
+ # Test connection and monitor messages
175
+ node test-mqtt.js
176
+
177
+ # Test on/off control
178
+ node test-mqtt-control.js
179
+
180
+ # Test temperature setting
181
+ node test-mqtt-temp.js
182
+ ```
183
+
184
+ ### Unit Tests
185
+
186
+ The plugin includes unit tests for core functionality:
187
+
188
+ ```bash
189
+ # Run tests
190
+ npm test
191
+
192
+ # Run tests with coverage report
193
+ npm run test:coverage
194
+ ```
195
+
196
+ Tests cover:
197
+ - MQTT connection and initialization
198
+ - Command formatting and sending
199
+ - Device control (on/off, temperature)
200
+ - Error handling
201
+ - Configuration validation
202
+
203
+ ## Support
204
+
205
+ If you find this plugin useful, consider supporting:
206
+
207
+ [![Revolut](https://img.shields.io/badge/Revolut-Support-blue)](https://revolut.me/molchaoxez)
208
+
209
+ ## License
210
+
211
+ MIT License
212
+
213
+ Original work Copyright (c) 2021 Dobriyan Benov
214
+ Modified work Copyright (c) 2025 Aleksey Molchanov
215
+
216
+ Permission is hereby granted, free of charge, to any person obtaining a copy
217
+ of this software and associated documentation files (the "Software"), to deal
218
+ in the Software without restriction, including without limitation the rights
219
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
220
+ copies of the Software, and to permit persons to whom the Software is
221
+ furnished to do so, subject to the following conditions:
222
+
223
+ The above copyright notice and this permission notice shall be included in all
224
+ copies or substantial portions of the Software.
225
+
226
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
227
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
228
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
229
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
230
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
231
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
232
+ SOFTWARE.
233
+
234
+ ## Contributing
235
+
236
+ Pull requests are welcome! For major changes, please open an issue first to discuss what you would like to change.
237
+
238
+ ## Links
239
+
240
+ - [GitHub Repository](https://github.com/svjakrm/homebridge-tesy-heater-mqtt)
241
+ - [Original Plugin](https://github.com/benov84/homebridge-tesy-heater)
242
+ - [Homebridge](https://github.com/homebridge/homebridge)
243
+ - [Tesy Cloud](https://v4.mytesy.com)
244
+ - [Tesy FinEco Cloud Convectors](https://tesy.bg/produkti/otoplenie-i-grija-za-vyzduha/elektricheski-konvektori/view_group/1)
@@ -0,0 +1,113 @@
1
+ {
2
+ "pluginAlias": "TesyHeater",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "schema": {
6
+ "type": "object",
7
+ "properties": {
8
+ "name": {
9
+ "title": "Platform Name",
10
+ "type": "string",
11
+ "default": "TesyHeater",
12
+ "required": true
13
+ },
14
+ "userid": {
15
+ "title": "Tesy Cloud User ID",
16
+ "type": "string",
17
+ "required": true,
18
+ "description": "Your Tesy Cloud account user ID (numeric)"
19
+ },
20
+ "username": {
21
+ "title": "Tesy Cloud Email",
22
+ "type": "string",
23
+ "required": true,
24
+ "format": "email",
25
+ "description": "Your Tesy Cloud account email address"
26
+ },
27
+ "password": {
28
+ "title": "Tesy Cloud Password",
29
+ "type": "string",
30
+ "required": true,
31
+ "description": "Your Tesy Cloud account password"
32
+ },
33
+ "maxTemp": {
34
+ "title": "Maximum Temperature (°C)",
35
+ "type": "integer",
36
+ "default": 30,
37
+ "minimum": 10,
38
+ "maximum": 40,
39
+ "description": "Maximum temperature limit for all heaters"
40
+ },
41
+ "minTemp": {
42
+ "title": "Minimum Temperature (°C)",
43
+ "type": "integer",
44
+ "default": 10,
45
+ "minimum": 5,
46
+ "maximum": 25,
47
+ "description": "Minimum temperature limit for all heaters"
48
+ },
49
+ "pullInterval": {
50
+ "title": "Status Update Interval (ms)",
51
+ "type": "integer",
52
+ "default": 60000,
53
+ "minimum": 30000,
54
+ "maximum": 300000,
55
+ "description": "How often to poll device status (in milliseconds). Default: 60000 (1 minute)"
56
+ }
57
+ }
58
+ },
59
+ "layout": [
60
+ {
61
+ "type": "help",
62
+ "helpvalue": "<h5>Tesy Heater Platform</h5><p>This plugin will <strong>automatically discover</strong> all Tesy heaters linked to your Tesy Cloud account. Simply enter your credentials below and restart Homebridge.</p>"
63
+ },
64
+ "name",
65
+ {
66
+ "type": "section",
67
+ "title": "Tesy Cloud Credentials",
68
+ "expandable": true,
69
+ "expanded": true,
70
+ "items": [
71
+ {
72
+ "key": "userid",
73
+ "type": "string",
74
+ "placeholder": "27356"
75
+ },
76
+ {
77
+ "key": "username",
78
+ "type": "string",
79
+ "placeholder": "your.email@example.com"
80
+ },
81
+ {
82
+ "key": "password",
83
+ "type": "string",
84
+ "placeholder": "Your password"
85
+ }
86
+ ]
87
+ },
88
+ {
89
+ "type": "section",
90
+ "title": "Advanced Settings",
91
+ "expandable": true,
92
+ "expanded": false,
93
+ "items": [
94
+ {
95
+ "key": "maxTemp",
96
+ "type": "number"
97
+ },
98
+ {
99
+ "key": "minTemp",
100
+ "type": "number"
101
+ },
102
+ {
103
+ "key": "pullInterval",
104
+ "type": "number"
105
+ }
106
+ ]
107
+ },
108
+ {
109
+ "type": "help",
110
+ "helpvalue": "<p><strong>How to find your User ID:</strong></p><ol><li>Log in to <a href='https://v4.mytesy.com' target='_blank'>Tesy Cloud</a></li><li>Check the browser URL or use the API to retrieve your user ID</li><li>Or run: <code>curl 'https://ad.mytesy.com/rest/get-my-devices?userEmail=YOUR_EMAIL&userPass=YOUR_PASSWORD&lang=en'</code></li></ol>"
111
+ }
112
+ ]
113
+ }
package/index.js ADDED
@@ -0,0 +1,537 @@
1
+ var Service, Characteristic, API;
2
+ const mqtt = require('mqtt');
3
+ const request = require('request');
4
+ const querystring = require('querystring');
5
+
6
+ module.exports = function(homebridge) {
7
+ Service = homebridge.hap.Service;
8
+ Characteristic = homebridge.hap.Characteristic;
9
+ API = homebridge;
10
+
11
+ homebridge.registerPlatform("homebridge-tesy-heater-mqtt", "TesyHeater", TesyHeaterPlatform, true);
12
+ };
13
+
14
+ class TesyHeaterPlatform {
15
+ constructor(log, config, api) {
16
+ this.log = log;
17
+ this.config = config || {};
18
+ this.api = api;
19
+
20
+ this.accessories = [];
21
+ this.devices = {};
22
+ this.mqttClient = null;
23
+
24
+ // Configuration
25
+ this.userid = this.config.userid;
26
+ this.username = this.config.username;
27
+ this.password = this.config.password;
28
+ this.pullInterval = this.config.pullInterval || 60000;
29
+ this.maxTemp = this.config.maxTemp || 30;
30
+ this.minTemp = this.config.minTemp || 10;
31
+
32
+ if (!this.userid || !this.username || !this.password) {
33
+ this.log.error("Missing required credentials (userid, username, password) in config!");
34
+ return;
35
+ }
36
+
37
+ this.log.info("TesyHeater Platform Plugin Loaded");
38
+
39
+ if (api) {
40
+ this.api.on('didFinishLaunching', () => {
41
+ this.log.info("Homebridge finished launching, discovering devices...");
42
+ this.discoverDevices();
43
+ });
44
+ }
45
+ }
46
+
47
+ configureAccessory(accessory) {
48
+ this.log.info("Configuring cached accessory:", accessory.displayName);
49
+
50
+ accessory.reachable = true;
51
+ this.accessories.push(accessory);
52
+ }
53
+
54
+ discoverDevices() {
55
+ this.log.info("Fetching devices from Tesy Cloud...");
56
+
57
+ const queryParams = querystring.stringify({
58
+ 'userID': parseInt(this.userid),
59
+ 'userEmail': this.username,
60
+ 'userPass': this.password,
61
+ 'lang': 'en'
62
+ });
63
+
64
+ const options = {
65
+ 'method': 'GET',
66
+ 'url': 'https://ad.mytesy.com/rest/get-my-devices?' + queryParams
67
+ };
68
+
69
+ request(options, (error, response) => {
70
+ if (error) {
71
+ this.log.error("API Error:", error);
72
+ return;
73
+ }
74
+
75
+ try {
76
+ const data = JSON.parse(response.body);
77
+
78
+ if (!data || Object.keys(data).length === 0) {
79
+ this.log.warn("No devices found in your Tesy Cloud account");
80
+ return;
81
+ }
82
+
83
+ this.log.info("Found %d device(s) in your account", Object.keys(data).length);
84
+
85
+ // Initialize MQTT connection once before adding devices
86
+ this.initMQTT();
87
+
88
+ // Add each device
89
+ for (const mac in data) {
90
+ const deviceData = data[mac];
91
+ const state = deviceData.state;
92
+
93
+ if (!state || !state.id) {
94
+ this.log.warn("Skipping device with MAC %s - missing state data", mac);
95
+ continue;
96
+ }
97
+
98
+ // Try to get device name from various possible fields
99
+ const deviceName = state.deviceName ||
100
+ state.name ||
101
+ deviceData.deviceName ||
102
+ deviceData.name ||
103
+ `Tesy Heater ${state.id}`;
104
+
105
+ this.log.debug("Device data for %s:", mac, JSON.stringify({
106
+ 'state.deviceName': state.deviceName,
107
+ 'state.name': state.name,
108
+ 'deviceData.deviceName': deviceData.deviceName,
109
+ 'deviceData.name': deviceData.name,
110
+ 'using': deviceName
111
+ }));
112
+
113
+ this.addDevice({
114
+ id: state.id.toString(),
115
+ mac: mac,
116
+ token: deviceData.token,
117
+ model: deviceData.model || 'cn05uv',
118
+ name: deviceName,
119
+ state: state
120
+ });
121
+ }
122
+
123
+ // Remove devices that are no longer in account
124
+ this.removeOldDevices(data);
125
+
126
+ // Start status polling
127
+ this.startPolling();
128
+
129
+ } catch(e) {
130
+ this.log.error("Error parsing device data:", e);
131
+ }
132
+ });
133
+ }
134
+
135
+ addDevice(deviceInfo) {
136
+ const uuid = this.api.hap.uuid.generate('tesy-' + deviceInfo.id);
137
+ let accessory = this.accessories.find(acc => acc.UUID === uuid);
138
+
139
+ if (accessory) {
140
+ this.log.info("Restoring existing accessory:", deviceInfo.name);
141
+
142
+ // Always update name to ensure HomeKit has the latest value
143
+ if (accessory.displayName !== deviceInfo.name) {
144
+ this.log.info("Updating accessory name from '%s' to '%s'", accessory.displayName, deviceInfo.name);
145
+ }
146
+
147
+ // Update displayName
148
+ accessory.displayName = deviceInfo.name;
149
+
150
+ // Update context
151
+ accessory.context.deviceInfo = deviceInfo;
152
+
153
+ // Update AccessoryInformation service name
154
+ const infoService = accessory.getService(Service.AccessoryInformation);
155
+ if (infoService) {
156
+ infoService.setCharacteristic(Characteristic.Name, deviceInfo.name);
157
+ }
158
+
159
+ // Update HeaterCooler service name
160
+ const service = accessory.getService(Service.HeaterCooler);
161
+ if (service) {
162
+ service.setCharacteristic(Characteristic.Name, deviceInfo.name);
163
+ service.displayName = deviceInfo.name;
164
+ }
165
+
166
+ // Also update the internal HAP accessory displayName
167
+ if (accessory._associatedHAPAccessory) {
168
+ accessory._associatedHAPAccessory.displayName = deviceInfo.name;
169
+ }
170
+
171
+ this.api.updatePlatformAccessories([accessory]);
172
+ } else {
173
+ this.log.info("Adding new accessory:", deviceInfo.name);
174
+ accessory = new this.api.platformAccessory(deviceInfo.name, uuid);
175
+ accessory.context.deviceInfo = deviceInfo;
176
+
177
+ this.accessories.push(accessory);
178
+ this.api.registerPlatformAccessories("homebridge-tesy-heater-mqtt", "TesyHeater", [accessory]);
179
+ }
180
+
181
+ // Setup accessory
182
+ this.setupAccessory(accessory);
183
+
184
+ // Store device reference
185
+ this.devices[deviceInfo.id] = {
186
+ accessory: accessory,
187
+ info: deviceInfo
188
+ };
189
+ }
190
+
191
+ removeOldDevices(currentData) {
192
+ const currentDeviceIds = Object.values(currentData)
193
+ .filter(d => d.state && d.state.id)
194
+ .map(d => d.state.id.toString());
195
+
196
+ const accessoriesToRemove = this.accessories.filter(acc => {
197
+ if (!acc.context.deviceInfo) return false;
198
+ return !currentDeviceIds.includes(acc.context.deviceInfo.id);
199
+ });
200
+
201
+ if (accessoriesToRemove.length > 0) {
202
+ this.log.info("Removing %d device(s) that are no longer in account", accessoriesToRemove.length);
203
+ this.api.unregisterPlatformAccessories("homebridge-tesy-heater-mqtt", "TesyHeater", accessoriesToRemove);
204
+
205
+ accessoriesToRemove.forEach(acc => {
206
+ const index = this.accessories.indexOf(acc);
207
+ if (index > -1) {
208
+ this.accessories.splice(index, 1);
209
+ }
210
+
211
+ if (acc.context.deviceInfo) {
212
+ delete this.devices[acc.context.deviceInfo.id];
213
+ }
214
+ });
215
+ }
216
+ }
217
+
218
+ setupAccessory(accessory) {
219
+ const deviceInfo = accessory.context.deviceInfo;
220
+
221
+ // Information Service
222
+ const informationService = accessory.getService(Service.AccessoryInformation) ||
223
+ accessory.addService(Service.AccessoryInformation);
224
+
225
+ informationService
226
+ .setCharacteristic(Characteristic.Manufacturer, 'Tesy')
227
+ .setCharacteristic(Characteristic.Model, deviceInfo.model || 'Convector')
228
+ .setCharacteristic(Characteristic.SerialNumber, deviceInfo.id);
229
+
230
+ // HeaterCooler Service
231
+ let service = accessory.getService(Service.HeaterCooler);
232
+ if (!service) {
233
+ service = accessory.addService(Service.HeaterCooler, deviceInfo.name);
234
+ }
235
+
236
+ // Configure characteristics
237
+ service.getCharacteristic(Characteristic.Active)
238
+ .on('get', this.getActive.bind(this, deviceInfo))
239
+ .on('set', this.setActive.bind(this, deviceInfo));
240
+
241
+ service.getCharacteristic(Characteristic.CurrentHeaterCoolerState)
242
+ .updateValue(Characteristic.CurrentHeaterCoolerState.INACTIVE);
243
+
244
+ service.getCharacteristic(Characteristic.TargetHeaterCoolerState)
245
+ .on('get', callback => callback(null, Characteristic.TargetHeaterCoolerState.HEAT))
246
+ .setProps({
247
+ validValues: [Characteristic.TargetHeaterCoolerState.HEAT]
248
+ });
249
+
250
+ service.getCharacteristic(Characteristic.CurrentTemperature)
251
+ .on('get', this.getCurrentTemperature.bind(this, deviceInfo))
252
+ .setProps({ minStep: 0.1 });
253
+
254
+ service.getCharacteristic(Characteristic.HeatingThresholdTemperature)
255
+ .on('get', this.getHeatingThresholdTemperature.bind(this, deviceInfo))
256
+ .on('set', this.setHeatingThresholdTemperature.bind(this, deviceInfo))
257
+ .setProps({
258
+ minValue: this.minTemp,
259
+ maxValue: this.maxTemp,
260
+ minStep: 0.5
261
+ });
262
+
263
+ // Cooling threshold (for HomeKit UI)
264
+ service.getCharacteristic(Characteristic.CoolingThresholdTemperature)
265
+ .setProps({
266
+ minValue: this.minTemp,
267
+ maxValue: this.maxTemp,
268
+ minStep: 0.5
269
+ })
270
+ .updateValue(this.minTemp);
271
+ }
272
+
273
+ initMQTT() {
274
+ if (this.mqttClient) {
275
+ this.log.debug("MQTT client already initialized");
276
+ return;
277
+ }
278
+
279
+ this.log.info("Initializing MQTT connection...");
280
+
281
+ this.mqttClient = mqtt.connect('wss://mqtt.tesy.com:8083/mqtt', {
282
+ username: 'client1',
283
+ password: '123',
284
+ clientId: 'mqttjs_' + Math.random().toString(16).substr(2, 8),
285
+ protocol: 'wss',
286
+ reconnectPeriod: 5000,
287
+ });
288
+
289
+ this.mqttClient.on('connect', () => {
290
+ this.log.info("✓ Connected to MQTT broker");
291
+
292
+ // Subscribe to all device response topics
293
+ Object.values(this.devices).forEach(device => {
294
+ const info = device.info;
295
+ const responseTopic = `v1/${info.mac}/response/${info.model}/${info.token}/#`;
296
+ this.mqttClient.subscribe(responseTopic, (err) => {
297
+ if (err) {
298
+ this.log.error("MQTT subscribe error for %s:", info.name, err);
299
+ } else {
300
+ this.log.debug("✓ Subscribed to MQTT topic for %s", info.name);
301
+ }
302
+ });
303
+ });
304
+ });
305
+
306
+ this.mqttClient.on('error', (error) => {
307
+ this.log.error("MQTT Error:", error);
308
+ });
309
+
310
+ this.mqttClient.on('close', () => {
311
+ this.log.warn("MQTT connection closed");
312
+ });
313
+
314
+ this.mqttClient.on('offline', () => {
315
+ this.log.warn("MQTT client offline");
316
+ });
317
+ }
318
+
319
+ sendMQTTCommand(deviceInfo, command, payload, callback) {
320
+ if (!this.mqttClient || !this.mqttClient.connected) {
321
+ this.log.error("MQTT not connected");
322
+ return callback(new Error("MQTT not connected"));
323
+ }
324
+
325
+ const topic = `v1/${deviceInfo.mac}/request/${deviceInfo.model}/${deviceInfo.token}/${command}`;
326
+ const message = {
327
+ app_id: 'hb' + Math.random().toString(16).substr(2, 7),
328
+ ...payload
329
+ };
330
+
331
+ this.log.debug("Sending MQTT command to %s: %s", deviceInfo.name, command);
332
+
333
+ this.mqttClient.publish(topic, JSON.stringify(message), (err) => {
334
+ if (err) {
335
+ this.log.error("MQTT publish error:", err);
336
+ callback(err);
337
+ } else {
338
+ callback(null);
339
+ }
340
+ });
341
+ }
342
+
343
+ fetchDeviceStatus(deviceInfo, callback) {
344
+ const queryParams = querystring.stringify({
345
+ 'userID': parseInt(this.userid),
346
+ 'userEmail': this.username,
347
+ 'userPass': this.password,
348
+ 'lang': 'en'
349
+ });
350
+
351
+ const options = {
352
+ 'method': 'GET',
353
+ 'url': 'https://ad.mytesy.com/rest/get-my-devices?' + queryParams
354
+ };
355
+
356
+ request(options, (error, response) => {
357
+ if (error) {
358
+ return callback(error, null);
359
+ }
360
+
361
+ try {
362
+ const data = JSON.parse(response.body);
363
+ const deviceData = data[deviceInfo.mac];
364
+
365
+ if (!deviceData || !deviceData.state) {
366
+ return callback(new Error("Device not found"), null);
367
+ }
368
+
369
+ callback(null, deviceData.state);
370
+ } catch(e) {
371
+ callback(e, null);
372
+ }
373
+ });
374
+ }
375
+
376
+ startPolling() {
377
+ if (this.pollingInterval) {
378
+ clearInterval(this.pollingInterval);
379
+ }
380
+
381
+ this.log.info("Starting status polling every %d ms", this.pullInterval);
382
+
383
+ this.pollingInterval = setInterval(() => {
384
+ this.updateAllDevices();
385
+ }, this.pullInterval);
386
+
387
+ // Initial update
388
+ this.updateAllDevices();
389
+ }
390
+
391
+ updateAllDevices() {
392
+ Object.values(this.devices).forEach(device => {
393
+ this.fetchDeviceStatus(device.info, (error, status) => {
394
+ if (error) {
395
+ this.log.error("Error fetching status for %s:", device.info.name, error);
396
+ return;
397
+ }
398
+
399
+ this.updateAccessoryStatus(device.accessory, status);
400
+ });
401
+ });
402
+ }
403
+
404
+ updateAccessoryStatus(accessory, status) {
405
+ const service = accessory.getService(Service.HeaterCooler);
406
+ if (!service) return;
407
+
408
+ try {
409
+ // Update current temperature
410
+ const currentTemp = parseFloat(status.current_temp);
411
+ if (!isNaN(currentTemp) && currentTemp >= this.minTemp && currentTemp <= this.maxTemp) {
412
+ const oldTemp = service.getCharacteristic(Characteristic.CurrentTemperature).value;
413
+ if (currentTemp !== oldTemp) {
414
+ service.getCharacteristic(Characteristic.CurrentTemperature).updateValue(currentTemp);
415
+ this.log.debug("%s: CurrentTemperature %s -> %s", accessory.displayName, oldTemp, currentTemp);
416
+ }
417
+ }
418
+
419
+ // Update target temperature
420
+ const targetTemp = parseFloat(status.temp);
421
+ if (!isNaN(targetTemp) && targetTemp >= this.minTemp && targetTemp <= this.maxTemp) {
422
+ const oldTarget = service.getCharacteristic(Characteristic.HeatingThresholdTemperature).value;
423
+ if (targetTemp !== oldTarget) {
424
+ service.getCharacteristic(Characteristic.HeatingThresholdTemperature).updateValue(targetTemp);
425
+ this.log.debug("%s: HeatingThresholdTemperature %s -> %s", accessory.displayName, oldTarget, targetTemp);
426
+ }
427
+ }
428
+
429
+ // Update active status
430
+ const isActive = status.status.toLowerCase() === 'on' ?
431
+ Characteristic.Active.ACTIVE :
432
+ Characteristic.Active.INACTIVE;
433
+ const oldActive = service.getCharacteristic(Characteristic.Active).value;
434
+ if (isActive !== oldActive) {
435
+ service.getCharacteristic(Characteristic.Active).updateValue(isActive);
436
+ this.log.info("%s: Active %s -> %s", accessory.displayName, oldActive ? 'ON' : 'OFF', isActive ? 'ON' : 'OFF');
437
+ }
438
+
439
+ // Update heating state
440
+ const isHeating = status.heating === 'on';
441
+ const heatingState = isHeating ?
442
+ Characteristic.CurrentHeaterCoolerState.HEATING :
443
+ Characteristic.CurrentHeaterCoolerState.IDLE;
444
+ const oldState = service.getCharacteristic(Characteristic.CurrentHeaterCoolerState).value;
445
+ if (heatingState !== oldState) {
446
+ service.getCharacteristic(Characteristic.CurrentHeaterCoolerState).updateValue(heatingState);
447
+ this.log.debug("%s: Heating state %s -> %s", accessory.displayName, oldState, heatingState);
448
+ }
449
+ } catch(e) {
450
+ this.log.error("Error updating %s status:", accessory.displayName, e);
451
+ }
452
+ }
453
+
454
+ // Characteristic Handlers
455
+
456
+ getActive(deviceInfo, callback) {
457
+ this.fetchDeviceStatus(deviceInfo, (error, status) => {
458
+ if (error) {
459
+ return callback(error);
460
+ }
461
+
462
+ const isActive = status.status.toLowerCase() === 'on' ?
463
+ Characteristic.Active.ACTIVE :
464
+ Characteristic.Active.INACTIVE;
465
+ callback(null, isActive);
466
+ });
467
+ }
468
+
469
+ setActive(deviceInfo, value, callback) {
470
+ const newValue = value === 0 ? 'off' : 'on';
471
+ this.log.info("%s: Setting active to %s", deviceInfo.name, newValue);
472
+
473
+ this.sendMQTTCommand(deviceInfo, 'onOff', { status: newValue }, (error) => {
474
+ if (error) {
475
+ this.log.error("%s: Error setting active status:", deviceInfo.name, error);
476
+ return callback(error);
477
+ }
478
+
479
+ this.log.info("%s: ✓ Active status changed to %s", deviceInfo.name, newValue);
480
+ callback(null);
481
+ });
482
+ }
483
+
484
+ getCurrentTemperature(deviceInfo, callback) {
485
+ this.fetchDeviceStatus(deviceInfo, (error, status) => {
486
+ if (error) {
487
+ return callback(error);
488
+ }
489
+
490
+ const currentTemp = parseFloat(status.current_temp);
491
+ callback(null, currentTemp);
492
+ });
493
+ }
494
+
495
+ getHeatingThresholdTemperature(deviceInfo, callback) {
496
+ this.fetchDeviceStatus(deviceInfo, (error, status) => {
497
+ if (error) {
498
+ return callback(error);
499
+ }
500
+
501
+ const targetTemp = parseFloat(status.temp);
502
+ callback(null, targetTemp);
503
+ });
504
+ }
505
+
506
+ setHeatingThresholdTemperature(deviceInfo, value, callback) {
507
+ // Clamp value
508
+ if (value < this.minTemp) value = this.minTemp;
509
+ if (value > this.maxTemp) value = this.maxTemp;
510
+
511
+ this.log.info("%s: Setting target temperature to %s°C", deviceInfo.name, value);
512
+
513
+ // First set mode to manual
514
+ this.sendMQTTCommand(deviceInfo, 'setMode', { mode: 'manual' }, (error) => {
515
+ if (error) {
516
+ this.log.error("%s: Error setting mode to manual:", deviceInfo.name, error);
517
+ return callback(error);
518
+ }
519
+
520
+ this.log.debug("%s: ✓ Mode set to manual", deviceInfo.name);
521
+
522
+ // Then set temperature
523
+ this.sendMQTTCommand(deviceInfo, 'setTemp', { temp: value }, (error) => {
524
+ if (error) {
525
+ this.log.error("%s: Error setting temperature:", deviceInfo.name, error);
526
+ return callback(error);
527
+ }
528
+
529
+ this.log.info("%s: ✓ Temperature set to %s°C", deviceInfo.name, value);
530
+ callback(null);
531
+ });
532
+ });
533
+ }
534
+ }
535
+
536
+ // Export for testing
537
+ module.exports.TesyHeaterPlatform = TesyHeaterPlatform;
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "homebridge-tesy-heater-mqtt",
3
+ "displayName": "Homebridge Tesy Heater MQTT",
4
+ "version": "1.0.0",
5
+ "description": "Tesy heater plugin for Homebridge using MQTT control (API v4). Supports on/off control and temperature adjustment.",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "homebridge-plugin",
9
+ "tesy",
10
+ "heater",
11
+ "convector",
12
+ "mqtt",
13
+ "smart-home",
14
+ "homekit"
15
+ ],
16
+ "engines": {
17
+ "node": "^18.20.4 || ^20.15.1 || ^22 || ^24",
18
+ "homebridge": "^1.6.0 || ^2.0.0"
19
+ },
20
+ "author": {
21
+ "name": "Aleksey Molchanov",
22
+ "email": "molchanov.alex@gmail.com"
23
+ },
24
+ "contributors": [
25
+ {
26
+ "name": "Dobriyan Benov",
27
+ "url": "https://github.com/benov84/homebridge-tesy-heater"
28
+ }
29
+ ],
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/svjakrm/homebridge-tesy-heater-mqtt.git"
33
+ },
34
+ "dependencies": {
35
+ "homebridge-http-base": "2.1.6",
36
+ "mqtt": "^5.14.1",
37
+ "request": "^2.65.0"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/svjakrm/homebridge-tesy-heater-mqtt/issues"
41
+ },
42
+ "homepage": "https://github.com/svjakrm/homebridge-tesy-heater-mqtt#readme",
43
+ "main": "index.js",
44
+ "scripts": {
45
+ "test": "jest",
46
+ "test:coverage": "jest --coverage"
47
+ },
48
+ "funding": [
49
+ {
50
+ "type": "revolut",
51
+ "url": "https://revolut.me/molchaoxez"
52
+ }
53
+ ],
54
+ "devDependencies": {
55
+ "@types/jest": "^30.0.0",
56
+ "jest": "^30.2.0"
57
+ }
58
+ }