homebridge-mitsubishi-comfort 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -0
- package/config.schema.json +68 -0
- package/dist/accessory.d.ts +31 -0
- package/dist/accessory.js +313 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/kumo-api.d.ts +30 -0
- package/dist/kumo-api.js +284 -0
- package/dist/platform.d.ts +16 -0
- package/dist/platform.js +137 -0
- package/dist/settings.d.ts +83 -0
- package/dist/settings.js +9 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Homebridge Kumo v3 Plugin
|
|
2
|
+
|
|
3
|
+
A Homebridge plugin for Mitsubishi heat pumps using the Kumo Cloud v3 API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Full HomeKit thermostat integration
|
|
8
|
+
- Support for Heat, Cool, Auto, and Off modes
|
|
9
|
+
- Temperature control
|
|
10
|
+
- Current temperature and humidity display
|
|
11
|
+
- Automatic token refresh
|
|
12
|
+
- Status polling every 30 seconds
|
|
13
|
+
- Multi-site and multi-zone support
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
### Prerequisites
|
|
18
|
+
|
|
19
|
+
- Node.js (v14.18.1 or higher)
|
|
20
|
+
- Homebridge (v1.3.5 or higher)
|
|
21
|
+
|
|
22
|
+
### Install from NPM (once published)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g homebridge-mitsubishi-comfort
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Install from Source
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/burtherman/homebridge-mitsubishi-comfort.git
|
|
32
|
+
cd homebridge-mitsubishi-comfort
|
|
33
|
+
npm install
|
|
34
|
+
npm run build
|
|
35
|
+
npm link
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
Add the following to your Homebridge `config.json`:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"platforms": [
|
|
45
|
+
{
|
|
46
|
+
"platform": "KumoV3",
|
|
47
|
+
"name": "Kumo",
|
|
48
|
+
"username": "your-email@example.com",
|
|
49
|
+
"password": "your-password"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Configuration Options
|
|
56
|
+
|
|
57
|
+
| Option | Type | Required | Description |
|
|
58
|
+
|--------|------|----------|-------------|
|
|
59
|
+
| `platform` | string | Yes | Must be `KumoV3` |
|
|
60
|
+
| `name` | string | No | Platform name (default: "Kumo") |
|
|
61
|
+
| `username` | string | Yes | Your Kumo Cloud email address |
|
|
62
|
+
| `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 |
|
|
65
|
+
| `debug` | boolean | No | Enable debug logging (default: false) |
|
|
66
|
+
|
|
67
|
+
## Development
|
|
68
|
+
|
|
69
|
+
### Build
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm run build
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Watch for Changes
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npm run watch
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
This will compile TypeScript, link the plugin, and restart on changes.
|
|
82
|
+
|
|
83
|
+
## How It Works
|
|
84
|
+
|
|
85
|
+
1. **Authentication**: The plugin logs in to the Kumo Cloud v3 API using your credentials
|
|
86
|
+
2. **Token Management**: Access tokens are automatically refreshed every 15 minutes
|
|
87
|
+
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
|
|
90
|
+
|
|
91
|
+
## Supported Characteristics
|
|
92
|
+
|
|
93
|
+
- Current Temperature
|
|
94
|
+
- Target Temperature
|
|
95
|
+
- Current Heating/Cooling State
|
|
96
|
+
- Target Heating/Cooling State (Off, Heat, Cool, Auto)
|
|
97
|
+
- Temperature Display Units (Celsius/Fahrenheit)
|
|
98
|
+
- Current Relative Humidity (when available)
|
|
99
|
+
|
|
100
|
+
## API Endpoints Used
|
|
101
|
+
|
|
102
|
+
- `POST /v3/login` - Authentication
|
|
103
|
+
- `GET /v3/sites` - Get all sites
|
|
104
|
+
- `GET /v3/sites/{siteId}/zones` - Get zones for a site
|
|
105
|
+
- `GET /v3/devices/{deviceSerial}/status` - Get device status
|
|
106
|
+
- `POST /v3/devices/send-command` - Send commands to device
|
|
107
|
+
|
|
108
|
+
## Security
|
|
109
|
+
|
|
110
|
+
### Best Practices
|
|
111
|
+
|
|
112
|
+
- **Credentials**: Your Kumo Cloud credentials are stored in the Homebridge config file. Ensure this file has appropriate permissions (readable only by the Homebridge user).
|
|
113
|
+
- **Debug Mode**: Only enable debug mode when troubleshooting. Debug logs may contain sensitive information like API endpoints and error details.
|
|
114
|
+
- **Network**: This plugin communicates with Kumo Cloud servers over HTTPS. Ensure your Homebridge instance runs in a secure network environment.
|
|
115
|
+
- **Updates**: Keep the plugin updated to receive security patches.
|
|
116
|
+
|
|
117
|
+
### What Data is Transmitted
|
|
118
|
+
|
|
119
|
+
- Authentication credentials (username/password) are sent to Kumo Cloud API during login
|
|
120
|
+
- Device commands and status updates are exchanged with Kumo Cloud servers
|
|
121
|
+
- No data is transmitted to third parties
|
|
122
|
+
- All communication uses HTTPS encryption
|
|
123
|
+
|
|
124
|
+
## Troubleshooting
|
|
125
|
+
|
|
126
|
+
### Plugin not discovering devices
|
|
127
|
+
|
|
128
|
+
- Verify your username and password are correct
|
|
129
|
+
- Check Homebridge logs for authentication errors
|
|
130
|
+
- Ensure your Kumo Cloud account has active devices
|
|
131
|
+
|
|
132
|
+
### Devices not responding to commands
|
|
133
|
+
|
|
134
|
+
- Check your internet connection
|
|
135
|
+
- Verify devices are online in the Kumo Cloud app
|
|
136
|
+
- Check Homebridge logs for API errors
|
|
137
|
+
|
|
138
|
+
### Temperature not updating
|
|
139
|
+
|
|
140
|
+
- Status is polled every 30 seconds by default
|
|
141
|
+
- Ensure the device is connected (check in Kumo Cloud app)
|
|
142
|
+
- Look for polling errors in Homebridge logs
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
|
147
|
+
|
|
148
|
+
## Credits
|
|
149
|
+
|
|
150
|
+
Based on the Kumo Cloud v3 API and inspired by [homebridge-kumo](https://github.com/fjs21/homebridge-kumo).
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "KumoV3",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Homebridge plugin for Mitsubishi heat pumps using Kumo Cloud v3 API.",
|
|
6
|
+
"footerDisplay": "For help and support, visit the [GitHub repository](https://github.com/burtherman/homebridge-mitsubishi-comfort).",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "Kumo",
|
|
14
|
+
"required": true
|
|
15
|
+
},
|
|
16
|
+
"username": {
|
|
17
|
+
"title": "Username",
|
|
18
|
+
"type": "string",
|
|
19
|
+
"required": true,
|
|
20
|
+
"format": "email",
|
|
21
|
+
"description": "Your Kumo Cloud email address"
|
|
22
|
+
},
|
|
23
|
+
"password": {
|
|
24
|
+
"title": "Password",
|
|
25
|
+
"type": "string",
|
|
26
|
+
"required": true,
|
|
27
|
+
"description": "Your Kumo Cloud password"
|
|
28
|
+
},
|
|
29
|
+
"pollInterval": {
|
|
30
|
+
"title": "Poll Interval (seconds)",
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"default": 30,
|
|
33
|
+
"minimum": 5,
|
|
34
|
+
"description": "How often to poll device status (minimum 5 seconds)"
|
|
35
|
+
},
|
|
36
|
+
"debug": {
|
|
37
|
+
"title": "Debug Mode",
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"default": false,
|
|
40
|
+
"description": "Enable verbose debug logging (requires running Homebridge with -D flag)"
|
|
41
|
+
},
|
|
42
|
+
"excludeDevices": {
|
|
43
|
+
"title": "Excluded Devices",
|
|
44
|
+
"type": "array",
|
|
45
|
+
"items": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"title": "Device Serial Number"
|
|
48
|
+
},
|
|
49
|
+
"description": "List of device serial numbers to exclude from HomeKit"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"layout": [
|
|
54
|
+
{
|
|
55
|
+
"type": "fieldset",
|
|
56
|
+
"title": "Kumo Cloud Credentials",
|
|
57
|
+
"expandable": false,
|
|
58
|
+
"items": ["username", "password"]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "fieldset",
|
|
62
|
+
"title": "Advanced Settings",
|
|
63
|
+
"expandable": true,
|
|
64
|
+
"expanded": false,
|
|
65
|
+
"items": ["pollInterval", "excludeDevices", "debug"]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { PlatformAccessory, CharacteristicValue } from 'homebridge';
|
|
2
|
+
import { KumoV3Platform } from './platform';
|
|
3
|
+
import { KumoAPI } from './kumo-api';
|
|
4
|
+
export declare class KumoThermostatAccessory {
|
|
5
|
+
private readonly platform;
|
|
6
|
+
private readonly accessory;
|
|
7
|
+
private readonly kumoAPI;
|
|
8
|
+
private service;
|
|
9
|
+
private pollTimer;
|
|
10
|
+
private deviceSerial;
|
|
11
|
+
private siteId;
|
|
12
|
+
private currentStatus;
|
|
13
|
+
private pollIntervalMs;
|
|
14
|
+
private hasHumiditySensor;
|
|
15
|
+
constructor(platform: KumoV3Platform, accessory: PlatformAccessory, kumoAPI: KumoAPI, pollIntervalSeconds?: number);
|
|
16
|
+
private startPolling;
|
|
17
|
+
private updateStatus;
|
|
18
|
+
private mapToCurrentHeatingCoolingState;
|
|
19
|
+
private mapToTargetHeatingCoolingState;
|
|
20
|
+
private getTargetTempFromStatus;
|
|
21
|
+
getCurrentHeatingCoolingState(): Promise<CharacteristicValue>;
|
|
22
|
+
getTargetHeatingCoolingState(): Promise<CharacteristicValue>;
|
|
23
|
+
setTargetHeatingCoolingState(value: CharacteristicValue): Promise<void>;
|
|
24
|
+
getCurrentTemperature(): Promise<CharacteristicValue>;
|
|
25
|
+
getTargetTemperature(): Promise<CharacteristicValue>;
|
|
26
|
+
setTargetTemperature(value: CharacteristicValue): Promise<void>;
|
|
27
|
+
getTemperatureDisplayUnits(): Promise<CharacteristicValue>;
|
|
28
|
+
setTemperatureDisplayUnits(value: CharacteristicValue): Promise<void>;
|
|
29
|
+
getCurrentRelativeHumidity(): Promise<CharacteristicValue>;
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KumoThermostatAccessory = void 0;
|
|
4
|
+
const settings_1 = require("./settings");
|
|
5
|
+
class KumoThermostatAccessory {
|
|
6
|
+
constructor(platform, accessory, kumoAPI, pollIntervalSeconds) {
|
|
7
|
+
this.platform = platform;
|
|
8
|
+
this.accessory = accessory;
|
|
9
|
+
this.kumoAPI = kumoAPI;
|
|
10
|
+
this.pollTimer = null;
|
|
11
|
+
this.currentStatus = null;
|
|
12
|
+
this.hasHumiditySensor = false;
|
|
13
|
+
this.deviceSerial = this.accessory.context.device.deviceSerial;
|
|
14
|
+
this.siteId = this.accessory.context.device.siteId;
|
|
15
|
+
this.pollIntervalMs = (pollIntervalSeconds || settings_1.POLL_INTERVAL / 1000) * 1000;
|
|
16
|
+
this.accessory.getService(this.platform.Service.AccessoryInformation)
|
|
17
|
+
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Mitsubishi')
|
|
18
|
+
.setCharacteristic(this.platform.Characteristic.Model, 'Kumo Cloud Heat Pump')
|
|
19
|
+
.setCharacteristic(this.platform.Characteristic.SerialNumber, this.deviceSerial);
|
|
20
|
+
this.service = this.accessory.getService(this.platform.Service.Thermostat) ||
|
|
21
|
+
this.accessory.addService(this.platform.Service.Thermostat);
|
|
22
|
+
this.service.setCharacteristic(this.platform.Characteristic.Name, accessory.context.device.displayName);
|
|
23
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState)
|
|
24
|
+
.onGet(this.getCurrentHeatingCoolingState.bind(this));
|
|
25
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState)
|
|
26
|
+
.onGet(this.getTargetHeatingCoolingState.bind(this))
|
|
27
|
+
.onSet(this.setTargetHeatingCoolingState.bind(this));
|
|
28
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentTemperature)
|
|
29
|
+
.onGet(this.getCurrentTemperature.bind(this));
|
|
30
|
+
this.service.getCharacteristic(this.platform.Characteristic.TargetTemperature)
|
|
31
|
+
.onGet(this.getTargetTemperature.bind(this))
|
|
32
|
+
.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();
|
|
37
|
+
}
|
|
38
|
+
startPolling() {
|
|
39
|
+
this.updateStatus();
|
|
40
|
+
this.pollTimer = setInterval(() => {
|
|
41
|
+
this.updateStatus();
|
|
42
|
+
}, this.pollIntervalMs);
|
|
43
|
+
}
|
|
44
|
+
async updateStatus() {
|
|
45
|
+
try {
|
|
46
|
+
const result = await this.kumoAPI.getZonesWithETag(this.siteId);
|
|
47
|
+
if (result.notModified) {
|
|
48
|
+
this.platform.log.debug(`Status not modified for device ${this.deviceSerial}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
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;
|
|
55
|
+
}
|
|
56
|
+
if (zone.adapter.roomTemp === undefined || zone.adapter.roomTemp === null) {
|
|
57
|
+
this.platform.log.error(`Device ${this.deviceSerial} has invalid roomTemp: ${zone.adapter.roomTemp}`);
|
|
58
|
+
this.platform.log.debug('Zone adapter data:', JSON.stringify(zone.adapter));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const hasHumidity = zone.adapter.humidity !== null && zone.adapter.humidity !== undefined;
|
|
62
|
+
if (hasHumidity && !this.hasHumiditySensor) {
|
|
63
|
+
this.hasHumiditySensor = true;
|
|
64
|
+
this.service.getCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity)
|
|
65
|
+
.onGet(this.getCurrentRelativeHumidity.bind(this));
|
|
66
|
+
this.platform.log.debug(`Added humidity characteristic for device ${this.deviceSerial}`);
|
|
67
|
+
}
|
|
68
|
+
else if (!hasHumidity && this.hasHumiditySensor) {
|
|
69
|
+
this.hasHumiditySensor = false;
|
|
70
|
+
if (this.service.testCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity)) {
|
|
71
|
+
this.service.removeCharacteristic(this.service.getCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity));
|
|
72
|
+
this.platform.log.debug(`Removed humidity characteristic for device ${this.deviceSerial}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const status = {
|
|
76
|
+
id: zone.id,
|
|
77
|
+
deviceSerial: zone.adapter.deviceSerial,
|
|
78
|
+
rssi: zone.adapter.rssi || 0,
|
|
79
|
+
power: zone.adapter.power,
|
|
80
|
+
operationMode: zone.adapter.operationMode,
|
|
81
|
+
humidity: zone.adapter.humidity,
|
|
82
|
+
fanSpeed: zone.adapter.fanSpeed,
|
|
83
|
+
airDirection: zone.adapter.airDirection,
|
|
84
|
+
roomTemp: zone.adapter.roomTemp,
|
|
85
|
+
spCool: zone.adapter.spCool,
|
|
86
|
+
spHeat: zone.adapter.spHeat,
|
|
87
|
+
spAuto: zone.adapter.spAuto,
|
|
88
|
+
};
|
|
89
|
+
this.currentStatus = status;
|
|
90
|
+
this.platform.log.debug(`Updated status for ${this.deviceSerial}: roomTemp=${status.roomTemp}, mode=${status.operationMode}`);
|
|
91
|
+
this.service.updateCharacteristic(this.platform.Characteristic.CurrentHeatingCoolingState, this.mapToCurrentHeatingCoolingState(status));
|
|
92
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetHeatingCoolingState, this.mapToTargetHeatingCoolingState(status));
|
|
93
|
+
if (status.roomTemp !== undefined && status.roomTemp !== null && !isNaN(status.roomTemp)) {
|
|
94
|
+
this.service.updateCharacteristic(this.platform.Characteristic.CurrentTemperature, status.roomTemp);
|
|
95
|
+
}
|
|
96
|
+
const targetTemp = this.getTargetTempFromStatus(status);
|
|
97
|
+
if (targetTemp !== undefined && targetTemp !== null && !isNaN(targetTemp)) {
|
|
98
|
+
this.service.updateCharacteristic(this.platform.Characteristic.TargetTemperature, targetTemp);
|
|
99
|
+
}
|
|
100
|
+
if (this.hasHumiditySensor && status.humidity !== null) {
|
|
101
|
+
this.service.updateCharacteristic(this.platform.Characteristic.CurrentRelativeHumidity, status.humidity);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
this.platform.log.error('Error updating device status:', error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
mapToCurrentHeatingCoolingState(status) {
|
|
109
|
+
if (status.power === 0) {
|
|
110
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
|
|
111
|
+
}
|
|
112
|
+
switch (status.operationMode) {
|
|
113
|
+
case 'heat':
|
|
114
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.HEAT;
|
|
115
|
+
case 'cool':
|
|
116
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.COOL;
|
|
117
|
+
case 'auto':
|
|
118
|
+
const targetTemp = this.getTargetTempFromStatus(status);
|
|
119
|
+
if (status.roomTemp < targetTemp) {
|
|
120
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.HEAT;
|
|
121
|
+
}
|
|
122
|
+
else if (status.roomTemp > targetTemp) {
|
|
123
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.COOL;
|
|
124
|
+
}
|
|
125
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
|
|
126
|
+
case 'off':
|
|
127
|
+
default:
|
|
128
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
mapToTargetHeatingCoolingState(status) {
|
|
132
|
+
if (status.power === 0 || status.operationMode === 'off') {
|
|
133
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.OFF;
|
|
134
|
+
}
|
|
135
|
+
switch (status.operationMode) {
|
|
136
|
+
case 'heat':
|
|
137
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.HEAT;
|
|
138
|
+
case 'cool':
|
|
139
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.COOL;
|
|
140
|
+
case 'auto':
|
|
141
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.AUTO;
|
|
142
|
+
default:
|
|
143
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.OFF;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
getTargetTempFromStatus(status) {
|
|
147
|
+
if (status.operationMode === 'heat' && status.spHeat !== undefined && status.spHeat !== null) {
|
|
148
|
+
return status.spHeat;
|
|
149
|
+
}
|
|
150
|
+
else if (status.operationMode === 'cool' && status.spCool !== undefined && status.spCool !== null) {
|
|
151
|
+
return status.spCool;
|
|
152
|
+
}
|
|
153
|
+
else if (status.operationMode === 'auto' && status.spAuto !== null && status.spAuto !== undefined) {
|
|
154
|
+
return status.spAuto;
|
|
155
|
+
}
|
|
156
|
+
if (status.spHeat !== undefined && status.spHeat !== null) {
|
|
157
|
+
return status.spHeat;
|
|
158
|
+
}
|
|
159
|
+
return 20;
|
|
160
|
+
}
|
|
161
|
+
async getCurrentHeatingCoolingState() {
|
|
162
|
+
if (!this.currentStatus) {
|
|
163
|
+
const status = await this.kumoAPI.getDeviceStatus(this.deviceSerial);
|
|
164
|
+
if (status) {
|
|
165
|
+
this.currentStatus = status;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
return this.platform.Characteristic.CurrentHeatingCoolingState.OFF;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const state = this.mapToCurrentHeatingCoolingState(this.currentStatus);
|
|
172
|
+
this.platform.log.debug('Get CurrentHeatingCoolingState:', state);
|
|
173
|
+
return state;
|
|
174
|
+
}
|
|
175
|
+
async getTargetHeatingCoolingState() {
|
|
176
|
+
if (!this.currentStatus) {
|
|
177
|
+
const status = await this.kumoAPI.getDeviceStatus(this.deviceSerial);
|
|
178
|
+
if (status) {
|
|
179
|
+
this.currentStatus = status;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
return this.platform.Characteristic.TargetHeatingCoolingState.OFF;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const state = this.mapToTargetHeatingCoolingState(this.currentStatus);
|
|
186
|
+
this.platform.log.debug('Get TargetHeatingCoolingState:', state);
|
|
187
|
+
return state;
|
|
188
|
+
}
|
|
189
|
+
async setTargetHeatingCoolingState(value) {
|
|
190
|
+
this.platform.log.debug('Set TargetHeatingCoolingState:', value);
|
|
191
|
+
let operationMode;
|
|
192
|
+
switch (value) {
|
|
193
|
+
case this.platform.Characteristic.TargetHeatingCoolingState.OFF:
|
|
194
|
+
operationMode = 'off';
|
|
195
|
+
break;
|
|
196
|
+
case this.platform.Characteristic.TargetHeatingCoolingState.HEAT:
|
|
197
|
+
operationMode = 'heat';
|
|
198
|
+
break;
|
|
199
|
+
case this.platform.Characteristic.TargetHeatingCoolingState.COOL:
|
|
200
|
+
operationMode = 'cool';
|
|
201
|
+
break;
|
|
202
|
+
case this.platform.Characteristic.TargetHeatingCoolingState.AUTO:
|
|
203
|
+
operationMode = 'auto';
|
|
204
|
+
break;
|
|
205
|
+
default:
|
|
206
|
+
this.platform.log.error('Unknown target heating cooling state:', value);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const success = await this.kumoAPI.sendCommand(this.deviceSerial, {
|
|
210
|
+
operationMode,
|
|
211
|
+
});
|
|
212
|
+
if (success) {
|
|
213
|
+
setTimeout(() => this.updateStatus(), 1000);
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
this.platform.log.error(`Failed to set target heating cooling state for ${this.accessory.displayName}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async getCurrentTemperature() {
|
|
220
|
+
if (!this.currentStatus) {
|
|
221
|
+
const status = await this.kumoAPI.getDeviceStatus(this.deviceSerial);
|
|
222
|
+
if (status) {
|
|
223
|
+
this.currentStatus = status;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
this.platform.log.warn('No status available for getCurrentTemperature');
|
|
227
|
+
return 20;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const temp = this.currentStatus.roomTemp;
|
|
231
|
+
if (temp === undefined || temp === null || isNaN(temp)) {
|
|
232
|
+
this.platform.log.warn('Invalid roomTemp value:', temp);
|
|
233
|
+
return 20;
|
|
234
|
+
}
|
|
235
|
+
this.platform.log.debug('Get CurrentTemperature:', temp);
|
|
236
|
+
return temp;
|
|
237
|
+
}
|
|
238
|
+
async getTargetTemperature() {
|
|
239
|
+
if (!this.currentStatus) {
|
|
240
|
+
const status = await this.kumoAPI.getDeviceStatus(this.deviceSerial);
|
|
241
|
+
if (status) {
|
|
242
|
+
this.currentStatus = status;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
this.platform.log.warn('No status available for getTargetTemperature');
|
|
246
|
+
return 20;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const temp = this.getTargetTempFromStatus(this.currentStatus);
|
|
250
|
+
if (temp === undefined || temp === null || isNaN(temp)) {
|
|
251
|
+
this.platform.log.warn('Invalid target temperature value:', temp);
|
|
252
|
+
return 20;
|
|
253
|
+
}
|
|
254
|
+
this.platform.log.debug('Get TargetTemperature:', temp);
|
|
255
|
+
return temp;
|
|
256
|
+
}
|
|
257
|
+
async setTargetTemperature(value) {
|
|
258
|
+
const temp = value;
|
|
259
|
+
this.platform.log.debug('Set TargetTemperature:', temp);
|
|
260
|
+
if (!this.currentStatus) {
|
|
261
|
+
this.platform.log.error('Cannot set temperature - no current status');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const roundedTemp = Math.round(temp * 2) / 2;
|
|
265
|
+
this.platform.log.debug(`Rounded temperature from ${temp} to ${roundedTemp}`);
|
|
266
|
+
const commands = {};
|
|
267
|
+
if (this.currentStatus.operationMode === 'heat') {
|
|
268
|
+
commands.spHeat = roundedTemp;
|
|
269
|
+
}
|
|
270
|
+
else if (this.currentStatus.operationMode === 'cool') {
|
|
271
|
+
commands.spCool = roundedTemp;
|
|
272
|
+
}
|
|
273
|
+
else if (this.currentStatus.operationMode === 'auto') {
|
|
274
|
+
commands.spHeat = roundedTemp;
|
|
275
|
+
commands.spCool = roundedTemp;
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
commands.spHeat = roundedTemp;
|
|
279
|
+
}
|
|
280
|
+
const success = await this.kumoAPI.sendCommand(this.deviceSerial, commands);
|
|
281
|
+
if (success) {
|
|
282
|
+
setTimeout(() => this.updateStatus(), 1000);
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
this.platform.log.error(`Failed to set target temperature for ${this.accessory.displayName}: ${JSON.stringify(commands)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
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
|
+
async getCurrentRelativeHumidity() {
|
|
295
|
+
var _a;
|
|
296
|
+
if (!this.currentStatus) {
|
|
297
|
+
const status = await this.kumoAPI.getDeviceStatus(this.deviceSerial);
|
|
298
|
+
if (status) {
|
|
299
|
+
this.currentStatus = status;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const humidity = ((_a = this.currentStatus) === null || _a === void 0 ? void 0 : _a.humidity) || 0;
|
|
303
|
+
this.platform.log.debug('Get CurrentRelativeHumidity:', humidity);
|
|
304
|
+
return humidity;
|
|
305
|
+
}
|
|
306
|
+
destroy() {
|
|
307
|
+
if (this.pollTimer) {
|
|
308
|
+
clearInterval(this.pollTimer);
|
|
309
|
+
this.pollTimer = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
exports.KumoThermostatAccessory = KumoThermostatAccessory;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Logger } from 'homebridge';
|
|
2
|
+
import { Site, Zone, DeviceStatus, Commands } from './settings';
|
|
3
|
+
export declare class KumoAPI {
|
|
4
|
+
private readonly username;
|
|
5
|
+
private readonly password;
|
|
6
|
+
private readonly log;
|
|
7
|
+
private accessToken;
|
|
8
|
+
private refreshToken;
|
|
9
|
+
private tokenExpiresAt;
|
|
10
|
+
private refreshTimer;
|
|
11
|
+
private siteEtags;
|
|
12
|
+
private debugMode;
|
|
13
|
+
constructor(username: string, password: string, log: Logger, debug?: boolean);
|
|
14
|
+
private maskToken;
|
|
15
|
+
login(): Promise<boolean>;
|
|
16
|
+
private scheduleTokenRefresh;
|
|
17
|
+
private refreshAccessToken;
|
|
18
|
+
private ensureAuthenticated;
|
|
19
|
+
private getAuthHeaders;
|
|
20
|
+
private makeAuthenticatedRequest;
|
|
21
|
+
getSites(): Promise<Site[]>;
|
|
22
|
+
getZones(siteId: string): Promise<Zone[]>;
|
|
23
|
+
getZonesWithETag(siteId: string): Promise<{
|
|
24
|
+
zones: Zone[];
|
|
25
|
+
notModified: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
getDeviceStatus(deviceSerial: string): Promise<DeviceStatus | null>;
|
|
28
|
+
sendCommand(deviceSerial: string, commands: Commands): Promise<boolean>;
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
package/dist/kumo-api.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.KumoAPI = void 0;
|
|
7
|
+
const node_fetch_1 = __importDefault(require("node-fetch"));
|
|
8
|
+
const settings_1 = require("./settings");
|
|
9
|
+
class KumoAPI {
|
|
10
|
+
constructor(username, password, log, debug = false) {
|
|
11
|
+
this.username = username;
|
|
12
|
+
this.password = password;
|
|
13
|
+
this.log = log;
|
|
14
|
+
this.accessToken = null;
|
|
15
|
+
this.refreshToken = null;
|
|
16
|
+
this.tokenExpiresAt = 0;
|
|
17
|
+
this.refreshTimer = null;
|
|
18
|
+
this.siteEtags = new Map();
|
|
19
|
+
this.debugMode = false;
|
|
20
|
+
this.debugMode = debug;
|
|
21
|
+
if (this.debugMode) {
|
|
22
|
+
this.log.info('Debug mode enabled');
|
|
23
|
+
this.log.warn('Debug mode may log sensitive information - use only for troubleshooting');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
maskToken(token) {
|
|
27
|
+
if (!token) {
|
|
28
|
+
return 'null';
|
|
29
|
+
}
|
|
30
|
+
if (token.length <= 8) {
|
|
31
|
+
return '***';
|
|
32
|
+
}
|
|
33
|
+
return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`;
|
|
34
|
+
}
|
|
35
|
+
async login() {
|
|
36
|
+
try {
|
|
37
|
+
this.log.debug('Attempting to login to Kumo Cloud API');
|
|
38
|
+
const response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}/login`, {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
'Accept': 'application/json',
|
|
43
|
+
'X-App-Version': settings_1.APP_VERSION,
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
username: this.username,
|
|
47
|
+
password: this.password,
|
|
48
|
+
appVersion: settings_1.APP_VERSION,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const errorText = await response.text();
|
|
53
|
+
this.log.error(`Login failed with status: ${response.status}`);
|
|
54
|
+
if (this.debugMode && errorText) {
|
|
55
|
+
this.log.debug(`Login error response: ${errorText}`);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const data = await response.json();
|
|
60
|
+
this.accessToken = data.token.access;
|
|
61
|
+
this.refreshToken = data.token.refresh;
|
|
62
|
+
this.tokenExpiresAt = Date.now() + settings_1.TOKEN_REFRESH_INTERVAL;
|
|
63
|
+
this.log.info('Successfully logged in to Kumo Cloud API');
|
|
64
|
+
this.scheduleTokenRefresh();
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error instanceof Error) {
|
|
69
|
+
this.log.error('Login error:', error.message);
|
|
70
|
+
if (this.debugMode) {
|
|
71
|
+
this.log.debug('Login error stack:', error.stack);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
this.log.error('Login error: Unknown error occurred');
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
scheduleTokenRefresh() {
|
|
81
|
+
if (this.refreshTimer) {
|
|
82
|
+
clearTimeout(this.refreshTimer);
|
|
83
|
+
}
|
|
84
|
+
const refreshIn = settings_1.TOKEN_REFRESH_INTERVAL - (5 * 60 * 1000);
|
|
85
|
+
this.refreshTimer = setTimeout(async () => {
|
|
86
|
+
this.log.debug('Refreshing access token');
|
|
87
|
+
await this.refreshAccessToken();
|
|
88
|
+
}, refreshIn);
|
|
89
|
+
}
|
|
90
|
+
async refreshAccessToken() {
|
|
91
|
+
if (!this.refreshToken) {
|
|
92
|
+
this.log.error('No refresh token available, need to login again');
|
|
93
|
+
return await this.login();
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
this.log.debug('Refreshing access token');
|
|
97
|
+
const response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}/refresh`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'Content-Type': 'application/json',
|
|
101
|
+
'Accept': 'application/json',
|
|
102
|
+
'Authorization': `Bearer ${this.refreshToken}`,
|
|
103
|
+
'X-App-Version': settings_1.APP_VERSION,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
if (!response.ok) {
|
|
107
|
+
this.log.warn('Token refresh failed, attempting full login');
|
|
108
|
+
return await this.login();
|
|
109
|
+
}
|
|
110
|
+
const data = await response.json();
|
|
111
|
+
this.accessToken = data.token.access;
|
|
112
|
+
this.refreshToken = data.token.refresh;
|
|
113
|
+
this.tokenExpiresAt = Date.now() + settings_1.TOKEN_REFRESH_INTERVAL;
|
|
114
|
+
this.log.debug('Access token refreshed successfully');
|
|
115
|
+
this.scheduleTokenRefresh();
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (error instanceof Error) {
|
|
120
|
+
this.log.error('Token refresh error:', error.message);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
this.log.error('Token refresh error: Unknown error occurred');
|
|
124
|
+
}
|
|
125
|
+
return await this.login();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async ensureAuthenticated() {
|
|
129
|
+
if (!this.accessToken || Date.now() >= this.tokenExpiresAt - (5 * 60 * 1000)) {
|
|
130
|
+
if (!this.refreshToken) {
|
|
131
|
+
return await this.login();
|
|
132
|
+
}
|
|
133
|
+
return await this.refreshAccessToken();
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
getAuthHeaders() {
|
|
138
|
+
return {
|
|
139
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
140
|
+
'Content-Type': 'application/json',
|
|
141
|
+
'Accept': 'application/json',
|
|
142
|
+
'X-App-Version': settings_1.APP_VERSION,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
async makeAuthenticatedRequest(endpoint, method = 'GET', body) {
|
|
146
|
+
const authenticated = await this.ensureAuthenticated();
|
|
147
|
+
if (!authenticated) {
|
|
148
|
+
this.log.error('Failed to authenticate');
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const options = {
|
|
153
|
+
method,
|
|
154
|
+
headers: this.getAuthHeaders(),
|
|
155
|
+
};
|
|
156
|
+
if (body) {
|
|
157
|
+
options.body = JSON.stringify(body);
|
|
158
|
+
}
|
|
159
|
+
const response = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}${endpoint}`, options);
|
|
160
|
+
if (response.status === 401) {
|
|
161
|
+
this.log.debug('Received 401, refreshing token and retrying');
|
|
162
|
+
const refreshed = await this.refreshAccessToken();
|
|
163
|
+
if (!refreshed) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
options.headers = this.getAuthHeaders();
|
|
167
|
+
const retryResponse = await (0, node_fetch_1.default)(`${settings_1.API_BASE_URL}${endpoint}`, options);
|
|
168
|
+
if (!retryResponse.ok) {
|
|
169
|
+
this.log.error(`Request failed after retry: ${retryResponse.status}`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
return await retryResponse.json();
|
|
173
|
+
}
|
|
174
|
+
if (!response.ok) {
|
|
175
|
+
this.log.error(`Request failed with status: ${response.status}`);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return await response.json();
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error instanceof Error) {
|
|
182
|
+
this.log.error('Request error:', error.message);
|
|
183
|
+
if (this.debugMode) {
|
|
184
|
+
this.log.debug('Full error stack:', error.stack);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
this.log.error('Request error: Unknown error occurred');
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
async getSites() {
|
|
194
|
+
this.log.debug('Fetching sites');
|
|
195
|
+
const sites = await this.makeAuthenticatedRequest('/sites');
|
|
196
|
+
return sites || [];
|
|
197
|
+
}
|
|
198
|
+
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) {
|
|
204
|
+
const etag = this.siteEtags.get(siteId);
|
|
205
|
+
const authenticated = await this.ensureAuthenticated();
|
|
206
|
+
if (!authenticated) {
|
|
207
|
+
this.log.error('Failed to authenticate');
|
|
208
|
+
return { zones: [], notModified: false };
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
const headers = this.getAuthHeaders();
|
|
212
|
+
if (etag) {
|
|
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 };
|
|
219
|
+
}
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const errorBody = await response.text();
|
|
222
|
+
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);
|
|
228
|
+
}
|
|
229
|
+
const zones = await response.json();
|
|
230
|
+
if (this.debugMode) {
|
|
231
|
+
this.log.debug(`Fetched ${zones.length} zones for site ${siteId}`);
|
|
232
|
+
zones.forEach(zone => {
|
|
233
|
+
this.log.debug(`Zone ${zone.name} (${zone.adapter.deviceSerial}): roomTemp=${zone.adapter.roomTemp}`);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return { zones, notModified: false };
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (error instanceof Error) {
|
|
240
|
+
this.log.error('Error fetching zones with ETag:', error.message);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
this.log.error('Error fetching zones: Unknown error occurred');
|
|
244
|
+
}
|
|
245
|
+
return { zones: [], notModified: false };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async getDeviceStatus(deviceSerial) {
|
|
249
|
+
this.log.debug(`Fetching status for device: ${deviceSerial}`);
|
|
250
|
+
return await this.makeAuthenticatedRequest(`/devices/${deviceSerial}/status`);
|
|
251
|
+
}
|
|
252
|
+
async sendCommand(deviceSerial, commands) {
|
|
253
|
+
this.log.debug(`Sending command to device ${deviceSerial}:`, JSON.stringify(commands));
|
|
254
|
+
const request = {
|
|
255
|
+
deviceSerial,
|
|
256
|
+
commands,
|
|
257
|
+
};
|
|
258
|
+
const response = await this.makeAuthenticatedRequest('/devices/send-command', 'POST', request);
|
|
259
|
+
if (!response) {
|
|
260
|
+
this.log.error(`Send command failed: no response from API for device ${deviceSerial}`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
if (!response.devices || !Array.isArray(response.devices)) {
|
|
264
|
+
this.log.error(`Send command failed: unexpected response format for device ${deviceSerial}`);
|
|
265
|
+
if (this.debugMode) {
|
|
266
|
+
this.log.debug(`Response:`, JSON.stringify(response));
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
if (!response.devices.includes(deviceSerial)) {
|
|
271
|
+
this.log.error(`Send command failed: device ${deviceSerial} not in response devices list`);
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
this.log.debug(`Command sent successfully to device ${deviceSerial}`);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
destroy() {
|
|
278
|
+
if (this.refreshTimer) {
|
|
279
|
+
clearTimeout(this.refreshTimer);
|
|
280
|
+
this.refreshTimer = null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
exports.KumoAPI = KumoAPI;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic } from 'homebridge';
|
|
2
|
+
export declare class KumoV3Platform implements DynamicPlatformPlugin {
|
|
3
|
+
readonly log: Logger;
|
|
4
|
+
readonly config: PlatformConfig;
|
|
5
|
+
readonly api: API;
|
|
6
|
+
readonly Service: typeof Service;
|
|
7
|
+
readonly Characteristic: typeof Characteristic;
|
|
8
|
+
readonly accessories: PlatformAccessory[];
|
|
9
|
+
private readonly accessoryHandlers;
|
|
10
|
+
private readonly kumoAPI;
|
|
11
|
+
private readonly kumoConfig;
|
|
12
|
+
constructor(log: Logger, config: PlatformConfig, api: API);
|
|
13
|
+
private cleanup;
|
|
14
|
+
configureAccessory(accessory: PlatformAccessory): void;
|
|
15
|
+
discoverDevices(): Promise<void>;
|
|
16
|
+
}
|
package/dist/platform.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KumoV3Platform = void 0;
|
|
4
|
+
const settings_1 = require("./settings");
|
|
5
|
+
const kumo_api_1 = require("./kumo-api");
|
|
6
|
+
const accessory_1 = require("./accessory");
|
|
7
|
+
class KumoV3Platform {
|
|
8
|
+
constructor(log, config, api) {
|
|
9
|
+
this.log = log;
|
|
10
|
+
this.config = config;
|
|
11
|
+
this.api = api;
|
|
12
|
+
this.Service = this.api.hap.Service;
|
|
13
|
+
this.Characteristic = this.api.hap.Characteristic;
|
|
14
|
+
this.accessories = [];
|
|
15
|
+
this.accessoryHandlers = [];
|
|
16
|
+
this.kumoConfig = config;
|
|
17
|
+
this.log.debug('Initializing platform:', this.config.name);
|
|
18
|
+
const kumoConfig = this.kumoConfig;
|
|
19
|
+
if (!kumoConfig.username || !kumoConfig.password) {
|
|
20
|
+
this.log.error('Username and password are required in config');
|
|
21
|
+
throw new Error('Missing required configuration');
|
|
22
|
+
}
|
|
23
|
+
if (typeof kumoConfig.username !== 'string' || !kumoConfig.username.includes('@')) {
|
|
24
|
+
this.log.error('Username must be a valid email address');
|
|
25
|
+
throw new Error('Invalid username format');
|
|
26
|
+
}
|
|
27
|
+
if (typeof kumoConfig.password !== 'string' || kumoConfig.password.trim().length === 0) {
|
|
28
|
+
this.log.error('Password must be a non-empty string');
|
|
29
|
+
throw new Error('Invalid password format');
|
|
30
|
+
}
|
|
31
|
+
if (kumoConfig.pollInterval !== undefined) {
|
|
32
|
+
if (typeof kumoConfig.pollInterval !== 'number' || kumoConfig.pollInterval < 5) {
|
|
33
|
+
this.log.error('Poll interval must be a number >= 5 seconds');
|
|
34
|
+
throw new Error('Invalid poll interval');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
this.kumoAPI = new kumo_api_1.KumoAPI(kumoConfig.username, kumoConfig.password, this.log, kumoConfig.debug || false);
|
|
38
|
+
this.api.on('didFinishLaunching', () => {
|
|
39
|
+
log.debug('Executed didFinishLaunching callback');
|
|
40
|
+
this.discoverDevices();
|
|
41
|
+
});
|
|
42
|
+
this.api.on('shutdown', () => {
|
|
43
|
+
log.debug('Shutting down platform');
|
|
44
|
+
this.cleanup();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
cleanup() {
|
|
48
|
+
for (const handler of this.accessoryHandlers) {
|
|
49
|
+
handler.destroy();
|
|
50
|
+
}
|
|
51
|
+
this.accessoryHandlers.length = 0;
|
|
52
|
+
this.kumoAPI.destroy();
|
|
53
|
+
}
|
|
54
|
+
configureAccessory(accessory) {
|
|
55
|
+
this.log.info('Loading accessory from cache:', accessory.displayName);
|
|
56
|
+
this.accessories.push(accessory);
|
|
57
|
+
}
|
|
58
|
+
async discoverDevices() {
|
|
59
|
+
var _a;
|
|
60
|
+
try {
|
|
61
|
+
this.log.info('Starting device discovery');
|
|
62
|
+
const loginSuccess = await this.kumoAPI.login();
|
|
63
|
+
if (!loginSuccess) {
|
|
64
|
+
this.log.error('Failed to login to Kumo Cloud API');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const sites = await this.kumoAPI.getSites();
|
|
68
|
+
if (sites.length === 0) {
|
|
69
|
+
this.log.warn('No sites found');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this.log.info(`Found ${sites.length} site(s)`);
|
|
73
|
+
const discoveredDevices = [];
|
|
74
|
+
for (const site of sites) {
|
|
75
|
+
this.log.debug(`Fetching zones for site: ${site.name}`);
|
|
76
|
+
const zones = await this.kumoAPI.getZones(site.id);
|
|
77
|
+
for (const zone of zones) {
|
|
78
|
+
if (!zone.isActive) {
|
|
79
|
+
this.log.debug(`Skipping inactive zone: ${zone.name}`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const deviceSerial = zone.adapter.deviceSerial;
|
|
83
|
+
const displayName = zone.name;
|
|
84
|
+
if ((_a = this.kumoConfig.excludeDevices) === null || _a === void 0 ? void 0 : _a.includes(deviceSerial)) {
|
|
85
|
+
this.log.info(`Skipping excluded device: ${displayName} (${deviceSerial})`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const uuid = this.api.hap.uuid.generate(deviceSerial);
|
|
89
|
+
discoveredDevices.push({
|
|
90
|
+
uuid,
|
|
91
|
+
displayName,
|
|
92
|
+
deviceSerial,
|
|
93
|
+
zoneName: zone.name,
|
|
94
|
+
});
|
|
95
|
+
this.log.info(`Discovered device: ${displayName} (${deviceSerial})`);
|
|
96
|
+
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
|
|
97
|
+
if (existingAccessory) {
|
|
98
|
+
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
|
|
99
|
+
existingAccessory.context.device = {
|
|
100
|
+
deviceSerial,
|
|
101
|
+
zoneName: zone.name,
|
|
102
|
+
displayName,
|
|
103
|
+
siteId: site.id,
|
|
104
|
+
};
|
|
105
|
+
const handler = new accessory_1.KumoThermostatAccessory(this, existingAccessory, this.kumoAPI, this.kumoConfig.pollInterval);
|
|
106
|
+
this.accessoryHandlers.push(handler);
|
|
107
|
+
this.api.updatePlatformAccessories([existingAccessory]);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
this.log.info('Adding new accessory:', displayName);
|
|
111
|
+
const accessory = new this.api.platformAccessory(displayName, uuid);
|
|
112
|
+
accessory.context.device = {
|
|
113
|
+
deviceSerial,
|
|
114
|
+
zoneName: zone.name,
|
|
115
|
+
displayName,
|
|
116
|
+
siteId: site.id,
|
|
117
|
+
};
|
|
118
|
+
const handler = new accessory_1.KumoThermostatAccessory(this, accessory, this.kumoAPI, this.kumoConfig.pollInterval);
|
|
119
|
+
this.accessoryHandlers.push(handler);
|
|
120
|
+
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
|
|
121
|
+
this.accessories.push(accessory);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const staleAccessories = this.accessories.filter(accessory => !discoveredDevices.find(device => device.uuid === accessory.UUID));
|
|
126
|
+
if (staleAccessories.length > 0) {
|
|
127
|
+
this.log.info(`Removing ${staleAccessories.length} stale accessory(ies)`);
|
|
128
|
+
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, staleAccessories);
|
|
129
|
+
}
|
|
130
|
+
this.log.info('Device discovery completed');
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
this.log.error('Error during device discovery:', error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
exports.KumoV3Platform = KumoV3Platform;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export declare const PLATFORM_NAME = "KumoV3";
|
|
2
|
+
export declare const PLUGIN_NAME = "homebridge-mitsubishi-comfort";
|
|
3
|
+
export declare const API_BASE_URL = "https://app-prod.kumocloud.com/v3";
|
|
4
|
+
export declare const TOKEN_REFRESH_INTERVAL: number;
|
|
5
|
+
export declare const POLL_INTERVAL: number;
|
|
6
|
+
export declare const APP_VERSION = "3.2.3";
|
|
7
|
+
export interface KumoConfig {
|
|
8
|
+
platform: string;
|
|
9
|
+
name?: string;
|
|
10
|
+
username: string;
|
|
11
|
+
password: string;
|
|
12
|
+
pollInterval?: number;
|
|
13
|
+
debug?: boolean;
|
|
14
|
+
excludeDevices?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface LoginResponse {
|
|
17
|
+
id: string;
|
|
18
|
+
username: string;
|
|
19
|
+
email: string;
|
|
20
|
+
token: {
|
|
21
|
+
access: string;
|
|
22
|
+
refresh: string;
|
|
23
|
+
};
|
|
24
|
+
preferences?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface Site {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
}
|
|
30
|
+
export interface Zone {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
isActive: boolean;
|
|
34
|
+
adapter: Adapter;
|
|
35
|
+
}
|
|
36
|
+
export interface Adapter {
|
|
37
|
+
id: string;
|
|
38
|
+
deviceSerial: string;
|
|
39
|
+
roomTemp: number;
|
|
40
|
+
spHeat: number;
|
|
41
|
+
spCool: number;
|
|
42
|
+
spAuto: number | null;
|
|
43
|
+
humidity: number | null;
|
|
44
|
+
power: number;
|
|
45
|
+
operationMode: string;
|
|
46
|
+
previousOperationMode: string;
|
|
47
|
+
fanSpeed: string;
|
|
48
|
+
airDirection: string;
|
|
49
|
+
connected: boolean;
|
|
50
|
+
isSimulator: boolean;
|
|
51
|
+
hasSensor: boolean;
|
|
52
|
+
hasMhk2: boolean;
|
|
53
|
+
scheduleOwner: string;
|
|
54
|
+
scheduleHoldEndTime: number;
|
|
55
|
+
rssi?: number;
|
|
56
|
+
}
|
|
57
|
+
export interface DeviceStatus {
|
|
58
|
+
id: string;
|
|
59
|
+
deviceSerial: string;
|
|
60
|
+
rssi: number;
|
|
61
|
+
power: number;
|
|
62
|
+
operationMode: string;
|
|
63
|
+
humidity: number | null;
|
|
64
|
+
fanSpeed: string;
|
|
65
|
+
airDirection: string;
|
|
66
|
+
roomTemp: number;
|
|
67
|
+
spCool: number;
|
|
68
|
+
spHeat: number;
|
|
69
|
+
spAuto: number | null;
|
|
70
|
+
}
|
|
71
|
+
export interface Commands {
|
|
72
|
+
spHeat?: number;
|
|
73
|
+
spCool?: number;
|
|
74
|
+
operationMode?: 'off' | 'heat' | 'cool' | 'auto';
|
|
75
|
+
fanSpeed?: 'auto' | 'low' | 'medium' | 'high';
|
|
76
|
+
}
|
|
77
|
+
export interface SendCommandRequest {
|
|
78
|
+
deviceSerial: string;
|
|
79
|
+
commands: Commands;
|
|
80
|
+
}
|
|
81
|
+
export interface SendCommandResponse {
|
|
82
|
+
devices: string[];
|
|
83
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
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;
|
|
4
|
+
exports.PLATFORM_NAME = 'KumoV3';
|
|
5
|
+
exports.PLUGIN_NAME = 'homebridge-mitsubishi-comfort';
|
|
6
|
+
exports.API_BASE_URL = 'https://app-prod.kumocloud.com/v3';
|
|
7
|
+
exports.TOKEN_REFRESH_INTERVAL = 15 * 60 * 1000;
|
|
8
|
+
exports.POLL_INTERVAL = 30 * 1000;
|
|
9
|
+
exports.APP_VERSION = '3.2.3';
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-mitsubishi-comfort",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge plugin for Mitsubishi heat pumps using Kumo Cloud v3 API",
|
|
5
|
+
"author": "burtherman",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"config.schema.json"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "rimraf ./dist && tsc",
|
|
13
|
+
"watch": "npm run build && npm link && nodemon",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"homebridge-plugin",
|
|
19
|
+
"mitsubishi",
|
|
20
|
+
"kumo",
|
|
21
|
+
"heat-pump"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.0.0",
|
|
25
|
+
"homebridge": "^1.6.0 || ^2.0.0"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/burtherman/homebridge-mitsubishi-comfort.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/burtherman/homebridge-mitsubishi-comfort/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/burtherman/homebridge-mitsubishi-comfort#readme",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"node-fetch": "^3.2.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^15.12.3",
|
|
40
|
+
"homebridge": "^1.3.1",
|
|
41
|
+
"nodemon": "^2.0.7",
|
|
42
|
+
"rimraf": "^3.0.2",
|
|
43
|
+
"typescript": "^4.2.2"
|
|
44
|
+
}
|
|
45
|
+
}
|