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 CHANGED
@@ -1,16 +1,27 @@
1
- # Homebridge Kumo v3 Plugin
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 | Status polling interval in seconds (default: 30) |
64
- | `excludeDevices` | string[] | No | Array of device serial numbers to exclude |
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. **Polling**: Device status is polled every 30 seconds to keep HomeKit in sync
89
- 5. **Control**: Changes made in HomeKit are sent to the Kumo Cloud API
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
- MIT
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
 
@@ -31,7 +31,13 @@
31
31
  "type": "integer",
32
32
  "default": 30,
33
33
  "minimum": 5,
34
- "description": "How often to poll device status (minimum 5 seconds)"
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": "Excluded Devices",
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": "List of device serial numbers to exclude from HomeKit"
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": ["pollInterval", "excludeDevices", "debug"]
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
  }
@@ -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 startPolling;
17
- private updateStatus;
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.service.getCharacteristic(this.platform.Characteristic.TemperatureDisplayUnits)
34
- .onGet(this.getTemperatureDisplayUnits.bind(this))
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
- startPolling() {
39
- this.updateStatus();
40
- this.pollTimer = setInterval(() => {
41
- this.updateStatus();
42
- }, this.pollIntervalMs);
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
- async updateStatus(forceRefresh = false) {
76
+ updateFromZone(zone) {
77
+ const updateTimestamp = Date.now();
78
+ this.processZoneUpdate(zone, 'polling', updateTimestamp);
79
+ }
80
+ processZoneUpdate(zone, source, timestamp) {
45
81
  try {
46
- const result = await this.kumoAPI.getZonesWithETag(this.siteId, forceRefresh);
47
- if (result.notModified) {
48
- this.platform.log.debug(`Status not modified for device ${this.deviceSerial}`);
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
- const zone = result.zones.find(z => z.adapter.deviceSerial === this.deviceSerial);
52
- if (!zone) {
53
- this.platform.log.error(`Device ${this.deviceSerial} not found in zones response`);
54
- return;
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(`Updated status for ${this.deviceSerial}: roomTemp=${status.roomTemp}, mode=${status.operationMode}`);
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
- setTimeout(() => this.updateStatus(true), 1000);
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 target heating cooling state for ${this.accessory.displayName}`);
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.warn('No status available for getCurrentTemperature');
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.platform.log.warn('Invalid roomTemp value:', temp);
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('Get CurrentTemperature:', temp);
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.warn('No status available for getTargetTemperature');
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.platform.log.warn('Invalid target temperature value:', temp);
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('Get TargetTemperature:', temp);
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
- this.platform.log.debug('Set TargetTemperature:', temp);
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 = roundedTemp;
321
+ commands.spHeat = temp;
269
322
  }
270
323
  else if (this.currentStatus.operationMode === 'cool') {
271
- commands.spCool = roundedTemp;
324
+ commands.spCool = temp;
272
325
  }
273
326
  else if (this.currentStatus.operationMode === 'auto') {
274
- commands.spHeat = roundedTemp;
275
- commands.spCool = roundedTemp;
327
+ commands.spHeat = temp;
328
+ commands.spCool = temp;
276
329
  }
277
330
  else {
278
- commands.spHeat = roundedTemp;
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
- setTimeout(() => this.updateStatus(true), 1000);
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
- if (this.pollTimer) {
308
- clearInterval(this.pollTimer);
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;
@@ -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
- constructor(username: string, password: string, log: Logger, debug?: boolean);
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
- this.log.warn('Token refresh failed, attempting full login');
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.token.access;
112
- this.refreshToken = data.token.refresh;
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 (!this.refreshToken) {
131
- return await this.login();
150
+ if (this.refreshInProgress) {
151
+ this.log.debug('Waiting for existing token refresh to complete');
152
+ return await this.refreshInProgress;
132
153
  }
133
- return await this.refreshAccessToken();
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 response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}${endpoint}`, options);
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
- return await response.json();
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 { zones: [], notModified: false };
257
+ return [];
209
258
  }
210
259
  try {
211
- const headers = this.getAuthHeaders();
212
- if (etag && !forceRefresh) {
213
- headers['If-None-Match'] = etag;
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 { zones: [], notModified: false };
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.debug(`Fetched ${zones.length} zones for site ${siteId}`);
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
- this.log.debug(`Zone ${zone.name} (${zone.adapter.deviceSerial}): roomTemp=${zone.adapter.roomTemp}`);
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 { zones, notModified: false };
291
+ return zones;
237
292
  }
238
293
  catch (error) {
239
294
  if (error instanceof Error) {
240
- this.log.error('Error fetching zones with ETag:', error.message);
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 { zones: [], notModified: false };
300
+ return [];
246
301
  }
247
302
  }
248
303
  async getDeviceStatus(deviceSerial) {
249
304
  this.log.debug(`Fetching status for device: ${deviceSerial}`);
250
- return await this.makeAuthenticatedRequest(`/devices/${deviceSerial}/status`);
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;
@@ -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(`Skipping excluded device: ${displayName} (${deviceSerial})`);
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;
@@ -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.0.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",