homebridge-bestway 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 +85 -0
- package/config.schema.json +99 -0
- package/index.js +7 -0
- package/lib/accessory.js +274 -0
- package/lib/api.js +155 -0
- package/lib/constants.js +19 -0
- package/lib/platform.js +61 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/homebridge/homebridge"><img src="https://raw.githubusercontent.com/homebridge/branding/master/logos/homebridge-wordmark-logo-vertical.png" height="150"></a>
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# homebridge-bestway
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/homebridge-bestway)
|
|
8
|
+
[](https://www.npmjs.com/package/homebridge-bestway)
|
|
9
|
+
[](https://github.com/homebridge/homebridge/wiki/Verified-Plugins)
|
|
10
|
+
|
|
11
|
+
A Homebridge plugin for controlling **Bestway** and **Lay-Z-Spa** Wi-Fi enabled hot tubs.
|
|
12
|
+
|
|
13
|
+
This plugin allows you to expose your spa to Apple HomeKit, giving you control over the heater, filter, and massage bubbles.
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
* **Thermostat Control**: Set target temperature and toggle heating.
|
|
18
|
+
* **Filter Control**: Turn the water filter pump on or off (exposed as a Switch).
|
|
19
|
+
* **Bubbles Control**: Control the massage system (exposed as a Fan/Speed Control for spas that support variable speeds, or a simple Switch for others).
|
|
20
|
+
* **Temperature Units**: Supports both Celsius and Fahrenheit (based on your region settings).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
### Graphical Install (Recommended)
|
|
25
|
+
|
|
26
|
+
1. Open the Homebridge UI.
|
|
27
|
+
2. Navigate to the **Plugins** tab.
|
|
28
|
+
3. Search for `Bestway` or `homebridge-bestway`.
|
|
29
|
+
4. Click **Install**.
|
|
30
|
+
|
|
31
|
+
### Manual Install
|
|
32
|
+
|
|
33
|
+
If you prefer the command line:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g homebridge-bestway
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configuration
|
|
40
|
+
|
|
41
|
+
This plugin comes with a schema definition for the Homebridge UI.
|
|
42
|
+
|
|
43
|
+
1. In the Homebridge UI, go to the **Plugins** tab.
|
|
44
|
+
2. Find `homebridge-bestway`.
|
|
45
|
+
3. Click **Settings**.
|
|
46
|
+
4. Enter your Bestway account email and password.
|
|
47
|
+
5. Select your Region (EU or US).
|
|
48
|
+
6. Save and restart Homebridge.
|
|
49
|
+
|
|
50
|
+
### Configuration Fields (Manual)
|
|
51
|
+
|
|
52
|
+
If you are editing `config.json` manually, here are the available fields:
|
|
53
|
+
* `email` (Required): Your Bestway account email address.
|
|
54
|
+
* `password` (Required): Your Bestway account password.
|
|
55
|
+
* `region` (Optional): The region for your account (`EU` or `US`). Defaults to `EU`.
|
|
56
|
+
* `minTempC` / `maxTempC` (Optional): Override the logic minimum/maximum temperature in Celsius (for EU users).
|
|
57
|
+
* `minTempF` / `maxTempF` (Optional): Override the logic minimum/maximum temperature in Fahrenheit (for US users).
|
|
58
|
+
|
|
59
|
+
### Example `config.json`
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"platforms": [
|
|
64
|
+
{
|
|
65
|
+
"platform": "Bestway",
|
|
66
|
+
"email": "your-email@example.com",
|
|
67
|
+
"password": "your-password",
|
|
68
|
+
"region": "EU"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Temperature Units & Region
|
|
75
|
+
|
|
76
|
+
The plugin attempts to handle temperature conversions automatically:
|
|
77
|
+
|
|
78
|
+
* **EU Region**: Input and output generally expected in Celsius.
|
|
79
|
+
* **US Region**: Input and output generally expected in Fahrenheit.
|
|
80
|
+
|
|
81
|
+
HomeKit natively uses Celsius, so the plugin performs necessary conversions behind the scenes if you are in a US region using Fahrenheit.
|
|
82
|
+
|
|
83
|
+
## Disclaimer
|
|
84
|
+
|
|
85
|
+
This plugin is a volunteer effort and is **not** officially affiliated with, endorsed by, or supported by Bestway or Lay-Z-Spa.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "Bestway",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"email": {
|
|
9
|
+
"title": "Email",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"required": true,
|
|
12
|
+
"format": "email"
|
|
13
|
+
},
|
|
14
|
+
"password": {
|
|
15
|
+
"title": "Password",
|
|
16
|
+
"type": "string",
|
|
17
|
+
"required": true,
|
|
18
|
+
"format": "password"
|
|
19
|
+
},
|
|
20
|
+
"region": {
|
|
21
|
+
"title": "Region",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"default": "EU",
|
|
24
|
+
"enum": [
|
|
25
|
+
"EU",
|
|
26
|
+
"US"
|
|
27
|
+
],
|
|
28
|
+
"description": "Select your Bestway account region (EU or US)."
|
|
29
|
+
},
|
|
30
|
+
"minTempC": {
|
|
31
|
+
"title": "Minimum Temperature (°C)",
|
|
32
|
+
"type": "integer",
|
|
33
|
+
"default": 38,
|
|
34
|
+
"minimum": 37,
|
|
35
|
+
"maximum": 40,
|
|
36
|
+
"description": "Logic minimum temperature (default 20°C)."
|
|
37
|
+
},
|
|
38
|
+
"maxTempC": {
|
|
39
|
+
"title": "Maximum Temperature (°C)",
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"default": 40,
|
|
42
|
+
"minimum": 39,
|
|
43
|
+
"maximum": 41,
|
|
44
|
+
"description": "Logic maximum temperature (default 40°C)."
|
|
45
|
+
},
|
|
46
|
+
"minTempF": {
|
|
47
|
+
"title": "Minimum Temperature (°F)",
|
|
48
|
+
"type": "integer",
|
|
49
|
+
"default": 100,
|
|
50
|
+
"minimum": 98,
|
|
51
|
+
"maximum": 104,
|
|
52
|
+
"description": "Logic minimum temperature (default 68°F)."
|
|
53
|
+
},
|
|
54
|
+
"maxTempF": {
|
|
55
|
+
"title": "Maximum Temperature (°F)",
|
|
56
|
+
"type": "integer",
|
|
57
|
+
"default": 104,
|
|
58
|
+
"minimum": 98,
|
|
59
|
+
"maximum": 106,
|
|
60
|
+
"description": "Logic maximum temperature (default 104°F)."
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"layout": [
|
|
65
|
+
{
|
|
66
|
+
"type": "flex",
|
|
67
|
+
"flex-flow": "row wrap",
|
|
68
|
+
"items": [
|
|
69
|
+
"email",
|
|
70
|
+
"password"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"region",
|
|
74
|
+
{
|
|
75
|
+
"key": "minTempC",
|
|
76
|
+
"condition": {
|
|
77
|
+
"functionBody": "return model.region !== 'US';"
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"key": "maxTempC",
|
|
82
|
+
"condition": {
|
|
83
|
+
"functionBody": "return model.region !== 'US';"
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"key": "minTempF",
|
|
88
|
+
"condition": {
|
|
89
|
+
"functionBody": "return model.region === 'US';"
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"key": "maxTempF",
|
|
94
|
+
"condition": {
|
|
95
|
+
"functionBody": "return model.region === 'US';"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
const { BestwayPlatform } = require('./lib/platform');
|
|
2
|
+
const { PLATFORM_NAME, PLUGIN_NAME } = require('./lib/constants');
|
|
3
|
+
|
|
4
|
+
module.exports = (api) => {
|
|
5
|
+
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, BestwayPlatform);
|
|
6
|
+
console.log('Homebridge-Bestway: Platform registered.');
|
|
7
|
+
};
|
package/lib/accessory.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
const { DeviceTypes } = require('./constants');
|
|
2
|
+
|
|
3
|
+
class BestwayAccessory {
|
|
4
|
+
constructor(platform, accessory, device) {
|
|
5
|
+
this.platform = platform;
|
|
6
|
+
this.accessory = accessory;
|
|
7
|
+
this.device = device;
|
|
8
|
+
this.client = platform.client;
|
|
9
|
+
this.log = platform.log;
|
|
10
|
+
|
|
11
|
+
this.Service = platform.api.hap.Service;
|
|
12
|
+
this.Characteristic = platform.api.hap.Characteristic;
|
|
13
|
+
|
|
14
|
+
// device info
|
|
15
|
+
this.accessory.getService(this.Service.AccessoryInformation)
|
|
16
|
+
.setCharacteristic(this.Characteristic.Manufacturer, 'Bestway')
|
|
17
|
+
.setCharacteristic(this.Characteristic.Model, device.product_name || 'Spa')
|
|
18
|
+
.setCharacteristic(this.Characteristic.SerialNumber, device.did);
|
|
19
|
+
|
|
20
|
+
// Setup services based on device type
|
|
21
|
+
this.setupThermostat();
|
|
22
|
+
this.setupFilter();
|
|
23
|
+
|
|
24
|
+
// Bubble setup differs by model
|
|
25
|
+
if ([DeviceTypes.AIRJET_V01_SPA, DeviceTypes.HYDROJET_SPA, DeviceTypes.HYDROJET_PRO_SPA].includes(device.type)) {
|
|
26
|
+
this.setupBubblesFan();
|
|
27
|
+
} else {
|
|
28
|
+
this.setupBubblesSwitch();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Start polling
|
|
32
|
+
this.poll();
|
|
33
|
+
setInterval(() => this.poll(), 30000); // Poll every 30s
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async poll() {
|
|
37
|
+
try {
|
|
38
|
+
const attrs = await this.client.getDeviceState(this.device.did);
|
|
39
|
+
this.updateState(attrs);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
this.log.debug('Poll failed:', error.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
updateState(attrs) {
|
|
46
|
+
if (!attrs) return;
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
// Thermostat
|
|
51
|
+
const tempNow = parseInt(attrs.temp_now || attrs.Tnow || 0);
|
|
52
|
+
const tempSet = parseInt(attrs.temp_set || attrs.Tset || 0);
|
|
53
|
+
// Heat power: 0=Off, 1=On (Airjet), 3=On (Hydrojet)
|
|
54
|
+
const heatPower = attrs.heat_power || attrs.heat || 0;
|
|
55
|
+
const isHeating = heatPower > 0;
|
|
56
|
+
|
|
57
|
+
const thermostatService = this.accessory.getService(this.Service.Thermostat);
|
|
58
|
+
if (thermostatService) {
|
|
59
|
+
// The Gizwitz API returns `Tunit`: 0=Fahrenheit, 1=Celsius
|
|
60
|
+
// THe HomeKit API requires Celsius
|
|
61
|
+
const isFahrenheit = (attrs.Tunit === 0 || attrs.temp_set_unit === '华氏');
|
|
62
|
+
|
|
63
|
+
const toCelsius = (val) => {
|
|
64
|
+
const c = isFahrenheit ? (val - 32) * 5 / 9 : val;
|
|
65
|
+
return Math.round(c * 10) / 10;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const currentC = toCelsius(tempNow);
|
|
69
|
+
const targetC = toCelsius(tempSet);
|
|
70
|
+
|
|
71
|
+
if (tempNow !== 0) {
|
|
72
|
+
thermostatService.updateCharacteristic(this.Characteristic.CurrentTemperature, currentC);
|
|
73
|
+
}
|
|
74
|
+
thermostatService.updateCharacteristic(this.Characteristic.TargetTemperature, targetC);
|
|
75
|
+
thermostatService.updateCharacteristic(this.Characteristic.CurrentHeatingCoolingState, isHeating ? 1 : 0);
|
|
76
|
+
thermostatService.updateCharacteristic(this.Characteristic.TargetHeatingCoolingState, isHeating ? 1 : 0);
|
|
77
|
+
|
|
78
|
+
// Update display units
|
|
79
|
+
thermostatService.updateCharacteristic(this.Characteristic.TemperatureDisplayUnits, isFahrenheit ? 1 : 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Filter (exposed as a switch)
|
|
83
|
+
const filterPower = attrs.filter_power || attrs.filter || 0;
|
|
84
|
+
const filterService = this.accessory.getService('Filter');
|
|
85
|
+
if (filterService) {
|
|
86
|
+
filterService.updateCharacteristic(this.Characteristic.On, !!filterPower);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
// Bubbles
|
|
91
|
+
// Fan Service (Multi-speed bubbles)
|
|
92
|
+
const wavePower = attrs.wave_power || attrs.wave || 0;
|
|
93
|
+
const fanService = this.accessory.getService(this.Service.Fan);
|
|
94
|
+
if (fanService) {
|
|
95
|
+
fanService.updateCharacteristic(this.Characteristic.On, wavePower > 0);
|
|
96
|
+
|
|
97
|
+
// Bestway API: 50/51=Medium, 100=High
|
|
98
|
+
let speed = 0;
|
|
99
|
+
if (wavePower >= 100) speed = 100;
|
|
100
|
+
else if (wavePower >= 40) speed = 50;
|
|
101
|
+
|
|
102
|
+
fanService.updateCharacteristic(this.Characteristic.RotationSpeed, speed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Switch Service (Single-speed Fallback bubbles)
|
|
106
|
+
const bubbleSwitch = this.accessory.getService('Bubbles');
|
|
107
|
+
if (bubbleSwitch) {
|
|
108
|
+
bubbleSwitch.updateCharacteristic(this.Characteristic.On, !!wavePower);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
setupThermostat() {
|
|
113
|
+
const service = this.accessory.getService(this.Service.Thermostat) || this.accessory.addService(this.Service.Thermostat);
|
|
114
|
+
|
|
115
|
+
// Set props for CurrentTemperature to ensure it accepts our range
|
|
116
|
+
service.getCharacteristic(this.Characteristic.CurrentTemperature)
|
|
117
|
+
.setProps({
|
|
118
|
+
minValue: -30,
|
|
119
|
+
maxValue: 100,
|
|
120
|
+
minStep: 0.1
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Ensure TargetHeatingCoolingState is configured correctly
|
|
124
|
+
|
|
125
|
+
service.getCharacteristic(this.Characteristic.TargetHeatingCoolingState)
|
|
126
|
+
.setProps({
|
|
127
|
+
validValues: [
|
|
128
|
+
0, // OFF
|
|
129
|
+
1 // HEAT
|
|
130
|
+
],
|
|
131
|
+
maxValue: 1,
|
|
132
|
+
minValue: 0
|
|
133
|
+
|
|
134
|
+
})
|
|
135
|
+
.onSet(async (value) => {
|
|
136
|
+
// Hardware supports Off (0) or Heat (1)
|
|
137
|
+
const shouldHeat = value !== 0; // 0=OFF, 1=HEAT
|
|
138
|
+
this.log.debug(`[Thermostat] Setting TargetHeatingCoolingState to ${value} (Heat: ${shouldHeat})`);
|
|
139
|
+
if ([DeviceTypes.HYDROJET_SPA, DeviceTypes.HYDROJET_PRO_SPA, DeviceTypes.AIRJET_V01_SPA].includes(this.device.type)) {
|
|
140
|
+
// Hydrojet/V01: heat=3 (On), heat=0 (Off)
|
|
141
|
+
await this.client.sendCommand(this.device.did, { heat: shouldHeat ? 3 : 0 });
|
|
142
|
+
} else {
|
|
143
|
+
// Airjet: heat_power=1 (On), heat_power=0 (Off)
|
|
144
|
+
await this.client.sendCommand(this.device.did, { heat_power: shouldHeat ? 1 : 0 });
|
|
145
|
+
}
|
|
146
|
+
// Specific update required for optimistic state
|
|
147
|
+
this.poll();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// We need to figure out what the min/max temps are for this spa.
|
|
151
|
+
// HomeKit always needs Celsius internally, so if the user set their region to 'US' (implying F input),
|
|
152
|
+
// we need to convert those values before passing them along.
|
|
153
|
+
let minTemp, maxTemp;
|
|
154
|
+
const isUsRegion = this.platform.config.region === 'US';
|
|
155
|
+
|
|
156
|
+
if (isUsRegion) {
|
|
157
|
+
// Config is in F, must convert to C for HomeKit props
|
|
158
|
+
const minF = this.platform.config.minTempF || 68;
|
|
159
|
+
const maxF = this.platform.config.maxTempF || 104;
|
|
160
|
+
minTemp = Math.round((minF - 32) * 5 / 9);
|
|
161
|
+
maxTemp = Math.round((maxF - 32) * 5 / 9);
|
|
162
|
+
} else {
|
|
163
|
+
// Config is in C
|
|
164
|
+
minTemp = this.platform.config.minTempC || 20;
|
|
165
|
+
maxTemp = this.platform.config.maxTempC || 40;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
this.log.info(`[Thermostat Debug] Calculated minTemp: ${minTemp}, maxTemp: ${maxTemp}`);
|
|
169
|
+
|
|
170
|
+
// Fallback/Legacy check
|
|
171
|
+
if (!this.platform.config.minTempC && !this.platform.config.minTempF && this.platform.config.minTemp) {
|
|
172
|
+
minTemp = this.platform.config.minTemp;
|
|
173
|
+
}
|
|
174
|
+
if (!this.platform.config.maxTempC && !this.platform.config.maxTempF && this.platform.config.maxTemp) {
|
|
175
|
+
maxTemp = this.platform.config.maxTemp;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
service.getCharacteristic(this.Characteristic.TargetTemperature)
|
|
179
|
+
.setProps({
|
|
180
|
+
minValue: minTemp,
|
|
181
|
+
maxValue: maxTemp,
|
|
182
|
+
minStep: 0.5
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Enforce initial value is within bounds
|
|
186
|
+
const currentTarget = service.getCharacteristic(this.Characteristic.TargetTemperature).value;
|
|
187
|
+
if (currentTarget < minTemp) {
|
|
188
|
+
this.log.warn(`[Thermostat Debug] Current TargetTemp ${currentTarget} is below min ${minTemp}. Updating to ${minTemp}.`);
|
|
189
|
+
service.updateCharacteristic(this.Characteristic.TargetTemperature, minTemp);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
service.getCharacteristic(this.Characteristic.TargetTemperature)
|
|
193
|
+
.onSet(async (value) => {
|
|
194
|
+
// Convert HomeKit Celsius to device unit if necessary
|
|
195
|
+
const currentAttrs = this.platform.client.stateCache[this.device.did]?.attrs || {};
|
|
196
|
+
const isFahrenheit = (currentAttrs.Tunit === 0 || currentAttrs.temp_set_unit === '华氏');
|
|
197
|
+
|
|
198
|
+
let targetVal = parseInt(value);
|
|
199
|
+
if (isFahrenheit) {
|
|
200
|
+
targetVal = Math.round((value * 9 / 5) + 32);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if ([DeviceTypes.HYDROJET_SPA, DeviceTypes.HYDROJET_PRO_SPA, DeviceTypes.AIRJET_V01_SPA].includes(this.device.type)) {
|
|
204
|
+
await this.client.sendCommand(this.device.did, { Tset: targetVal });
|
|
205
|
+
} else {
|
|
206
|
+
await this.client.sendCommand(this.device.did, { temp_set: targetVal });
|
|
207
|
+
}
|
|
208
|
+
this.poll();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setupFilter() {
|
|
213
|
+
const service = this.accessory.getService('Filter') || this.accessory.addService(this.Service.Switch, 'Filter', 'filter');
|
|
214
|
+
|
|
215
|
+
service.getCharacteristic(this.Characteristic.On)
|
|
216
|
+
.onSet(async (value) => {
|
|
217
|
+
if ([DeviceTypes.HYDROJET_SPA, DeviceTypes.HYDROJET_PRO_SPA, DeviceTypes.AIRJET_V01_SPA].includes(this.device.type)) {
|
|
218
|
+
// Hydrojet/V01 uses 'filter'=2 for ON
|
|
219
|
+
await this.client.sendCommand(this.device.did, { filter: value ? 2 : 0 });
|
|
220
|
+
} else {
|
|
221
|
+
// Standard Airjet
|
|
222
|
+
await this.client.sendCommand(this.device.did, { filter_power: value ? 1 : 0 });
|
|
223
|
+
}
|
|
224
|
+
this.poll();
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setupBubblesSwitch() {
|
|
229
|
+
const service = this.accessory.getService('Bubbles') || this.accessory.addService(this.Service.Switch, 'Bubbles', 'bubbles');
|
|
230
|
+
|
|
231
|
+
service.getCharacteristic(this.Characteristic.On)
|
|
232
|
+
.onSet(async (value) => {
|
|
233
|
+
await this.client.sendCommand(this.device.did, { wave_power: value ? 1 : 0 });
|
|
234
|
+
this.poll();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
setupBubblesFan() {
|
|
239
|
+
const service = this.accessory.getService(this.Service.Fan) || this.accessory.addService(this.Service.Fan, 'Bubbles', 'bubbles');
|
|
240
|
+
|
|
241
|
+
service.getCharacteristic(this.Characteristic.On)
|
|
242
|
+
.onSet(async (value) => {
|
|
243
|
+
if (!value) {
|
|
244
|
+
await this.client.sendCommand(this.device.did, { wave: 0 });
|
|
245
|
+
} else {
|
|
246
|
+
// Default to Max Intensity (100)
|
|
247
|
+
await this.client.sendCommand(this.device.did, { wave: 100 });
|
|
248
|
+
}
|
|
249
|
+
this.poll();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
service.getCharacteristic(this.Characteristic.RotationSpeed)
|
|
253
|
+
.setProps({
|
|
254
|
+
minValue: 0,
|
|
255
|
+
maxValue: 100,
|
|
256
|
+
minStep: 50 // 0, 50, 100
|
|
257
|
+
})
|
|
258
|
+
.onSet(async (value) => {
|
|
259
|
+
let apiValue = 0;
|
|
260
|
+
if (value >= 90) apiValue = 100; // High
|
|
261
|
+
else if (value >= 10) apiValue = 50; // Medium
|
|
262
|
+
|
|
263
|
+
if (this.device.type === DeviceTypes.HYDROJET_SPA || this.device.type === DeviceTypes.HYDROJET_PRO_SPA) {
|
|
264
|
+
if (value >= 90) apiValue = 100;
|
|
265
|
+
else if (value >= 10) apiValue = 40;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await this.client.sendCommand(this.device.did, { wave: apiValue });
|
|
269
|
+
this.poll();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = { BestwayAccessory };
|
package/lib/api.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const { HEADERS, APP_ID, DeviceTypes } = require('./constants');
|
|
3
|
+
|
|
4
|
+
class BestwayApi {
|
|
5
|
+
constructor(apiUrl, username, password) {
|
|
6
|
+
this.apiUrl = apiUrl;
|
|
7
|
+
this.username = username;
|
|
8
|
+
this.password = password;
|
|
9
|
+
this.userToken = null;
|
|
10
|
+
this.userId = null;
|
|
11
|
+
this.devices = {};
|
|
12
|
+
this.stateCache = {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async login() {
|
|
16
|
+
try {
|
|
17
|
+
const response = await axios.post(`${this.apiUrl}/app/login`, {
|
|
18
|
+
username: this.username,
|
|
19
|
+
password: this.password,
|
|
20
|
+
lang: 'en',
|
|
21
|
+
}, {
|
|
22
|
+
headers: HEADERS,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
this.userId = response.data.uid;
|
|
26
|
+
this.userToken = response.data.token;
|
|
27
|
+
this.tokenExpiry = response.data.expire_at;
|
|
28
|
+
return true;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error('Login failed:', error.response ? error.response.data : error.message);
|
|
31
|
+
throw new Error('Failed to login to Bestway API');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getHeaders() {
|
|
36
|
+
return {
|
|
37
|
+
...HEADERS,
|
|
38
|
+
'X-Gizwits-User-token': this.userToken,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async getDevices() {
|
|
43
|
+
if (!this.userToken) await this.login();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const response = await axios.get(`${this.apiUrl}/app/bindings`, {
|
|
47
|
+
headers: this.getHeaders(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const devices = response.data.devices || [];
|
|
51
|
+
this.devices = {};
|
|
52
|
+
|
|
53
|
+
return devices.map(d => {
|
|
54
|
+
const device = {
|
|
55
|
+
did: d.did,
|
|
56
|
+
product_name: d.product_name,
|
|
57
|
+
dev_alias: d.dev_alias,
|
|
58
|
+
is_online: d.is_online,
|
|
59
|
+
type: this.getDeviceType(d.product_name)
|
|
60
|
+
};
|
|
61
|
+
this.devices[d.did] = device;
|
|
62
|
+
return device;
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (this.isAuthError(error)) {
|
|
66
|
+
await this.login();
|
|
67
|
+
return this.getDevices();
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getDeviceType(productName) {
|
|
74
|
+
if (productName === 'Airjet') return DeviceTypes.AIRJET_SPA;
|
|
75
|
+
if (productName === 'Airjet_V01') return DeviceTypes.AIRJET_V01_SPA;
|
|
76
|
+
if (productName === 'Hydrojet') return DeviceTypes.HYDROJET_SPA;
|
|
77
|
+
if (productName === 'Hydrojet_Pro') return DeviceTypes.HYDROJET_PRO_SPA;
|
|
78
|
+
if (productName === '泳池过滤器') return DeviceTypes.POOL_FILTER;
|
|
79
|
+
return DeviceTypes.UNKNOWN;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getDeviceState(deviceId) {
|
|
83
|
+
if (!this.userToken) await this.login();
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const response = await axios.get(`${this.apiUrl}/app/devdata/${deviceId}/latest`, {
|
|
87
|
+
headers: this.getHeaders(),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const data = response.data;
|
|
91
|
+
const apiTimestamp = data.updated_at;
|
|
92
|
+
|
|
93
|
+
// Check cache validity (optimistic updates)
|
|
94
|
+
// When we send a command, we update the local cache with a new timestamp.
|
|
95
|
+
// If the API returns a state with an older timestamp (due to cloud latency),
|
|
96
|
+
// we prefer our newer local state to prevent the UI from temporarily reverting.
|
|
97
|
+
if (this.stateCache[deviceId] && this.stateCache[deviceId].timestamp > apiTimestamp) {
|
|
98
|
+
return this.stateCache[deviceId].attrs;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Update cache
|
|
102
|
+
this.stateCache[deviceId] = {
|
|
103
|
+
timestamp: apiTimestamp,
|
|
104
|
+
attrs: data.attr
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return data.attr;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (this.isAuthError(error)) {
|
|
110
|
+
await this.login();
|
|
111
|
+
return this.getDeviceState(deviceId);
|
|
112
|
+
}
|
|
113
|
+
// If 404/500 or offline, might return null or throw
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async sendCommand(deviceId, attrs) {
|
|
119
|
+
if (!this.userToken) await this.login();
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await axios.post(`${this.apiUrl}/app/control/${deviceId}`, {
|
|
123
|
+
attrs: attrs
|
|
124
|
+
}, {
|
|
125
|
+
headers: this.getHeaders(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Optimistic update
|
|
129
|
+
const now = Math.floor(Date.now() / 1000);
|
|
130
|
+
if (!this.stateCache[deviceId]) {
|
|
131
|
+
this.stateCache[deviceId] = { timestamp: 0, attrs: {} };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.stateCache[deviceId].timestamp = now;
|
|
135
|
+
Object.assign(this.stateCache[deviceId].attrs, attrs);
|
|
136
|
+
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (this.isAuthError(error)) {
|
|
139
|
+
await this.login();
|
|
140
|
+
return this.sendCommand(deviceId, attrs);
|
|
141
|
+
}
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
isAuthError(error) {
|
|
147
|
+
// Check for specific error codes: 9004 (Token Invalid)
|
|
148
|
+
if (error.response && error.response.data && error.response.data.error_code === 9004) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = BestwayApi;
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
PLATFORM_NAME: 'Bestway',
|
|
3
|
+
PLUGIN_NAME: 'homebridge-bestway',
|
|
4
|
+
API_URL_EU: 'https://euapi.gizwits.com',
|
|
5
|
+
API_URL_US: 'https://usapi.gizwits.com',
|
|
6
|
+
APP_ID: '98754e684ec045528b073876c34c7348',
|
|
7
|
+
HEADERS: {
|
|
8
|
+
'Content-type': 'application/json; charset=UTF-8',
|
|
9
|
+
'X-Gizwits-Application-Id': '98754e684ec045528b073876c34c7348',
|
|
10
|
+
},
|
|
11
|
+
DeviceTypes: {
|
|
12
|
+
AIRJET_SPA: 'Airjet',
|
|
13
|
+
AIRJET_V01_SPA: 'Airjet V01',
|
|
14
|
+
HYDROJET_SPA: 'Hydrojet',
|
|
15
|
+
HYDROJET_PRO_SPA: 'Hydrojet Pro',
|
|
16
|
+
POOL_FILTER: 'Pool Filter',
|
|
17
|
+
UNKNOWN: 'Unknown'
|
|
18
|
+
}
|
|
19
|
+
};
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const { API_URL_EU, API_URL_US, PLUGIN_NAME, PLATFORM_NAME } = require('./constants');
|
|
2
|
+
const BestwayApi = require('./api');
|
|
3
|
+
const { BestwayAccessory } = require('./accessory');
|
|
4
|
+
|
|
5
|
+
class BestwayPlatform {
|
|
6
|
+
constructor(log, config, api) {
|
|
7
|
+
this.log = log;
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.api = api;
|
|
10
|
+
this.accessories = [];
|
|
11
|
+
|
|
12
|
+
if (!config || !config.email || !config.password) {
|
|
13
|
+
this.log.error('email and password required to be added in config.json');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const apiUrl = config.api_url || (config.region === 'US' ? API_URL_US : API_URL_EU);
|
|
18
|
+
|
|
19
|
+
this.client = new BestwayApi(
|
|
20
|
+
apiUrl,
|
|
21
|
+
config.email,
|
|
22
|
+
config.password
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
this.api.on('didFinishLaunching', () => {
|
|
26
|
+
this.discoverDevices();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
configureAccessory(accessory) {
|
|
31
|
+
this.accessories.push(accessory);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async discoverDevices() {
|
|
35
|
+
try {
|
|
36
|
+
this.log.info('Discovering devices...');
|
|
37
|
+
const devices = await this.client.getDevices();
|
|
38
|
+
|
|
39
|
+
// Loop through discovered devices
|
|
40
|
+
for (const device of devices) {
|
|
41
|
+
const uuid = this.api.hap.uuid.generate(device.did);
|
|
42
|
+
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
|
|
43
|
+
|
|
44
|
+
if (existingAccessory) {
|
|
45
|
+
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
|
|
46
|
+
new BestwayAccessory(this, existingAccessory, device);
|
|
47
|
+
} else {
|
|
48
|
+
this.log.info('Adding new accessory:', device.dev_alias);
|
|
49
|
+
const accessory = new this.api.platformAccessory(device.dev_alias || device.product_name, uuid);
|
|
50
|
+
accessory.context.device = device;
|
|
51
|
+
new BestwayAccessory(this, accessory, device);
|
|
52
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
this.log.error('Failed to discover devices:', error.message);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { BestwayPlatform };
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-bestway",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge plugin for Bestway / Lay-Z-Spa hot tubs",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18",
|
|
8
|
+
"homebridge": ">=1.6.0"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"homebridge-plugin",
|
|
12
|
+
"homebridge",
|
|
13
|
+
"bestway",
|
|
14
|
+
"lay-z-spa"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"axios": "^1.6.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
21
|
+
}
|
|
22
|
+
}
|