homebridge-mitsubishi-comfort 1.0.1 → 1.3.1
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/README.md +110 -7
- package/config.schema.json +57 -5
- package/dist/accessory.d.ts +8 -4
- package/dist/accessory.js +100 -45
- package/dist/kumo-api.d.ts +26 -6
- package/dist/kumo-api.js +246 -37
- package/dist/platform.d.ts +12 -0
- package/dist/platform.js +172 -1
- package/dist/settings.d.ts +6 -1
- package/dist/settings.js +2 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
|
-
# Homebridge
|
|
1
|
+
# Homebridge Mitsubishi Comfort
|
|
2
2
|
|
|
3
3
|
A Homebridge plugin for Mitsubishi heat pumps using the Kumo Cloud v3 API.
|
|
4
4
|
|
|
5
|
+
## ⚠️ Disclaimer
|
|
6
|
+
|
|
7
|
+
This plugin is not affiliated with, endorsed by, or associated with Mitsubishi Electric in any way. It is an independent, unofficial plugin developed by the community for personal use.
|
|
8
|
+
|
|
9
|
+
**Use at your own risk.** The author assumes no liability for any damage, data loss, or issues that may arise from using this plugin. By using this plugin, you acknowledge that you do so entirely at your own discretion and risk.
|
|
10
|
+
|
|
5
11
|
## Features
|
|
6
12
|
|
|
13
|
+
- **Intelligent streaming-first architecture** with automatic fallback
|
|
14
|
+
- **95% reduction in API calls** when streaming is healthy (optimal mode)
|
|
15
|
+
- **Real-time streaming updates** via Socket.IO for instant status changes
|
|
16
|
+
- **Adaptive polling** that activates only when streaming fails
|
|
7
17
|
- Full HomeKit thermostat integration
|
|
8
18
|
- Support for Heat, Cool, Auto, and Off modes
|
|
9
19
|
- Temperature control
|
|
10
20
|
- Current temperature and humidity display
|
|
11
21
|
- Automatic token refresh
|
|
12
|
-
- Status polling every 30 seconds
|
|
13
22
|
- Multi-site and multi-zone support
|
|
23
|
+
- Device exclusion/hiding support
|
|
24
|
+
- Comprehensive logging for streaming/polling state transitions
|
|
14
25
|
|
|
15
26
|
## Installation
|
|
16
27
|
|
|
@@ -60,10 +71,68 @@ Add the following to your Homebridge `config.json`:
|
|
|
60
71
|
| `name` | string | No | Platform name (default: "Kumo") |
|
|
61
72
|
| `username` | string | Yes | Your Kumo Cloud email address |
|
|
62
73
|
| `password` | string | Yes | Your Kumo Cloud password |
|
|
63
|
-
| `pollInterval` | number | No |
|
|
64
|
-
| `
|
|
74
|
+
| `pollInterval` | number | No | Polling interval when streaming is healthy in seconds (default: 30, minimum: 5) |
|
|
75
|
+
| `disablePolling` | boolean | No | **Recommended:** Disable polling when streaming is healthy (auto-enables if streaming fails, default: false) |
|
|
76
|
+
| `degradedPollInterval` | number | No | Fast polling interval when streaming is unhealthy in seconds (default: 10, minimum: 5, maximum: 60) |
|
|
77
|
+
| `streamingHealthCheckInterval` | number | No | How often to check if streaming is healthy in seconds (default: 30, minimum: 10, maximum: 300) |
|
|
78
|
+
| `streamingStaleThreshold` | number | No | Consider streaming stale if no updates received for this long in seconds (default: 60, minimum: 30, maximum: 600) |
|
|
79
|
+
| `excludeDevices` | string[] | No | Array of device serial numbers to hide from HomeKit |
|
|
65
80
|
| `debug` | boolean | No | Enable debug logging (default: false) |
|
|
66
81
|
|
|
82
|
+
### Recommended Configuration for Optimal Efficiency
|
|
83
|
+
|
|
84
|
+
For best performance and minimal network traffic, enable streaming-only mode:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"platforms": [
|
|
89
|
+
{
|
|
90
|
+
"platform": "KumoV3",
|
|
91
|
+
"name": "Kumo",
|
|
92
|
+
"username": "your-email@example.com",
|
|
93
|
+
"password": "your-password",
|
|
94
|
+
"disablePolling": true
|
|
95
|
+
}
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This configuration:
|
|
101
|
+
- Uses streaming for all device updates when healthy (0 polling queries)
|
|
102
|
+
- Automatically activates 10-second polling if streaming disconnects
|
|
103
|
+
- Reduces API calls by ~95% (from ~257/hour to ~12/hour)
|
|
104
|
+
- Only makes token refresh queries every 15 minutes during normal operation
|
|
105
|
+
|
|
106
|
+
### Debug Mode
|
|
107
|
+
|
|
108
|
+
When `debug: true` is enabled, the plugin will log detailed information including:
|
|
109
|
+
|
|
110
|
+
- API requests and responses with timing information
|
|
111
|
+
- Raw JSON data from zone/device API responses showing all available fields
|
|
112
|
+
- Real-time streaming updates with complete device state
|
|
113
|
+
- Authentication and token refresh events
|
|
114
|
+
- WebSocket connection status
|
|
115
|
+
|
|
116
|
+
**Note:** Debug mode may log sensitive information and should only be enabled for troubleshooting. The plugin will display a warning when debug mode is active.
|
|
117
|
+
|
|
118
|
+
### Known Limitations
|
|
119
|
+
|
|
120
|
+
- **Outdoor Temperature**: The Kumo Cloud API does not expose outdoor temperature data from the outdoor units. While outdoor units have temperature sensors (used for defrost cycles), this data is only available through direct CN105 serial connections, not through the cloud API.
|
|
121
|
+
|
|
122
|
+
- **Temperature Display Differences**: Mitsubishi and Apple use different Fahrenheit-to-Celsius conversion tables, which can cause temperature setpoints to display differently in each app:
|
|
123
|
+
|
|
124
|
+
**When you set 70°F in Apple Home:**
|
|
125
|
+
- HomeKit converts using standard math: 70°F → 21.111°C
|
|
126
|
+
- Your unit is set to exactly 21.111°C (70.0°F)
|
|
127
|
+
- Mitsubishi Comfort app may display this as ~69°F due to their custom conversion table
|
|
128
|
+
|
|
129
|
+
**When you set 70°F in Mitsubishi Comfort app:**
|
|
130
|
+
- Mitsubishi converts using their custom table: 70°F → 21.5°C (0.5°C increments)
|
|
131
|
+
- Your unit is set to 21.5°C (which equals 70.7°F in standard conversion)
|
|
132
|
+
- Apple Home displays this as 71°F (because 21.5°C = 70.7°F)
|
|
133
|
+
|
|
134
|
+
**Both apps are technically correct** - they just use different conversion standards. The actual Celsius value sent to your unit is accurate in both cases. For consistency, pick one app for temperature control rather than mixing both.
|
|
135
|
+
|
|
67
136
|
## Development
|
|
68
137
|
|
|
69
138
|
### Build
|
|
@@ -85,8 +154,22 @@ This will compile TypeScript, link the plugin, and restart on changes.
|
|
|
85
154
|
1. **Authentication**: The plugin logs in to the Kumo Cloud v3 API using your credentials
|
|
86
155
|
2. **Token Management**: Access tokens are automatically refreshed every 15 minutes
|
|
87
156
|
3. **Discovery**: All sites and zones are discovered and registered as HomeKit thermostats
|
|
88
|
-
4. **
|
|
89
|
-
5. **
|
|
157
|
+
4. **Real-time Streaming**: Establishes Socket.IO connection for instant device updates
|
|
158
|
+
5. **Intelligent Fallback**:
|
|
159
|
+
- **Normal Mode** (streaming healthy): Updates via streaming only, minimal API calls
|
|
160
|
+
- **Degraded Mode** (streaming failed): Automatic fallback to fast polling (10s intervals)
|
|
161
|
+
- **Health Monitoring**: Continuous checking of streaming connection status
|
|
162
|
+
- **Automatic Recovery**: Returns to streaming-only mode when connection restored
|
|
163
|
+
6. **Control**: Changes made in HomeKit are sent to the Kumo Cloud API
|
|
164
|
+
|
|
165
|
+
### Update Strategy
|
|
166
|
+
|
|
167
|
+
The plugin uses a smart streaming-first approach with automatic fallback:
|
|
168
|
+
|
|
169
|
+
- **When streaming is healthy**: All device updates arrive via Socket.IO in real-time. If `disablePolling: true` is set, no polling occurs (optimal mode).
|
|
170
|
+
- **When streaming disconnects**: Plugin automatically switches to degraded mode with fast polling (default: 10s intervals) to ensure devices remain responsive.
|
|
171
|
+
- **When streaming reconnects**: Plugin automatically returns to normal mode, halting polling if `disablePolling: true`.
|
|
172
|
+
- **Race condition prevention**: Timestamp-based filtering ensures newer updates always take precedence, regardless of source.
|
|
90
173
|
|
|
91
174
|
## Supported Characteristics
|
|
92
175
|
|
|
@@ -99,12 +182,18 @@ This will compile TypeScript, link the plugin, and restart on changes.
|
|
|
99
182
|
|
|
100
183
|
## API Endpoints Used
|
|
101
184
|
|
|
185
|
+
### REST API
|
|
102
186
|
- `POST /v3/login` - Authentication
|
|
103
187
|
- `GET /v3/sites` - Get all sites
|
|
104
188
|
- `GET /v3/sites/{siteId}/zones` - Get zones for a site
|
|
105
189
|
- `GET /v3/devices/{deviceSerial}/status` - Get device status
|
|
106
190
|
- `POST /v3/devices/send-command` - Send commands to device
|
|
107
191
|
|
|
192
|
+
### Socket.IO Streaming
|
|
193
|
+
- `wss://socket-prod.kumocloud.com` - Real-time device updates via Socket.IO
|
|
194
|
+
- Emits `subscribe` event with device serial to receive updates
|
|
195
|
+
- Receives `device_update` events with full device state
|
|
196
|
+
|
|
108
197
|
## Security
|
|
109
198
|
|
|
110
199
|
### Best Practices
|
|
@@ -143,7 +232,21 @@ This will compile TypeScript, link the plugin, and restart on changes.
|
|
|
143
232
|
|
|
144
233
|
## License
|
|
145
234
|
|
|
146
|
-
|
|
235
|
+
Apache License 2.0
|
|
236
|
+
|
|
237
|
+
Copyright 2024
|
|
238
|
+
|
|
239
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
240
|
+
you may not use this file except in compliance with the License.
|
|
241
|
+
You may obtain a copy of the License at
|
|
242
|
+
|
|
243
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
244
|
+
|
|
245
|
+
Unless required by applicable law or agreed to in writing, software
|
|
246
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
247
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
248
|
+
See the License for the specific language governing permissions and
|
|
249
|
+
limitations under the License.
|
|
147
250
|
|
|
148
251
|
## Credits
|
|
149
252
|
|
package/config.schema.json
CHANGED
|
@@ -31,7 +31,13 @@
|
|
|
31
31
|
"type": "integer",
|
|
32
32
|
"default": 30,
|
|
33
33
|
"minimum": 5,
|
|
34
|
-
"description": "
|
|
34
|
+
"description": "Polling interval when streaming is healthy (minimum 5 seconds, default 30)"
|
|
35
|
+
},
|
|
36
|
+
"disablePolling": {
|
|
37
|
+
"title": "Disable Polling",
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"default": false,
|
|
40
|
+
"description": "Disable polling when streaming is healthy (will auto-enable if streaming fails)"
|
|
35
41
|
},
|
|
36
42
|
"debug": {
|
|
37
43
|
"title": "Debug Mode",
|
|
@@ -40,13 +46,40 @@
|
|
|
40
46
|
"description": "Enable verbose debug logging (requires running Homebridge with -D flag)"
|
|
41
47
|
},
|
|
42
48
|
"excludeDevices": {
|
|
43
|
-
"title": "
|
|
49
|
+
"title": "Hide Device IDs",
|
|
44
50
|
"type": "array",
|
|
51
|
+
"uniqueItems": true,
|
|
45
52
|
"items": {
|
|
46
53
|
"type": "string",
|
|
47
|
-
"title": "Device Serial Number"
|
|
54
|
+
"title": "Device Serial Number",
|
|
55
|
+
"placeholder": "e.g., 9X34P008S100095F",
|
|
56
|
+
"description": "Enter device serial number to hide"
|
|
48
57
|
},
|
|
49
|
-
"description": "
|
|
58
|
+
"description": "Device serial numbers to hide from HomeKit (found in logs during device discovery)"
|
|
59
|
+
},
|
|
60
|
+
"streamingHealthCheckInterval": {
|
|
61
|
+
"title": "Streaming Health Check Interval (seconds)",
|
|
62
|
+
"type": "integer",
|
|
63
|
+
"default": 30,
|
|
64
|
+
"minimum": 10,
|
|
65
|
+
"maximum": 300,
|
|
66
|
+
"description": "How often to check if streaming is healthy (default: 30 seconds)"
|
|
67
|
+
},
|
|
68
|
+
"streamingStaleThreshold": {
|
|
69
|
+
"title": "Streaming Stale Threshold (seconds)",
|
|
70
|
+
"type": "integer",
|
|
71
|
+
"default": 60,
|
|
72
|
+
"minimum": 30,
|
|
73
|
+
"maximum": 600,
|
|
74
|
+
"description": "Consider streaming stale if no updates received for this long (default: 60 seconds)"
|
|
75
|
+
},
|
|
76
|
+
"degradedPollInterval": {
|
|
77
|
+
"title": "Degraded Mode Poll Interval (seconds)",
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"default": 10,
|
|
80
|
+
"minimum": 5,
|
|
81
|
+
"maximum": 60,
|
|
82
|
+
"description": "Fast polling interval when streaming is unhealthy (default: 10 seconds)"
|
|
50
83
|
}
|
|
51
84
|
}
|
|
52
85
|
},
|
|
@@ -62,7 +95,26 @@
|
|
|
62
95
|
"title": "Advanced Settings",
|
|
63
96
|
"expandable": true,
|
|
64
97
|
"expanded": false,
|
|
65
|
-
"items": [
|
|
98
|
+
"items": [
|
|
99
|
+
"pollInterval",
|
|
100
|
+
"degradedPollInterval",
|
|
101
|
+
"disablePolling",
|
|
102
|
+
"streamingHealthCheckInterval",
|
|
103
|
+
"streamingStaleThreshold",
|
|
104
|
+
{
|
|
105
|
+
"key": "excludeDevices",
|
|
106
|
+
"type": "array",
|
|
107
|
+
"title": "Hide Device IDs",
|
|
108
|
+
"buttonText": "Add Device Serial",
|
|
109
|
+
"items": [
|
|
110
|
+
{
|
|
111
|
+
"type": "string",
|
|
112
|
+
"placeholder": "e.g., 9X34P008S100095F"
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"debug"
|
|
117
|
+
]
|
|
66
118
|
}
|
|
67
119
|
]
|
|
68
120
|
}
|
package/dist/accessory.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PlatformAccessory, CharacteristicValue } from 'homebridge';
|
|
2
2
|
import { KumoV3Platform } from './platform';
|
|
3
3
|
import { KumoAPI } from './kumo-api';
|
|
4
|
+
import { Zone } from './settings';
|
|
4
5
|
export declare class KumoThermostatAccessory {
|
|
5
6
|
private readonly platform;
|
|
6
7
|
private readonly accessory;
|
|
@@ -12,9 +13,14 @@ export declare class KumoThermostatAccessory {
|
|
|
12
13
|
private currentStatus;
|
|
13
14
|
private pollIntervalMs;
|
|
14
15
|
private hasHumiditySensor;
|
|
16
|
+
private lastUpdateTimestamp;
|
|
17
|
+
private lastUpdateSource;
|
|
15
18
|
constructor(platform: KumoV3Platform, accessory: PlatformAccessory, kumoAPI: KumoAPI, pollIntervalSeconds?: number);
|
|
16
|
-
private
|
|
17
|
-
|
|
19
|
+
private handleStreamingUpdate;
|
|
20
|
+
getSiteId(): string;
|
|
21
|
+
getDeviceSerial(): string;
|
|
22
|
+
updateFromZone(zone: Zone): void;
|
|
23
|
+
private processZoneUpdate;
|
|
18
24
|
private mapToCurrentHeatingCoolingState;
|
|
19
25
|
private mapToTargetHeatingCoolingState;
|
|
20
26
|
private getTargetTempFromStatus;
|
|
@@ -24,8 +30,6 @@ export declare class KumoThermostatAccessory {
|
|
|
24
30
|
getCurrentTemperature(): Promise<CharacteristicValue>;
|
|
25
31
|
getTargetTemperature(): Promise<CharacteristicValue>;
|
|
26
32
|
setTargetTemperature(value: CharacteristicValue): Promise<void>;
|
|
27
|
-
getTemperatureDisplayUnits(): Promise<CharacteristicValue>;
|
|
28
|
-
setTemperatureDisplayUnits(value: CharacteristicValue): Promise<void>;
|
|
29
33
|
getCurrentRelativeHumidity(): Promise<CharacteristicValue>;
|
|
30
34
|
destroy(): void;
|
|
31
35
|
}
|
package/dist/accessory.js
CHANGED
|
@@ -10,6 +10,8 @@ class KumoThermostatAccessory {
|
|
|
10
10
|
this.pollTimer = null;
|
|
11
11
|
this.currentStatus = null;
|
|
12
12
|
this.hasHumiditySensor = false;
|
|
13
|
+
this.lastUpdateTimestamp = 0;
|
|
14
|
+
this.lastUpdateSource = 'none';
|
|
13
15
|
this.deviceSerial = this.accessory.context.device.deviceSerial;
|
|
14
16
|
this.siteId = this.accessory.context.device.siteId;
|
|
15
17
|
this.pollIntervalMs = (pollIntervalSeconds || settings_1.POLL_INTERVAL / 1000) * 1000;
|
|
@@ -30,29 +32,65 @@ class KumoThermostatAccessory {
|
|
|
30
32
|
this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
|
|
31
33
|
.onGet(this.getTargetTemperature.bind(this))
|
|
32
34
|
.onSet(this.setTargetTemperature.bind(this));
|
|
33
|
-
this.
|
|
34
|
-
|
|
35
|
-
.onSet(this.setTemperatureDisplayUnits.bind(this));
|
|
36
|
-
this.startPolling();
|
|
35
|
+
this.kumoAPI.subscribeToDevice(this.deviceSerial, this.handleStreamingUpdate.bind(this));
|
|
36
|
+
this.platform.log.debug(`Registered streaming callback for ${this.deviceSerial}`);
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
38
|
+
handleStreamingUpdate(deviceSerial, data) {
|
|
39
|
+
if (data.roomTemp === undefined || data.roomTemp === null) {
|
|
40
|
+
this.platform.log.debug(`Streaming update for ${deviceSerial} missing essential data, skipping`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const updateTimestamp = Date.now();
|
|
44
|
+
this.platform.log.debug(`Streaming update received for ${deviceSerial}: temp=${data.roomTemp}, mode=${data.operationMode}, power=${data.power}`);
|
|
45
|
+
const zoneUpdate = {
|
|
46
|
+
adapter: {
|
|
47
|
+
id: data.id || '',
|
|
48
|
+
deviceSerial: deviceSerial,
|
|
49
|
+
roomTemp: data.roomTemp,
|
|
50
|
+
spHeat: data.spHeat,
|
|
51
|
+
spCool: data.spCool,
|
|
52
|
+
spAuto: data.spAuto || null,
|
|
53
|
+
humidity: data.humidity || null,
|
|
54
|
+
power: data.power,
|
|
55
|
+
operationMode: data.operationMode,
|
|
56
|
+
previousOperationMode: data.operationMode,
|
|
57
|
+
fanSpeed: data.fanSpeed || 'auto',
|
|
58
|
+
airDirection: data.airDirection || 'auto',
|
|
59
|
+
connected: true,
|
|
60
|
+
isSimulator: false,
|
|
61
|
+
hasSensor: data.humidity !== null && data.humidity !== undefined,
|
|
62
|
+
hasMhk2: false,
|
|
63
|
+
scheduleOwner: 'adapter',
|
|
64
|
+
scheduleHoldEndTime: 0,
|
|
65
|
+
rssi: data.rssi,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
this.processZoneUpdate(zoneUpdate, 'streaming', updateTimestamp);
|
|
69
|
+
}
|
|
70
|
+
getSiteId() {
|
|
71
|
+
return this.siteId;
|
|
72
|
+
}
|
|
73
|
+
getDeviceSerial() {
|
|
74
|
+
return this.deviceSerial;
|
|
43
75
|
}
|
|
44
|
-
|
|
76
|
+
updateFromZone(zone) {
|
|
77
|
+
const updateTimestamp = Date.now();
|
|
78
|
+
this.processZoneUpdate(zone, 'polling', updateTimestamp);
|
|
79
|
+
}
|
|
80
|
+
processZoneUpdate(zone, source, timestamp) {
|
|
45
81
|
try {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
82
|
+
if (timestamp < this.lastUpdateTimestamp) {
|
|
83
|
+
this.platform.log.debug(`[${this.deviceSerial}] Ignoring ${source} update: ` +
|
|
84
|
+
`${this.lastUpdateTimestamp - timestamp}ms older than last ${this.lastUpdateSource} update`);
|
|
49
85
|
return;
|
|
50
86
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
87
|
+
this.lastUpdateTimestamp = timestamp;
|
|
88
|
+
const previousSource = this.lastUpdateSource;
|
|
89
|
+
this.lastUpdateSource = source;
|
|
90
|
+
if (previousSource !== source && previousSource !== 'none') {
|
|
91
|
+
this.platform.log.debug(`[${this.deviceSerial}] Update source changed: ${previousSource} → ${source}`);
|
|
55
92
|
}
|
|
93
|
+
this.platform.log.debug(`Processing ${source} update for ${this.deviceSerial}`);
|
|
56
94
|
if (zone.adapter.roomTemp === undefined || zone.adapter.roomTemp === null) {
|
|
57
95
|
this.platform.log.error(`Device ${this.deviceSerial} has invalid roomTemp: ${zone.adapter.roomTemp}`);
|
|
58
96
|
this.platform.log.debug('Zone adapter data:', JSON.stringify(zone.adapter));
|
|
@@ -87,7 +125,7 @@ class KumoThermostatAccessory {
|
|
|
87
125
|
spAuto: zone.adapter.spAuto,
|
|
88
126
|
};
|
|
89
127
|
this.currentStatus = status;
|
|
90
|
-
this.platform.log.debug(
|
|
128
|
+
this.platform.log.debug(`${this.accessory.displayName}: ${status.roomTemp}°C (target: ${this.getTargetTempFromStatus(status)}°C, mode: ${status.operationMode})`);
|
|
91
129
|
this.service.updateCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState, this.mapToCurrentHeatingCoolingState(status));
|
|
92
130
|
this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, this.mapToTargetHeatingCoolingState(status));
|
|
93
131
|
if (status.roomTemp !== undefined && status.roomTemp !== null && !isNaN(status.roomTemp)) {
|
|
@@ -95,6 +133,8 @@ class KumoThermostatAccessory {
|
|
|
95
133
|
}
|
|
96
134
|
const targetTemp = this.getTargetTempFromStatus(status);
|
|
97
135
|
if (targetTemp !== undefined && targetTemp !== null && !isNaN(targetTemp)) {
|
|
136
|
+
const targetTempF = (targetTemp * 9 / 5) + 32;
|
|
137
|
+
this.platform.log.debug(`[TEMP UPDATE] ${this.accessory.displayName}: API returned target ${targetTemp.toFixed(3)}°C (${targetTempF.toFixed(1)}°F) [mode: ${status.operationMode}]`);
|
|
98
138
|
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, targetTemp);
|
|
99
139
|
}
|
|
100
140
|
if (this.hasHumiditySensor && status.humidity !== null) {
|
|
@@ -189,31 +229,41 @@ class KumoThermostatAccessory {
|
|
|
189
229
|
async setTargetHeatingCoolingState(value) {
|
|
190
230
|
this.platform.log.debug('Set TargetHeatingCoolingState:', value);
|
|
191
231
|
let operationMode;
|
|
232
|
+
let modeName;
|
|
192
233
|
switch (value) {
|
|
193
234
|
case this.platform.Characteristic.TargetHeatingCoolingState.OFF:
|
|
194
235
|
operationMode = 'off';
|
|
236
|
+
modeName = 'OFF';
|
|
195
237
|
break;
|
|
196
238
|
case this.platform.Characteristic.TargetHeatingCoolingState.HEAT:
|
|
197
239
|
operationMode = 'heat';
|
|
240
|
+
modeName = 'HEAT';
|
|
198
241
|
break;
|
|
199
242
|
case this.platform.Characteristic.TargetHeatingCoolingState.COOL:
|
|
200
243
|
operationMode = 'cool';
|
|
244
|
+
modeName = 'COOL';
|
|
201
245
|
break;
|
|
202
246
|
case this.platform.Characteristic.TargetHeatingCoolingState.AUTO:
|
|
203
247
|
operationMode = 'auto';
|
|
248
|
+
modeName = 'AUTO';
|
|
204
249
|
break;
|
|
205
250
|
default:
|
|
206
251
|
this.platform.log.error('Unknown target heating cooling state:', value);
|
|
207
252
|
return;
|
|
208
253
|
}
|
|
254
|
+
this.platform.log.info(`[MODE CHANGE] ${this.accessory.displayName}: HomeKit sent ${modeName} mode`);
|
|
209
255
|
const success = await this.kumoAPI.sendCommand(this.deviceSerial, {
|
|
210
256
|
operationMode,
|
|
211
257
|
});
|
|
212
258
|
if (success) {
|
|
213
|
-
|
|
259
|
+
this.platform.log.info(`[MODE CHANGE] ${this.accessory.displayName}: Command accepted by API`);
|
|
260
|
+
if (this.currentStatus) {
|
|
261
|
+
this.currentStatus.operationMode = operationMode;
|
|
262
|
+
this.currentStatus.power = operationMode === 'off' ? 0 : 1;
|
|
263
|
+
}
|
|
214
264
|
}
|
|
215
265
|
else {
|
|
216
|
-
this.platform.log.error(`Failed to set
|
|
266
|
+
this.platform.log.error(`[MODE CHANGE] ${this.accessory.displayName}: Failed to set mode to ${modeName}`);
|
|
217
267
|
}
|
|
218
268
|
}
|
|
219
269
|
async getCurrentTemperature() {
|
|
@@ -223,16 +273,18 @@ class KumoThermostatAccessory {
|
|
|
223
273
|
this.currentStatus = status;
|
|
224
274
|
}
|
|
225
275
|
else {
|
|
226
|
-
this.platform.log.
|
|
276
|
+
this.platform.log.debug('No status available yet for getCurrentTemperature, returning default');
|
|
227
277
|
return 20;
|
|
228
278
|
}
|
|
229
279
|
}
|
|
230
280
|
const temp = this.currentStatus.roomTemp;
|
|
231
281
|
if (temp === undefined || temp === null || isNaN(temp)) {
|
|
232
|
-
this.
|
|
282
|
+
if (this.lastUpdateSource !== 'none') {
|
|
283
|
+
this.platform.log.warn(`Invalid roomTemp value for ${this.accessory.displayName}:`, temp);
|
|
284
|
+
}
|
|
233
285
|
return 20;
|
|
234
286
|
}
|
|
235
|
-
this.platform.log.debug(
|
|
287
|
+
this.platform.log.debug(`HomeKit get current temp for ${this.accessory.displayName}: ${temp}°C`);
|
|
236
288
|
return temp;
|
|
237
289
|
}
|
|
238
290
|
async getTargetTemperature() {
|
|
@@ -242,55 +294,60 @@ class KumoThermostatAccessory {
|
|
|
242
294
|
this.currentStatus = status;
|
|
243
295
|
}
|
|
244
296
|
else {
|
|
245
|
-
this.platform.log.
|
|
297
|
+
this.platform.log.debug('No status available yet for getTargetTemperature, returning default');
|
|
246
298
|
return 20;
|
|
247
299
|
}
|
|
248
300
|
}
|
|
249
301
|
const temp = this.getTargetTempFromStatus(this.currentStatus);
|
|
250
302
|
if (temp === undefined || temp === null || isNaN(temp)) {
|
|
251
|
-
this.
|
|
303
|
+
if (this.lastUpdateSource !== 'none') {
|
|
304
|
+
this.platform.log.warn(`Invalid target temperature value for ${this.accessory.displayName}:`, temp);
|
|
305
|
+
}
|
|
252
306
|
return 20;
|
|
253
307
|
}
|
|
254
|
-
this.platform.log.debug(
|
|
308
|
+
this.platform.log.debug(`HomeKit get target temp for ${this.accessory.displayName}: ${temp}°C`);
|
|
255
309
|
return temp;
|
|
256
310
|
}
|
|
257
311
|
async setTargetTemperature(value) {
|
|
258
312
|
const temp = value;
|
|
259
|
-
|
|
313
|
+
const tempF = (temp * 9 / 5) + 32;
|
|
314
|
+
this.platform.log.info(`[TEMP CHANGE] ${this.accessory.displayName}: HomeKit sent ${temp.toFixed(3)}°C (${tempF.toFixed(1)}°F)`);
|
|
260
315
|
if (!this.currentStatus) {
|
|
261
316
|
this.platform.log.error('Cannot set temperature - no current status');
|
|
262
317
|
return;
|
|
263
318
|
}
|
|
264
|
-
const roundedTemp = Math.round(temp * 2) / 2;
|
|
265
|
-
this.platform.log.debug(`Rounded temperature from ${temp} to ${roundedTemp}`);
|
|
266
319
|
const commands = {};
|
|
267
320
|
if (this.currentStatus.operationMode === 'heat') {
|
|
268
|
-
commands.spHeat =
|
|
321
|
+
commands.spHeat = temp;
|
|
269
322
|
}
|
|
270
323
|
else if (this.currentStatus.operationMode === 'cool') {
|
|
271
|
-
commands.spCool =
|
|
324
|
+
commands.spCool = temp;
|
|
272
325
|
}
|
|
273
326
|
else if (this.currentStatus.operationMode === 'auto') {
|
|
274
|
-
commands.spHeat =
|
|
275
|
-
commands.spCool =
|
|
327
|
+
commands.spHeat = temp;
|
|
328
|
+
commands.spCool = temp;
|
|
276
329
|
}
|
|
277
330
|
else {
|
|
278
|
-
commands.spHeat =
|
|
331
|
+
commands.spHeat = temp;
|
|
279
332
|
}
|
|
333
|
+
this.platform.log.info(`[TEMP CHANGE] ${this.accessory.displayName}: Sending to API: ${JSON.stringify(commands)}°C`);
|
|
280
334
|
const success = await this.kumoAPI.sendCommand(this.deviceSerial, commands);
|
|
281
335
|
if (success) {
|
|
282
|
-
|
|
336
|
+
this.platform.log.info(`[TEMP CHANGE] ${this.accessory.displayName}: Command accepted by API`);
|
|
337
|
+
if (this.currentStatus) {
|
|
338
|
+
if (commands.spHeat !== undefined) {
|
|
339
|
+
this.currentStatus.spHeat = commands.spHeat;
|
|
340
|
+
}
|
|
341
|
+
if (commands.spCool !== undefined) {
|
|
342
|
+
this.currentStatus.spCool = commands.spCool;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, temp);
|
|
283
346
|
}
|
|
284
347
|
else {
|
|
285
348
|
this.platform.log.error(`Failed to set target temperature for ${this.accessory.displayName}: ${JSON.stringify(commands)}`);
|
|
286
349
|
}
|
|
287
350
|
}
|
|
288
|
-
async getTemperatureDisplayUnits() {
|
|
289
|
-
return this.platform.Characteristic.TemperatureDisplayUnits.CELSIUS;
|
|
290
|
-
}
|
|
291
|
-
async setTemperatureDisplayUnits(value) {
|
|
292
|
-
this.platform.log.debug('Set TemperatureDisplayUnits:', value);
|
|
293
|
-
}
|
|
294
351
|
async getCurrentRelativeHumidity() {
|
|
295
352
|
var _a;
|
|
296
353
|
if (!this.currentStatus) {
|
|
@@ -304,10 +361,8 @@ class KumoThermostatAccessory {
|
|
|
304
361
|
return humidity;
|
|
305
362
|
}
|
|
306
363
|
destroy() {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
this.pollTimer = null;
|
|
310
|
-
}
|
|
364
|
+
this.kumoAPI.unsubscribeFromDevice(this.deviceSerial);
|
|
365
|
+
this.platform.log.debug(`Unsubscribed from streaming updates for ${this.deviceSerial}`);
|
|
311
366
|
}
|
|
312
367
|
}
|
|
313
368
|
exports.KumoThermostatAccessory = KumoThermostatAccessory;
|
package/dist/kumo-api.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Logger } from 'homebridge';
|
|
2
2
|
import { Site, Zone, DeviceStatus, Commands } from './settings';
|
|
3
|
+
export type DeviceUpdateCallback = (deviceSerial: string, status: Partial<DeviceStatus>) => void;
|
|
3
4
|
export declare class KumoAPI {
|
|
4
5
|
private readonly username;
|
|
5
6
|
private readonly password;
|
|
@@ -8,9 +9,20 @@ export declare class KumoAPI {
|
|
|
8
9
|
private refreshToken;
|
|
9
10
|
private tokenExpiresAt;
|
|
10
11
|
private refreshTimer;
|
|
11
|
-
private siteEtags;
|
|
12
12
|
private debugMode;
|
|
13
|
-
|
|
13
|
+
private refreshInProgress;
|
|
14
|
+
private socket;
|
|
15
|
+
private streamingEnabled;
|
|
16
|
+
private deviceUpdateCallbacks;
|
|
17
|
+
private reconnectAttempts;
|
|
18
|
+
private maxReconnectAttempts;
|
|
19
|
+
private lastStreamingUpdate;
|
|
20
|
+
private streamingHealthCallbacks;
|
|
21
|
+
private healthCheckTimer;
|
|
22
|
+
private streamingHealthCheckInterval;
|
|
23
|
+
private streamingStaleThreshold;
|
|
24
|
+
private isStreamingHealthy;
|
|
25
|
+
constructor(username: string, password: string, log: Logger, debug?: boolean, enableStreaming?: boolean);
|
|
14
26
|
private maskToken;
|
|
15
27
|
login(): Promise<boolean>;
|
|
16
28
|
private scheduleTokenRefresh;
|
|
@@ -20,11 +32,19 @@ export declare class KumoAPI {
|
|
|
20
32
|
private makeAuthenticatedRequest;
|
|
21
33
|
getSites(): Promise<Site[]>;
|
|
22
34
|
getZones(siteId: string): Promise<Zone[]>;
|
|
23
|
-
getZonesWithETag(siteId: string, forceRefresh?: boolean): Promise<{
|
|
24
|
-
zones: Zone[];
|
|
25
|
-
notModified: boolean;
|
|
26
|
-
}>;
|
|
27
35
|
getDeviceStatus(deviceSerial: string): Promise<DeviceStatus | null>;
|
|
28
36
|
sendCommand(deviceSerial: string, commands: Commands): Promise<boolean>;
|
|
37
|
+
startStreaming(deviceSerials: string[]): Promise<boolean>;
|
|
38
|
+
subscribeToDevice(deviceSerial: string, callback: DeviceUpdateCallback): void;
|
|
39
|
+
unsubscribeFromDevice(deviceSerial: string): void;
|
|
40
|
+
isStreamingConnected(): boolean;
|
|
41
|
+
setStreamingHealthConfig(checkInterval: number, staleThreshold: number): void;
|
|
42
|
+
onStreamingHealthChange(callback: (isHealthy: boolean) => void): void;
|
|
43
|
+
getStreamingHealth(): boolean;
|
|
44
|
+
private updateStreamingTimestamp;
|
|
45
|
+
private checkStreamingHealth;
|
|
46
|
+
private notifyHealthChange;
|
|
47
|
+
private startHealthChecks;
|
|
48
|
+
private stopHealthChecks;
|
|
29
49
|
destroy(): void;
|
|
30
50
|
}
|
package/dist/kumo-api.js
CHANGED
|
@@ -5,9 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.KumoAPI = void 0;
|
|
7
7
|
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
8
|
+
const socket_io_client_1 = require("socket.io-client");
|
|
8
9
|
const settings_1 = require("./settings");
|
|
9
10
|
class KumoAPI {
|
|
10
|
-
constructor(username, password, log, debug = false) {
|
|
11
|
+
constructor(username, password, log, debug = false, enableStreaming = true) {
|
|
11
12
|
this.username = username;
|
|
12
13
|
this.password = password;
|
|
13
14
|
this.log = log;
|
|
@@ -15,13 +16,28 @@ class KumoAPI {
|
|
|
15
16
|
this.refreshToken = null;
|
|
16
17
|
this.tokenExpiresAt = 0;
|
|
17
18
|
this.refreshTimer = null;
|
|
18
|
-
this.siteEtags = new Map();
|
|
19
19
|
this.debugMode = false;
|
|
20
|
+
this.refreshInProgress = null;
|
|
21
|
+
this.socket = null;
|
|
22
|
+
this.streamingEnabled = true;
|
|
23
|
+
this.deviceUpdateCallbacks = new Map();
|
|
24
|
+
this.reconnectAttempts = 0;
|
|
25
|
+
this.maxReconnectAttempts = 5;
|
|
26
|
+
this.lastStreamingUpdate = new Map();
|
|
27
|
+
this.streamingHealthCallbacks = new Set();
|
|
28
|
+
this.healthCheckTimer = null;
|
|
29
|
+
this.streamingHealthCheckInterval = 30000;
|
|
30
|
+
this.streamingStaleThreshold = 60000;
|
|
31
|
+
this.isStreamingHealthy = false;
|
|
20
32
|
this.debugMode = debug;
|
|
33
|
+
this.streamingEnabled = enableStreaming;
|
|
21
34
|
if (this.debugMode) {
|
|
22
35
|
this.log.info('Debug mode enabled');
|
|
23
36
|
this.log.warn('Debug mode may log sensitive information - use only for troubleshooting');
|
|
24
37
|
}
|
|
38
|
+
if (this.streamingEnabled) {
|
|
39
|
+
this.log.info('Streaming mode enabled - real-time updates will be used');
|
|
40
|
+
}
|
|
25
41
|
}
|
|
26
42
|
maskToken(token) {
|
|
27
43
|
if (!token) {
|
|
@@ -99,17 +115,21 @@ class KumoAPI {
|
|
|
99
115
|
headers: {
|
|
100
116
|
'Content-Type': 'application/json',
|
|
101
117
|
'Accept': 'application/json',
|
|
102
|
-
'Authorization': `Bearer ${this.refreshToken}`,
|
|
103
118
|
'X-App-Version': settings_1.APP_VERSION,
|
|
104
119
|
},
|
|
120
|
+
body: JSON.stringify({
|
|
121
|
+
refresh: this.refreshToken,
|
|
122
|
+
}),
|
|
105
123
|
});
|
|
106
124
|
if (!response.ok) {
|
|
107
|
-
|
|
125
|
+
const errorText = await response.text();
|
|
126
|
+
this.log.warn(`Token refresh failed (${response.status}): ${errorText}`);
|
|
127
|
+
this.log.warn('Attempting full login');
|
|
108
128
|
return await this.login();
|
|
109
129
|
}
|
|
110
130
|
const data = await response.json();
|
|
111
|
-
this.accessToken = data.
|
|
112
|
-
this.refreshToken = data.
|
|
131
|
+
this.accessToken = data.access;
|
|
132
|
+
this.refreshToken = data.refresh;
|
|
113
133
|
this.tokenExpiresAt = Date.now() + settings_1.TOKEN_REFRESH_INTERVAL;
|
|
114
134
|
this.log.debug('Access token refreshed successfully');
|
|
115
135
|
this.scheduleTokenRefresh();
|
|
@@ -127,10 +147,22 @@ class KumoAPI {
|
|
|
127
147
|
}
|
|
128
148
|
async ensureAuthenticated() {
|
|
129
149
|
if (!this.accessToken || Date.now() >= this.tokenExpiresAt - (5 * 60 * 1000)) {
|
|
130
|
-
if (
|
|
131
|
-
|
|
150
|
+
if (this.refreshInProgress) {
|
|
151
|
+
this.log.debug('Waiting for existing token refresh to complete');
|
|
152
|
+
return await this.refreshInProgress;
|
|
132
153
|
}
|
|
133
|
-
|
|
154
|
+
this.refreshInProgress = (async () => {
|
|
155
|
+
try {
|
|
156
|
+
if (!this.refreshToken) {
|
|
157
|
+
return await this.login();
|
|
158
|
+
}
|
|
159
|
+
return await this.refreshAccessToken();
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
this.refreshInProgress = null;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
return await this.refreshInProgress;
|
|
134
166
|
}
|
|
135
167
|
return true;
|
|
136
168
|
}
|
|
@@ -156,7 +188,16 @@ class KumoAPI {
|
|
|
156
188
|
if (body) {
|
|
157
189
|
options.body = JSON.stringify(body);
|
|
158
190
|
}
|
|
159
|
-
const
|
|
191
|
+
const url = `${settings_1.API_BASE_URL}${endpoint}`;
|
|
192
|
+
if (this.debugMode) {
|
|
193
|
+
this.log.info(`→ API Request: ${method} ${endpoint}`);
|
|
194
|
+
if (body) {
|
|
195
|
+
this.log.info(` Body: ${JSON.stringify(body)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const startTime = Date.now();
|
|
199
|
+
const response = await (0, node_fetch_1.default)(url, options);
|
|
200
|
+
const duration = Date.now() - startTime;
|
|
160
201
|
if (response.status === 401) {
|
|
161
202
|
this.log.debug('Received 401, refreshing token and retrying');
|
|
162
203
|
const refreshed = await this.refreshAccessToken();
|
|
@@ -173,9 +214,23 @@ class KumoAPI {
|
|
|
173
214
|
}
|
|
174
215
|
if (!response.ok) {
|
|
175
216
|
this.log.error(`Request failed with status: ${response.status}`);
|
|
217
|
+
const errorText = await response.text();
|
|
218
|
+
if (this.debugMode || response.status === 400) {
|
|
219
|
+
this.log.error(` Error response: ${errorText}`);
|
|
220
|
+
}
|
|
176
221
|
return null;
|
|
177
222
|
}
|
|
178
|
-
|
|
223
|
+
const data = await response.json();
|
|
224
|
+
if (this.debugMode) {
|
|
225
|
+
this.log.info(`← API Response: ${response.status} (${duration}ms)`);
|
|
226
|
+
if (Array.isArray(data)) {
|
|
227
|
+
this.log.info(` Returned ${data.length} item(s)`);
|
|
228
|
+
}
|
|
229
|
+
else if (data && typeof data === 'object') {
|
|
230
|
+
this.log.info(` Keys: ${Object.keys(data).join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return data;
|
|
179
234
|
}
|
|
180
235
|
catch (error) {
|
|
181
236
|
if (error instanceof Error) {
|
|
@@ -196,58 +251,63 @@ class KumoAPI {
|
|
|
196
251
|
return sites || [];
|
|
197
252
|
}
|
|
198
253
|
async getZones(siteId) {
|
|
199
|
-
this.log.debug(`Fetching zones for site: ${siteId}`);
|
|
200
|
-
const zones = await this.makeAuthenticatedRequest(`/sites/${siteId}/zones`);
|
|
201
|
-
return zones || [];
|
|
202
|
-
}
|
|
203
|
-
async getZonesWithETag(siteId, forceRefresh = false) {
|
|
204
|
-
const etag = this.siteEtags.get(siteId);
|
|
205
254
|
const authenticated = await this.ensureAuthenticated();
|
|
206
255
|
if (!authenticated) {
|
|
207
256
|
this.log.error('Failed to authenticate');
|
|
208
|
-
return
|
|
257
|
+
return [];
|
|
209
258
|
}
|
|
210
259
|
try {
|
|
211
|
-
const
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
const response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}/sites/${siteId}/zones`, { headers });
|
|
216
|
-
if (response.status === 304) {
|
|
217
|
-
this.log.debug(`Zones for site ${siteId}: Not Modified (304)`);
|
|
218
|
-
return { zones: [], notModified: true };
|
|
260
|
+
const endpoint = `/sites/${siteId}/zones`;
|
|
261
|
+
if (this.debugMode) {
|
|
262
|
+
this.log.info(`→ API Request: GET ${endpoint}`);
|
|
219
263
|
}
|
|
264
|
+
const startTime = Date.now();
|
|
265
|
+
const response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}${endpoint}`, {
|
|
266
|
+
headers: this.getAuthHeaders(),
|
|
267
|
+
});
|
|
268
|
+
const duration = Date.now() - startTime;
|
|
220
269
|
if (!response.ok) {
|
|
221
270
|
const errorBody = await response.text();
|
|
222
271
|
this.log.error(`Failed to fetch zones for site ${siteId}: ${response.status} - ${errorBody}`);
|
|
223
|
-
return
|
|
224
|
-
}
|
|
225
|
-
const newEtag = response.headers.get('etag');
|
|
226
|
-
if (newEtag) {
|
|
227
|
-
this.siteEtags.set(siteId, newEtag);
|
|
272
|
+
return [];
|
|
228
273
|
}
|
|
229
274
|
const zones = await response.json();
|
|
230
275
|
if (this.debugMode) {
|
|
231
|
-
this.log.
|
|
276
|
+
this.log.info(`← API Response: 200 (${duration}ms)`);
|
|
277
|
+
this.log.info(` Fetched ${zones.length} zone(s) for site ${siteId}`);
|
|
278
|
+
zones.forEach(zone => {
|
|
279
|
+
this.log.info(` RAW Zone JSON for ${zone.name}:`);
|
|
280
|
+
this.log.info(JSON.stringify(zone, null, 2));
|
|
281
|
+
});
|
|
232
282
|
zones.forEach(zone => {
|
|
233
|
-
|
|
283
|
+
const a = zone.adapter;
|
|
284
|
+
this.log.info(` ${zone.name} [${a.deviceSerial}]`);
|
|
285
|
+
this.log.info(` Temperature: ${a.roomTemp}°C (current) → Heat: ${a.spHeat}°C, Cool: ${a.spCool}°C, Auto: ${a.spAuto}°C`);
|
|
286
|
+
this.log.info(` Status: ${a.operationMode} mode, power=${a.power}, connected=${a.connected}`);
|
|
287
|
+
this.log.info(` Fan: ${a.fanSpeed}, Direction: ${a.airDirection}, Humidity: ${a.humidity !== null ? a.humidity + '%' : 'N/A'}`);
|
|
288
|
+
this.log.info(` Signal: ${a.rssi !== undefined ? a.rssi + ' dBm' : 'N/A'}`);
|
|
234
289
|
});
|
|
235
290
|
}
|
|
236
|
-
return
|
|
291
|
+
return zones;
|
|
237
292
|
}
|
|
238
293
|
catch (error) {
|
|
239
294
|
if (error instanceof Error) {
|
|
240
|
-
this.log.error('Error fetching zones
|
|
295
|
+
this.log.error('Error fetching zones:', error.message);
|
|
241
296
|
}
|
|
242
297
|
else {
|
|
243
298
|
this.log.error('Error fetching zones: Unknown error occurred');
|
|
244
299
|
}
|
|
245
|
-
return
|
|
300
|
+
return [];
|
|
246
301
|
}
|
|
247
302
|
}
|
|
248
303
|
async getDeviceStatus(deviceSerial) {
|
|
249
304
|
this.log.debug(`Fetching status for device: ${deviceSerial}`);
|
|
250
|
-
|
|
305
|
+
const status = await this.makeAuthenticatedRequest(`/devices/${deviceSerial}/status`);
|
|
306
|
+
if (this.debugMode && status) {
|
|
307
|
+
this.log.info(` RAW Device Status JSON for ${deviceSerial}:`);
|
|
308
|
+
this.log.info(JSON.stringify(status, null, 2));
|
|
309
|
+
}
|
|
310
|
+
return status;
|
|
251
311
|
}
|
|
252
312
|
async sendCommand(deviceSerial, commands) {
|
|
253
313
|
this.log.debug(`Sending command to device ${deviceSerial}:`, JSON.stringify(commands));
|
|
@@ -274,11 +334,160 @@ class KumoAPI {
|
|
|
274
334
|
this.log.debug(`Command sent successfully to device ${deviceSerial}`);
|
|
275
335
|
return true;
|
|
276
336
|
}
|
|
337
|
+
async startStreaming(deviceSerials) {
|
|
338
|
+
var _a;
|
|
339
|
+
if (!this.streamingEnabled) {
|
|
340
|
+
this.log.debug('Streaming is disabled, skipping connection');
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) {
|
|
344
|
+
this.log.debug('Streaming already connected');
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
347
|
+
if (!this.accessToken) {
|
|
348
|
+
this.log.error('Cannot start streaming: not authenticated');
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
this.log.info('Starting streaming connection...');
|
|
353
|
+
this.socket = (0, socket_io_client_1.io)(settings_1.SOCKET_BASE_URL, {
|
|
354
|
+
transports: ['polling', 'websocket'],
|
|
355
|
+
extraHeaders: {
|
|
356
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
357
|
+
'Accept': '*/*',
|
|
358
|
+
'User-Agent': 'kumocloud/1122',
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
this.socket.on('connect', () => {
|
|
362
|
+
var _a, _b;
|
|
363
|
+
this.log.info(`✓ Streaming connected (ID: ${(_a = this.socket) === null || _a === void 0 ? void 0 : _a.id})`);
|
|
364
|
+
this.reconnectAttempts = 0;
|
|
365
|
+
for (const deviceSerial of deviceSerials) {
|
|
366
|
+
this.log.debug(`Subscribing to device: ${deviceSerial}`);
|
|
367
|
+
(_b = this.socket) === null || _b === void 0 ? void 0 : _b.emit('subscribe', deviceSerial);
|
|
368
|
+
}
|
|
369
|
+
for (const deviceSerial of deviceSerials) {
|
|
370
|
+
this.lastStreamingUpdate.set(deviceSerial, Date.now());
|
|
371
|
+
}
|
|
372
|
+
this.isStreamingHealthy = true;
|
|
373
|
+
this.notifyHealthChange(false, true);
|
|
374
|
+
this.startHealthChecks();
|
|
375
|
+
this.log.info('✓ Streaming connection established');
|
|
376
|
+
this.log.info(`Monitoring ${deviceSerials.length} device(s) for real-time updates`);
|
|
377
|
+
});
|
|
378
|
+
this.socket.on('device_update', (data) => {
|
|
379
|
+
const deviceSerial = data.deviceSerial;
|
|
380
|
+
if (!deviceSerial) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.updateStreamingTimestamp(deviceSerial);
|
|
384
|
+
if (this.debugMode) {
|
|
385
|
+
this.log.debug(`Stream update for ${deviceSerial}: temp=${data.roomTemp}°C, mode=${data.operationMode}, power=${data.power}`);
|
|
386
|
+
this.log.info(` RAW Streaming Update JSON for ${deviceSerial}:`);
|
|
387
|
+
this.log.info(JSON.stringify(data, null, 2));
|
|
388
|
+
}
|
|
389
|
+
const callback = this.deviceUpdateCallbacks.get(deviceSerial);
|
|
390
|
+
if (callback) {
|
|
391
|
+
callback(deviceSerial, data);
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
this.socket.on('disconnect', (reason) => {
|
|
395
|
+
this.log.warn(`✗ Streaming disconnected: ${reason}`);
|
|
396
|
+
const wasHealthy = this.isStreamingHealthy;
|
|
397
|
+
this.isStreamingHealthy = false;
|
|
398
|
+
this.notifyHealthChange(wasHealthy, false);
|
|
399
|
+
this.stopHealthChecks();
|
|
400
|
+
if (reason !== 'io client disconnect' && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
401
|
+
this.reconnectAttempts++;
|
|
402
|
+
this.log.info(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
|
|
403
|
+
}
|
|
404
|
+
else if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
|
405
|
+
this.log.error('Max reconnect attempts reached - polling will handle updates');
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
this.socket.on('connect_error', (error) => {
|
|
409
|
+
this.log.error(`Streaming connection error: ${error.message}`);
|
|
410
|
+
});
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
if (error instanceof Error) {
|
|
415
|
+
this.log.error('Failed to start streaming:', error.message);
|
|
416
|
+
}
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
subscribeToDevice(deviceSerial, callback) {
|
|
421
|
+
var _a;
|
|
422
|
+
this.deviceUpdateCallbacks.set(deviceSerial, callback);
|
|
423
|
+
if ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) {
|
|
424
|
+
this.log.debug(`Subscribing to device: ${deviceSerial}`);
|
|
425
|
+
this.socket.emit('subscribe', deviceSerial);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
unsubscribeFromDevice(deviceSerial) {
|
|
429
|
+
this.deviceUpdateCallbacks.delete(deviceSerial);
|
|
430
|
+
}
|
|
431
|
+
isStreamingConnected() {
|
|
432
|
+
var _a;
|
|
433
|
+
return ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.connected) || false;
|
|
434
|
+
}
|
|
435
|
+
setStreamingHealthConfig(checkInterval, staleThreshold) {
|
|
436
|
+
this.streamingHealthCheckInterval = checkInterval * 1000;
|
|
437
|
+
this.streamingStaleThreshold = staleThreshold * 1000;
|
|
438
|
+
this.log.debug(`Streaming health config: check every ${checkInterval}s, stale after ${staleThreshold}s`);
|
|
439
|
+
}
|
|
440
|
+
onStreamingHealthChange(callback) {
|
|
441
|
+
this.streamingHealthCallbacks.add(callback);
|
|
442
|
+
}
|
|
443
|
+
getStreamingHealth() {
|
|
444
|
+
return this.isStreamingHealthy;
|
|
445
|
+
}
|
|
446
|
+
updateStreamingTimestamp(deviceSerial) {
|
|
447
|
+
this.lastStreamingUpdate.set(deviceSerial, Date.now());
|
|
448
|
+
}
|
|
449
|
+
checkStreamingHealth() {
|
|
450
|
+
const wasHealthy = this.isStreamingHealthy;
|
|
451
|
+
this.isStreamingHealthy = this.isStreamingConnected();
|
|
452
|
+
this.notifyHealthChange(wasHealthy, this.isStreamingHealthy);
|
|
453
|
+
}
|
|
454
|
+
notifyHealthChange(wasHealthy, isHealthy) {
|
|
455
|
+
if (wasHealthy !== isHealthy) {
|
|
456
|
+
this.log.info(`Streaming health changed: ${wasHealthy ? 'healthy' : 'unhealthy'} → ${isHealthy ? 'healthy' : 'unhealthy'}`);
|
|
457
|
+
for (const callback of this.streamingHealthCallbacks) {
|
|
458
|
+
callback(isHealthy);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
startHealthChecks() {
|
|
463
|
+
if (this.healthCheckTimer) {
|
|
464
|
+
clearInterval(this.healthCheckTimer);
|
|
465
|
+
}
|
|
466
|
+
this.healthCheckTimer = setInterval(() => {
|
|
467
|
+
this.checkStreamingHealth();
|
|
468
|
+
}, this.streamingHealthCheckInterval);
|
|
469
|
+
this.log.debug('Started streaming health checks');
|
|
470
|
+
}
|
|
471
|
+
stopHealthChecks() {
|
|
472
|
+
if (this.healthCheckTimer) {
|
|
473
|
+
clearInterval(this.healthCheckTimer);
|
|
474
|
+
this.healthCheckTimer = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
277
477
|
destroy() {
|
|
278
478
|
if (this.refreshTimer) {
|
|
279
479
|
clearTimeout(this.refreshTimer);
|
|
280
480
|
this.refreshTimer = null;
|
|
281
481
|
}
|
|
482
|
+
this.stopHealthChecks();
|
|
483
|
+
this.streamingHealthCallbacks.clear();
|
|
484
|
+
this.lastStreamingUpdate.clear();
|
|
485
|
+
this.log.debug('Streaming health monitoring stopped');
|
|
486
|
+
if (this.socket) {
|
|
487
|
+
this.log.debug('Disconnecting streaming connection');
|
|
488
|
+
this.socket.disconnect();
|
|
489
|
+
this.socket = null;
|
|
490
|
+
}
|
|
282
491
|
}
|
|
283
492
|
}
|
|
284
493
|
exports.KumoAPI = KumoAPI;
|
package/dist/platform.d.ts
CHANGED
|
@@ -9,8 +9,20 @@ export declare class KumoV3Platform implements DynamicPlatformPlugin {
|
|
|
9
9
|
private readonly accessoryHandlers;
|
|
10
10
|
private readonly kumoAPI;
|
|
11
11
|
private readonly kumoConfig;
|
|
12
|
+
private readonly sitePollers;
|
|
13
|
+
private readonly siteAccessories;
|
|
14
|
+
private readonly degradedPollInterval;
|
|
15
|
+
private isStreamingHealthy;
|
|
16
|
+
private isDegradedMode;
|
|
12
17
|
constructor(log: Logger, config: PlatformConfig, api: API);
|
|
13
18
|
private cleanup;
|
|
14
19
|
configureAccessory(accessory: PlatformAccessory): void;
|
|
15
20
|
discoverDevices(): Promise<void>;
|
|
21
|
+
private startSitePoller;
|
|
22
|
+
private pollSite;
|
|
23
|
+
private handleStreamingHealthChange;
|
|
24
|
+
private enterDegradedMode;
|
|
25
|
+
private exitDegradedMode;
|
|
26
|
+
private restartAllPollers;
|
|
27
|
+
private stopAllPollers;
|
|
16
28
|
}
|
package/dist/platform.js
CHANGED
|
@@ -13,6 +13,10 @@ class KumoV3Platform {
|
|
|
13
13
|
this.Characteristic = this.api.hap.Characteristic;
|
|
14
14
|
this.accessories = [];
|
|
15
15
|
this.accessoryHandlers = [];
|
|
16
|
+
this.sitePollers = new Map();
|
|
17
|
+
this.siteAccessories = new Map();
|
|
18
|
+
this.isStreamingHealthy = false;
|
|
19
|
+
this.isDegradedMode = false;
|
|
16
20
|
this.kumoConfig = config;
|
|
17
21
|
this.log.debug('Initializing platform:', this.config.name);
|
|
18
22
|
const kumoConfig = this.kumoConfig;
|
|
@@ -34,7 +38,15 @@ class KumoV3Platform {
|
|
|
34
38
|
throw new Error('Invalid poll interval');
|
|
35
39
|
}
|
|
36
40
|
}
|
|
41
|
+
this.degradedPollInterval = (kumoConfig.degradedPollInterval || 10) * 1000;
|
|
42
|
+
this.log.debug(`Degraded polling interval: ${this.degradedPollInterval / 1000}s`);
|
|
37
43
|
this.kumoAPI = new kumo_api_1.KumoAPI(kumoConfig.username, kumoConfig.password, this.log, kumoConfig.debug || false);
|
|
44
|
+
const healthCheckInterval = kumoConfig.streamingHealthCheckInterval || 30;
|
|
45
|
+
const staleThreshold = kumoConfig.streamingStaleThreshold || 60;
|
|
46
|
+
this.kumoAPI.setStreamingHealthConfig(healthCheckInterval, staleThreshold);
|
|
47
|
+
this.kumoAPI.onStreamingHealthChange((isHealthy) => {
|
|
48
|
+
this.handleStreamingHealthChange(isHealthy);
|
|
49
|
+
});
|
|
38
50
|
this.api.on('didFinishLaunching', () => {
|
|
39
51
|
log.debug('Executed didFinishLaunching callback');
|
|
40
52
|
this.discoverDevices();
|
|
@@ -45,6 +57,11 @@ class KumoV3Platform {
|
|
|
45
57
|
});
|
|
46
58
|
}
|
|
47
59
|
cleanup() {
|
|
60
|
+
for (const [siteId, timer] of this.sitePollers) {
|
|
61
|
+
clearInterval(timer);
|
|
62
|
+
this.log.debug(`Stopped site poller for ${siteId}`);
|
|
63
|
+
}
|
|
64
|
+
this.sitePollers.clear();
|
|
48
65
|
for (const handler of this.accessoryHandlers) {
|
|
49
66
|
handler.destroy();
|
|
50
67
|
}
|
|
@@ -82,7 +99,7 @@ class KumoV3Platform {
|
|
|
82
99
|
const deviceSerial = zone.adapter.deviceSerial;
|
|
83
100
|
const displayName = zone.name;
|
|
84
101
|
if ((_a = this.kumoConfig.excludeDevices) === null || _a === void 0 ? void 0 : _a.includes(deviceSerial)) {
|
|
85
|
-
this.log.info(`
|
|
102
|
+
this.log.info(`Hiding device from HomeKit: ${displayName} (${deviceSerial})`);
|
|
86
103
|
continue;
|
|
87
104
|
}
|
|
88
105
|
const uuid = this.api.hap.uuid.generate(deviceSerial);
|
|
@@ -128,10 +145,164 @@ class KumoV3Platform {
|
|
|
128
145
|
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, staleAccessories);
|
|
129
146
|
}
|
|
130
147
|
this.log.info('Device discovery completed');
|
|
148
|
+
const allDeviceSerials = discoveredDevices.map(d => d.deviceSerial);
|
|
149
|
+
if (allDeviceSerials.length > 0) {
|
|
150
|
+
this.log.info('Starting streaming for real-time updates...');
|
|
151
|
+
const streamingStarted = await this.kumoAPI.startStreaming(allDeviceSerials);
|
|
152
|
+
if (streamingStarted) {
|
|
153
|
+
this.log.info('✓ Streaming enabled - devices will update in real-time');
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
this.log.warn('Streaming failed to start - falling back to polling');
|
|
157
|
+
}
|
|
158
|
+
const healthCheckInterval = this.kumoConfig.streamingHealthCheckInterval || 30;
|
|
159
|
+
const staleThreshold = this.kumoConfig.streamingStaleThreshold || 60;
|
|
160
|
+
this.log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
161
|
+
this.log.info('Mitsubishi Comfort Plugin Configuration');
|
|
162
|
+
this.log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
163
|
+
this.log.info(`Streaming: ${streamingStarted ? 'ENABLED' : 'DISABLED'}`);
|
|
164
|
+
this.log.info(`Polling mode: ${this.kumoConfig.disablePolling ? 'On-demand only' : 'Enabled'}`);
|
|
165
|
+
this.log.info(`Normal poll interval: ${(this.kumoConfig.pollInterval || 30)}s`);
|
|
166
|
+
this.log.info(`Degraded poll interval: ${this.degradedPollInterval / 1000}s`);
|
|
167
|
+
this.log.info(`Health check interval: ${healthCheckInterval}s`);
|
|
168
|
+
this.log.info(`Stale threshold: ${staleThreshold}s`);
|
|
169
|
+
this.log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
170
|
+
if (streamingStarted) {
|
|
171
|
+
if (this.kumoConfig.disablePolling) {
|
|
172
|
+
this.log.info('Strategy: Streaming primary, polling fallback only');
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
this.log.info('Strategy: Streaming primary, polling supplemental');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!this.kumoConfig.disablePolling) {
|
|
180
|
+
const uniqueSites = new Set(discoveredDevices.map(d => { var _a; return (_a = this.accessories.find(a => a.UUID === d.uuid)) === null || _a === void 0 ? void 0 : _a.context.device.siteId; }).filter(Boolean));
|
|
181
|
+
this.log.info(`Initializing pollers for ${uniqueSites.size} site(s)`);
|
|
182
|
+
for (const siteId of uniqueSites) {
|
|
183
|
+
this.startSitePoller(siteId);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
this.log.info('Polling disabled - will activate only if streaming fails');
|
|
188
|
+
}
|
|
131
189
|
}
|
|
132
190
|
catch (error) {
|
|
133
191
|
this.log.error('Error during device discovery:', error);
|
|
134
192
|
}
|
|
135
193
|
}
|
|
194
|
+
startSitePoller(siteId) {
|
|
195
|
+
if (this.sitePollers.has(siteId)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (this.isStreamingHealthy && this.kumoConfig.disablePolling) {
|
|
199
|
+
this.log.info(`Skipping poller for site ${siteId} (streaming healthy, polling disabled)`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const interval = this.isDegradedMode ? this.degradedPollInterval : (this.kumoConfig.pollInterval || 30) * 1000;
|
|
203
|
+
const intervalSec = interval / 1000;
|
|
204
|
+
const mode = this.isDegradedMode ? 'DEGRADED' : 'NORMAL';
|
|
205
|
+
this.log.info(`Starting ${mode} poller for site ${siteId}: ${intervalSec}s intervals`);
|
|
206
|
+
const accessories = this.accessoryHandlers.filter(handler => handler.getSiteId() === siteId);
|
|
207
|
+
this.siteAccessories.set(siteId, accessories);
|
|
208
|
+
this.pollSite(siteId);
|
|
209
|
+
const timer = setInterval(() => {
|
|
210
|
+
this.pollSite(siteId);
|
|
211
|
+
}, interval);
|
|
212
|
+
this.sitePollers.set(siteId, timer);
|
|
213
|
+
}
|
|
214
|
+
async pollSite(siteId) {
|
|
215
|
+
try {
|
|
216
|
+
const mode = this.isDegradedMode ? 'DEGRADED' : 'NORMAL';
|
|
217
|
+
const health = this.isStreamingHealthy ? 'healthy' : 'unhealthy';
|
|
218
|
+
this.log.debug(`[${mode}] Polling site ${siteId} (streaming: ${health})`);
|
|
219
|
+
const zones = await this.kumoAPI.getZones(siteId);
|
|
220
|
+
const accessories = this.siteAccessories.get(siteId) || [];
|
|
221
|
+
for (const handler of accessories) {
|
|
222
|
+
const zone = zones.find(z => z.adapter.deviceSerial === handler.getDeviceSerial());
|
|
223
|
+
if (zone) {
|
|
224
|
+
handler.updateFromZone(zone);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.log.warn(`Zone not found for device: ${handler.getDeviceSerial()}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
this.log.error(`Error polling site ${siteId}:`, error);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
handleStreamingHealthChange(isHealthy) {
|
|
236
|
+
const wasHealthy = this.isStreamingHealthy;
|
|
237
|
+
this.isStreamingHealthy = isHealthy;
|
|
238
|
+
if (wasHealthy && !isHealthy) {
|
|
239
|
+
this.log.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
240
|
+
this.log.warn('⚠ STREAMING INTERRUPTED');
|
|
241
|
+
this.log.warn('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
242
|
+
this.enterDegradedMode();
|
|
243
|
+
}
|
|
244
|
+
if (!wasHealthy && isHealthy) {
|
|
245
|
+
this.log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
246
|
+
this.log.info('✓ STREAMING RESUMED');
|
|
247
|
+
this.log.info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
248
|
+
this.exitDegradedMode();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
enterDegradedMode() {
|
|
252
|
+
if (this.isDegradedMode) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.isDegradedMode = true;
|
|
256
|
+
const intervalSec = this.degradedPollInterval / 1000;
|
|
257
|
+
this.log.warn(`→ Switching to DEGRADED MODE`);
|
|
258
|
+
this.log.warn(`→ Polling activated: ${intervalSec}s intervals`);
|
|
259
|
+
this.log.warn(`→ Updates will continue via API polling`);
|
|
260
|
+
if (this.kumoConfig.disablePolling) {
|
|
261
|
+
this.log.warn('→ Overriding disablePolling setting for fallback');
|
|
262
|
+
}
|
|
263
|
+
this.restartAllPollers(this.degradedPollInterval);
|
|
264
|
+
}
|
|
265
|
+
exitDegradedMode() {
|
|
266
|
+
if (!this.isDegradedMode) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.isDegradedMode = false;
|
|
270
|
+
if (this.kumoConfig.disablePolling) {
|
|
271
|
+
this.log.info('→ Returning to NORMAL MODE');
|
|
272
|
+
this.log.info('→ Polling halted (streaming active)');
|
|
273
|
+
this.log.info('→ Updates resume via real-time streaming');
|
|
274
|
+
this.stopAllPollers();
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
const normalInterval = (this.kumoConfig.pollInterval || 30) * 1000;
|
|
278
|
+
const normalSec = normalInterval / 1000;
|
|
279
|
+
this.log.info('→ Returning to NORMAL MODE');
|
|
280
|
+
this.log.info(`→ Polling reduced to ${normalSec}s intervals`);
|
|
281
|
+
this.log.info('→ Primary updates via streaming');
|
|
282
|
+
this.restartAllPollers(normalInterval);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
restartAllPollers(intervalMs) {
|
|
286
|
+
const intervalSec = intervalMs / 1000;
|
|
287
|
+
for (const [siteId, timer] of this.sitePollers) {
|
|
288
|
+
clearInterval(timer);
|
|
289
|
+
this.pollSite(siteId);
|
|
290
|
+
const newTimer = setInterval(() => {
|
|
291
|
+
this.pollSite(siteId);
|
|
292
|
+
}, intervalMs);
|
|
293
|
+
this.sitePollers.set(siteId, newTimer);
|
|
294
|
+
this.log.debug(`Poller restarted for site ${siteId}: ${intervalSec}s interval`);
|
|
295
|
+
}
|
|
296
|
+
const siteCount = this.sitePollers.size;
|
|
297
|
+
this.log.info(`✓ ${siteCount} site poller(s) active at ${intervalSec}s intervals`);
|
|
298
|
+
}
|
|
299
|
+
stopAllPollers() {
|
|
300
|
+
for (const [siteId, timer] of this.sitePollers) {
|
|
301
|
+
clearInterval(timer);
|
|
302
|
+
this.log.debug(`Poller stopped for site ${siteId}`);
|
|
303
|
+
}
|
|
304
|
+
this.sitePollers.clear();
|
|
305
|
+
this.log.info('✓ All polling halted');
|
|
306
|
+
}
|
|
136
307
|
}
|
|
137
308
|
exports.KumoV3Platform = KumoV3Platform;
|
package/dist/settings.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export declare const PLATFORM_NAME = "KumoV3";
|
|
2
2
|
export declare const PLUGIN_NAME = "homebridge-mitsubishi-comfort";
|
|
3
3
|
export declare const API_BASE_URL = "https://app-prod.kumocloud.com/v3";
|
|
4
|
+
export declare const SOCKET_BASE_URL = "https://socket-prod.kumocloud.com";
|
|
4
5
|
export declare const TOKEN_REFRESH_INTERVAL: number;
|
|
5
6
|
export declare const POLL_INTERVAL: number;
|
|
6
7
|
export declare const APP_VERSION = "3.2.3";
|
|
@@ -10,8 +11,12 @@ export interface KumoConfig {
|
|
|
10
11
|
username: string;
|
|
11
12
|
password: string;
|
|
12
13
|
pollInterval?: number;
|
|
14
|
+
disablePolling?: boolean;
|
|
13
15
|
debug?: boolean;
|
|
14
16
|
excludeDevices?: string[];
|
|
17
|
+
streamingHealthCheckInterval?: number;
|
|
18
|
+
streamingStaleThreshold?: number;
|
|
19
|
+
degradedPollInterval?: number;
|
|
15
20
|
}
|
|
16
21
|
export interface LoginResponse {
|
|
17
22
|
id: string;
|
|
@@ -71,7 +76,7 @@ export interface DeviceStatus {
|
|
|
71
76
|
export interface Commands {
|
|
72
77
|
spHeat?: number;
|
|
73
78
|
spCool?: number;
|
|
74
|
-
operationMode?: 'off' | 'heat' | 'cool' | 'auto';
|
|
79
|
+
operationMode?: 'off' | 'heat' | 'cool' | 'auto' | 'vent' | 'dry';
|
|
75
80
|
fanSpeed?: 'auto' | 'low' | 'medium' | 'high';
|
|
76
81
|
}
|
|
77
82
|
export interface SendCommandRequest {
|
package/dist/settings.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.APP_VERSION = exports.POLL_INTERVAL = exports.TOKEN_REFRESH_INTERVAL = exports.API_BASE_URL = exports.PLUGIN_NAME = exports.PLATFORM_NAME = void 0;
|
|
3
|
+
exports.APP_VERSION = exports.POLL_INTERVAL = exports.TOKEN_REFRESH_INTERVAL = exports.SOCKET_BASE_URL = exports.API_BASE_URL = exports.PLUGIN_NAME = exports.PLATFORM_NAME = void 0;
|
|
4
4
|
exports.PLATFORM_NAME = 'KumoV3';
|
|
5
5
|
exports.PLUGIN_NAME = 'homebridge-mitsubishi-comfort';
|
|
6
6
|
exports.API_BASE_URL = 'https://app-prod.kumocloud.com/v3';
|
|
7
|
+
exports.SOCKET_BASE_URL = 'https://socket-prod.kumocloud.com';
|
|
7
8
|
exports.TOKEN_REFRESH_INTERVAL = 15 * 60 * 1000;
|
|
8
9
|
exports.POLL_INTERVAL = 30 * 1000;
|
|
9
10
|
exports.APP_VERSION = '3.2.3';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-mitsubishi-comfort",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Homebridge plugin for Mitsubishi heat pumps using Kumo Cloud v3 API",
|
|
5
5
|
"author": "burtherman",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
},
|
|
34
34
|
"homepage": "https://github.com/burtherman/homebridge-mitsubishi-comfort#readme",
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"node-fetch": "^3.2.0"
|
|
36
|
+
"node-fetch": "^3.2.0",
|
|
37
|
+
"socket.io-client": "^4.8.1"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/node": "^15.12.3",
|