homebridge-panasonic-ecocute-bath-extra-command 0.2.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
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Homebridge Panasonic Ecocute Bath Extra Command
|
|
2
|
+
|
|
3
|
+
Homebridge platform plugin for Panasonic Ecocute bath extra commands via Panasonic Cloud API.
|
|
4
|
+
|
|
5
|
+
This is an unofficial plugin and is not supported by Panasonic.
|
|
6
|
+
Panasonic may change the cloud API at any time. Use at your own risk.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Exposes Panasonic Ecocute bath extra commands to HomeKit as Valve accessories.
|
|
11
|
+
- Supports Bath Reheating / 追いだき.
|
|
12
|
+
- Supports Warmth Charge / ぬくもりチャージ.
|
|
13
|
+
- Uses one shared API Host / Device ID / Bearer Token at platform level.
|
|
14
|
+
- Creates multiple HomeKit accessories from the `commands` array.
|
|
15
|
+
- Reads remaining hot water information and can expose it as a Battery Service.
|
|
16
|
+
- Blocks Warmth Charge ON when `warmthCharge.permission` is not true.
|
|
17
|
+
- Does not mark the Valve as faulted when Warmth Charge is unavailable, to avoid unnecessary HomeKit grey-out.
|
|
18
|
+
|
|
19
|
+
## Security Notice
|
|
20
|
+
|
|
21
|
+
Do not publish or share your Device ID, Bearer Token, Authorization header, cookies, refresh tokens, passwords, or hashedCorporationPassword.
|
|
22
|
+
|
|
23
|
+
## Platform Configuration
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"platform": "PanasonicEcocuteBathExtra",
|
|
28
|
+
"name": "Panasonic Ecocute Bath Extra",
|
|
29
|
+
"apiHost": "https://app.dhw.apws.panasonic.com",
|
|
30
|
+
"deviceId": "YOUR_DEVICE_ID",
|
|
31
|
+
"bearerToken": "YOUR_BEARER_TOKEN",
|
|
32
|
+
"statusPollSeconds": 10,
|
|
33
|
+
"activePollSeconds": 5,
|
|
34
|
+
"advancedStatus": {
|
|
35
|
+
"statusCacheTtlSeconds": 2,
|
|
36
|
+
"statusRetryDelayMs": 800,
|
|
37
|
+
"statusErrorLogThrottleSeconds": 60
|
|
38
|
+
},
|
|
39
|
+
"lowWaterLevelThreshold": 20,
|
|
40
|
+
"batteryLevelMode": "liter_div_10",
|
|
41
|
+
"commands": [
|
|
42
|
+
{
|
|
43
|
+
"name": "エコキュート 追いだき",
|
|
44
|
+
"commandType": "bathReheating",
|
|
45
|
+
"valveType": "shower"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "エコキュート ぬくもりチャージ",
|
|
49
|
+
"commandType": "warmthCharge",
|
|
50
|
+
"valveType": "shower",
|
|
51
|
+
"permissionSensorType": "none"
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Commands
|
|
58
|
+
|
|
59
|
+
### Bath Reheating / 追いだき
|
|
60
|
+
|
|
61
|
+
- Endpoint: `PUT /api/v1/devices/<DEVICE_ID>/properties/bathReheating`
|
|
62
|
+
- ON body: `{ "bathReheating": true }`
|
|
63
|
+
- OFF body: `{ "bathReheating": false }`
|
|
64
|
+
|
|
65
|
+
### Warmth Charge / ぬくもりチャージ
|
|
66
|
+
|
|
67
|
+
- Endpoint: `PUT /api/v1/devices/<DEVICE_ID>/properties/warmthCharging`
|
|
68
|
+
- ON body: `{ "warmthCharge": { "status": true } }`
|
|
69
|
+
- OFF body: `{ "warmthCharge": { "status": false } }`
|
|
70
|
+
- `warmthCharge.status` means Warmth Charge is currently active.
|
|
71
|
+
- `warmthCharge.permission` means Warmth Charge is currently allowed.
|
|
72
|
+
|
|
73
|
+
If `warmthCharge.permission` is not true, HomeKit ON is blocked and no PUT request is sent.
|
|
74
|
+
|
|
75
|
+
## Warmth Charge Permission Sensor
|
|
76
|
+
|
|
77
|
+
`permissionSensorType` can be one of:
|
|
78
|
+
|
|
79
|
+
- `none`
|
|
80
|
+
- `contact`
|
|
81
|
+
- `occupancy`
|
|
82
|
+
- `motion`
|
|
83
|
+
- `leak`
|
|
84
|
+
|
|
85
|
+
Light Sensor is intentionally not provided because `warmthCharge.permission` is boolean.
|
|
86
|
+
Leak Sensor may look like a leak warning in HomeKit, so use it only if that behavior is acceptable.
|
|
87
|
+
|
|
88
|
+
## Getting Device ID and Bearer Token
|
|
89
|
+
|
|
90
|
+
Use the official Panasonic app and an HTTPS proxy tool such as Proxyman or mitmproxy.
|
|
91
|
+
Temporarily set the iPhone Wi-Fi HTTP proxy, operate the official app, and look for:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
/api/v1/devices/<DEVICE_ID>/properties
|
|
95
|
+
Authorization: Bearer <TOKEN>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Copy only the Device ID and Bearer Token into Homebridge. Turn the iPhone Wi-Fi HTTP proxy off after capture.
|
|
99
|
+
|
|
100
|
+
## Screenshots
|
|
101
|
+
|
|
102
|
+
Screenshots below show the v0.2.0 platform UI and verified HomeKit behavior.
|
|
103
|
+
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
## Future Work
|
|
111
|
+
|
|
112
|
+
Possible future additions:
|
|
113
|
+
|
|
114
|
+
- Bath Auto / ふろ自動 via Panasonic Cloud API, after confirming the real endpoint, request body, and status fields.
|
|
115
|
+
- `entrySign` support as a HomeKit Occupancy Sensor for bathroom entry detection.
|
|
116
|
+
|
|
117
|
+
## Version History
|
|
118
|
+
|
|
119
|
+
### 0.2.0
|
|
120
|
+
|
|
121
|
+
- Converted `PanasonicEcocuteBathExtra` to a platform plugin.
|
|
122
|
+
- Moved API Host / Device ID / Bearer Token to shared platform-level settings.
|
|
123
|
+
- Added `commands` array for Bath Reheating and Warmth Charge.
|
|
124
|
+
- Kept direct compatibility accessory aliases for advanced/manual use.
|
|
125
|
+
- Improved platform child logs to include each accessory name.
|
|
126
|
+
- Avoided HomeKit grey-out by keeping Valve `StatusFault` as `NO_FAULT` when Warmth Charge is unavailable.
|
|
127
|
+
|
|
128
|
+
### 0.1.0
|
|
129
|
+
|
|
130
|
+
- Initial integrated package.
|
|
131
|
+
- Added Warmth Charge command support.
|
|
132
|
+
- Integrated old Bath Reheating behavior.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "PanasonicEcocuteBathExtra",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": false,
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"title": "Platform Name",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"default": "Panasonic Ecocute Bath Extra"
|
|
12
|
+
},
|
|
13
|
+
"apiHost": {
|
|
14
|
+
"title": "API Host",
|
|
15
|
+
"type": "string",
|
|
16
|
+
"default": "https://app.dhw.apws.panasonic.com",
|
|
17
|
+
"description": "Panasonic Cloud API host shared by all commands in this platform."
|
|
18
|
+
},
|
|
19
|
+
"deviceId": {
|
|
20
|
+
"title": "Device ID",
|
|
21
|
+
"type": "string",
|
|
22
|
+
"format": "password",
|
|
23
|
+
"required": true,
|
|
24
|
+
"description": "Panasonic Ecocute Device ID shared by all commands in this platform."
|
|
25
|
+
},
|
|
26
|
+
"bearerToken": {
|
|
27
|
+
"title": "Bearer Token",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"format": "password",
|
|
30
|
+
"required": true,
|
|
31
|
+
"description": "Panasonic Cloud Bearer Token shared by all commands in this platform. Do not share this value."
|
|
32
|
+
},
|
|
33
|
+
"statusPollSeconds": {
|
|
34
|
+
"title": "通常状態更新間隔 秒",
|
|
35
|
+
"type": "integer",
|
|
36
|
+
"default": 10,
|
|
37
|
+
"minimum": 5
|
|
38
|
+
},
|
|
39
|
+
"activePollSeconds": {
|
|
40
|
+
"title": "動作中の状態更新間隔 秒",
|
|
41
|
+
"type": "integer",
|
|
42
|
+
"default": 5,
|
|
43
|
+
"minimum": 2
|
|
44
|
+
},
|
|
45
|
+
"advancedStatus": {
|
|
46
|
+
"title": "状態GET詳細設定",
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"statusCacheTtlSeconds": {
|
|
50
|
+
"title": "状態GETキャッシュ 秒",
|
|
51
|
+
"type": "integer",
|
|
52
|
+
"default": 2,
|
|
53
|
+
"minimum": 0
|
|
54
|
+
},
|
|
55
|
+
"statusRetryDelayMs": {
|
|
56
|
+
"title": "状態GETリトライ待機 ミリ秒",
|
|
57
|
+
"type": "integer",
|
|
58
|
+
"default": 800,
|
|
59
|
+
"minimum": 0
|
|
60
|
+
},
|
|
61
|
+
"statusErrorLogThrottleSeconds": {
|
|
62
|
+
"title": "状態GETエラーログ抑制 秒",
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"default": 60,
|
|
65
|
+
"minimum": 5
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"lowWaterLevelThreshold": {
|
|
70
|
+
"title": "残湯低下しきい値 %",
|
|
71
|
+
"type": "integer",
|
|
72
|
+
"default": 20,
|
|
73
|
+
"minimum": 0,
|
|
74
|
+
"maximum": 100
|
|
75
|
+
},
|
|
76
|
+
"batteryLevelMode": {
|
|
77
|
+
"title": "残湯表示方式",
|
|
78
|
+
"type": "string",
|
|
79
|
+
"default": "liter_div_10",
|
|
80
|
+
"oneOf": [
|
|
81
|
+
{
|
|
82
|
+
"title": "湯量L ÷ 10(540L→54%)",
|
|
83
|
+
"enum": [
|
|
84
|
+
"liter_div_10"
|
|
85
|
+
]
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"title": "API rate × 10(9→90%)",
|
|
89
|
+
"enum": [
|
|
90
|
+
"rate_x10"
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
},
|
|
95
|
+
"commands": {
|
|
96
|
+
"title": "Bath Commands / おふろ操作",
|
|
97
|
+
"type": "array",
|
|
98
|
+
"default": [
|
|
99
|
+
{
|
|
100
|
+
"name": "エコキュート 追いだき",
|
|
101
|
+
"commandType": "bathReheating",
|
|
102
|
+
"valveType": "shower"
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
"name": "エコキュート ぬくもりチャージ",
|
|
106
|
+
"commandType": "warmthCharge",
|
|
107
|
+
"valveType": "shower",
|
|
108
|
+
"permissionSensorType": "none"
|
|
109
|
+
}
|
|
110
|
+
],
|
|
111
|
+
"items": {
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"name": {
|
|
115
|
+
"title": "Accessory Name",
|
|
116
|
+
"type": "string",
|
|
117
|
+
"default": "エコキュート ぬくもりチャージ"
|
|
118
|
+
},
|
|
119
|
+
"commandType": {
|
|
120
|
+
"title": "Bath Extra Command / おふろ追加操作",
|
|
121
|
+
"type": "string",
|
|
122
|
+
"default": "warmthCharge",
|
|
123
|
+
"oneOf": [
|
|
124
|
+
{
|
|
125
|
+
"title": "Warmth Charge / ぬくもりチャージ",
|
|
126
|
+
"enum": [
|
|
127
|
+
"warmthCharge"
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"title": "Bath Reheating / 追いだき",
|
|
132
|
+
"enum": [
|
|
133
|
+
"bathReheating"
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
"valveType": {
|
|
139
|
+
"title": "Valve Type",
|
|
140
|
+
"type": "string",
|
|
141
|
+
"default": "shower",
|
|
142
|
+
"oneOf": [
|
|
143
|
+
{
|
|
144
|
+
"title": "Faucet / 水栓",
|
|
145
|
+
"enum": [
|
|
146
|
+
"faucet"
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"title": "Shower / シャワー",
|
|
151
|
+
"enum": [
|
|
152
|
+
"shower"
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
"title": "Sprinkler / スプリンクラー",
|
|
157
|
+
"enum": [
|
|
158
|
+
"sprinkler"
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
"description": "HomeKit Valve display type only."
|
|
163
|
+
},
|
|
164
|
+
"permissionSensorType": {
|
|
165
|
+
"title": "Warmth Charge Permission Sensor Type",
|
|
166
|
+
"type": "string",
|
|
167
|
+
"default": "none",
|
|
168
|
+
"oneOf": [
|
|
169
|
+
{
|
|
170
|
+
"title": "None / 表示しない",
|
|
171
|
+
"enum": [
|
|
172
|
+
"none"
|
|
173
|
+
]
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
"title": "Contact Sensor",
|
|
177
|
+
"enum": [
|
|
178
|
+
"contact"
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
"title": "Occupancy Sensor",
|
|
183
|
+
"enum": [
|
|
184
|
+
"occupancy"
|
|
185
|
+
]
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
"title": "Motion Sensor",
|
|
189
|
+
"enum": [
|
|
190
|
+
"motion"
|
|
191
|
+
]
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
"title": "Leak Sensor",
|
|
195
|
+
"enum": [
|
|
196
|
+
"leak"
|
|
197
|
+
]
|
|
198
|
+
}
|
|
199
|
+
],
|
|
200
|
+
"description": "Optional HomeKit sensor for warmthCharge.permission. Light Sensor is intentionally not provided because permission is boolean."
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/index.js
ADDED
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
let Service;
|
|
4
|
+
let Characteristic;
|
|
5
|
+
|
|
6
|
+
const PLUGIN_NAME = 'homebridge-panasonic-ecocute-bath-extra-command';
|
|
7
|
+
const ACCESSORY_EXTRA_NAME = 'PanasonicEcocuteBathExtra';
|
|
8
|
+
const ACCESSORY_REHEATING_NAME = 'PanasonicEcocuteBathReheating';
|
|
9
|
+
const ACCESSORY_WARMTH_CHARGE_NAME = 'PanasonicEcocuteWarmthCharge';
|
|
10
|
+
|
|
11
|
+
module.exports = (api) => {
|
|
12
|
+
Service = api.hap.Service;
|
|
13
|
+
Characteristic = api.hap.Characteristic;
|
|
14
|
+
api.registerPlatform(PLUGIN_NAME, ACCESSORY_EXTRA_NAME, PanasonicEcocuteBathExtraPlatform);
|
|
15
|
+
api.registerAccessory(PLUGIN_NAME, ACCESSORY_REHEATING_NAME, PanasonicEcocuteBathReheatingValve);
|
|
16
|
+
api.registerAccessory(PLUGIN_NAME, ACCESSORY_WARMTH_CHARGE_NAME, PanasonicEcocuteWarmthChargeValve);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class PanasonicEcocuteBathReheatingValve {
|
|
20
|
+
constructor(log, config) {
|
|
21
|
+
this.log = log;
|
|
22
|
+
this.config = config || {};
|
|
23
|
+
|
|
24
|
+
this.name = this.config.name || 'エコキュート 追いだき';
|
|
25
|
+
this.apiHost = String(this.config.apiHost || 'https://app.dhw.apws.panasonic.com').replace(/\/+$/, '');
|
|
26
|
+
this.deviceId = String(this.config.deviceId || '');
|
|
27
|
+
this.bearerToken = String(this.config.bearerToken || '');
|
|
28
|
+
|
|
29
|
+
this.statusPollSeconds = Math.max(5, Number(this.config.statusPollSeconds || 10));
|
|
30
|
+
this.activePollSeconds = Math.max(2, Number(this.config.activePollSeconds || 5));
|
|
31
|
+
this.statusCacheTtlSeconds = Math.max(0, Number(this.config.statusCacheTtlSeconds ?? 2));
|
|
32
|
+
this.statusRetryDelayMs = Math.max(0, Number(this.config.statusRetryDelayMs ?? 800));
|
|
33
|
+
this.statusErrorLogThrottleSeconds = Math.max(5, Number(this.config.statusErrorLogThrottleSeconds ?? 60));
|
|
34
|
+
|
|
35
|
+
this.lowWaterLevelThreshold = Math.max(0, Math.min(100, Number(this.config.lowWaterLevelThreshold ?? 20)));
|
|
36
|
+
this.batteryLevelMode = String(this.config.batteryLevelMode || 'liter_div_10');
|
|
37
|
+
this.httpTimeoutMs = Math.max(5000, Number(this.config.httpTimeoutMs || 15000));
|
|
38
|
+
|
|
39
|
+
this.currentReheating = false;
|
|
40
|
+
this.currentWaterHeating = false;
|
|
41
|
+
this.currentBatteryLevel = 0;
|
|
42
|
+
|
|
43
|
+
this.commandInFlight = false;
|
|
44
|
+
this.pollTimer = null;
|
|
45
|
+
|
|
46
|
+
this.statusCache = null;
|
|
47
|
+
this.statusCacheAt = 0;
|
|
48
|
+
this.statusFetchPromise = null;
|
|
49
|
+
|
|
50
|
+
this.lastStatusErrorKey = '';
|
|
51
|
+
this.lastStatusErrorAt = 0;
|
|
52
|
+
this.statusErrorSuppressed = 0;
|
|
53
|
+
|
|
54
|
+
this.informationService = new Service.AccessoryInformation()
|
|
55
|
+
.setCharacteristic(Characteristic.Manufacturer, 'Panasonic')
|
|
56
|
+
.setCharacteristic(Characteristic.Model, 'Ecocute Bath Reheating Cloud Valve')
|
|
57
|
+
.setCharacteristic(Characteristic.SerialNumber, 'panasonic-ecocute-bath-reheating')
|
|
58
|
+
.setCharacteristic(Characteristic.FirmwareRevision, '0.2.0');
|
|
59
|
+
|
|
60
|
+
this.valveService = new Service.Valve(this.name);
|
|
61
|
+
this.valveService
|
|
62
|
+
.setCharacteristic(Characteristic.ValveType, ({sprinkler: Characteristic.ValveType.IRRIGATION, shower: Characteristic.ValveType.SHOWER_HEAD, faucet: Characteristic.ValveType.WATER_FAUCET}[String(this.config.valveType || "shower").toLowerCase()] || Characteristic.ValveType.SHOWER_HEAD))
|
|
63
|
+
.setCharacteristic(Characteristic.Name, this.name);
|
|
64
|
+
|
|
65
|
+
this.valveService.getCharacteristic(Characteristic.Active)
|
|
66
|
+
.onGet(this.handleGetActive.bind(this))
|
|
67
|
+
.onSet(this.handleSetActive.bind(this));
|
|
68
|
+
|
|
69
|
+
this.valveService.getCharacteristic(Characteristic.InUse)
|
|
70
|
+
.onGet(this.handleGetInUse.bind(this));
|
|
71
|
+
|
|
72
|
+
const BatteryServiceClass = Service.BatteryService || Service.Battery;
|
|
73
|
+
if (BatteryServiceClass) {
|
|
74
|
+
this.batteryService = new BatteryServiceClass(`${this.name} 残湯`, 'remaining-hot-water');
|
|
75
|
+
|
|
76
|
+
this.batteryService.getCharacteristic(Characteristic.BatteryLevel)
|
|
77
|
+
.onGet(async () => {
|
|
78
|
+
await this.refreshStatus({ allowCache: true });
|
|
79
|
+
return this.currentBatteryLevel;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.batteryService.getCharacteristic(Characteristic.StatusLowBattery)
|
|
83
|
+
.onGet(async () => {
|
|
84
|
+
await this.refreshStatus({ allowCache: true });
|
|
85
|
+
return this.currentBatteryLevel <= this.lowWaterLevelThreshold
|
|
86
|
+
? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
|
|
87
|
+
: Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.batteryService.getCharacteristic(Characteristic.ChargingState)
|
|
91
|
+
.onGet(async () => {
|
|
92
|
+
await this.refreshStatus({ allowCache: true });
|
|
93
|
+
return this.currentWaterHeating
|
|
94
|
+
? Characteristic.ChargingState.CHARGING
|
|
95
|
+
: Characteristic.ChargingState.NOT_CHARGING;
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
this.batteryService = null;
|
|
99
|
+
this.log.warn('BatteryService is not available; remaining hot water display is disabled.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.log.info(
|
|
103
|
+
`initialized v0.2.0 Valve ` +
|
|
104
|
+
`statusPollSeconds=${this.statusPollSeconds} activePollSeconds=${this.activePollSeconds} ` +
|
|
105
|
+
`cacheTtl=${this.statusCacheTtlSeconds}s retryDelay=${this.statusRetryDelayMs}ms ` +
|
|
106
|
+
`errorThrottle=${this.statusErrorLogThrottleSeconds}s ` +
|
|
107
|
+
`deviceId=${this.mask(this.deviceId)} token=${this.bearerToken ? '***SET***' : '***EMPTY***'}`
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (!this.deviceId || !this.bearerToken) {
|
|
111
|
+
this.log.warn('deviceId or bearerToken is empty. Configure them in Homebridge UI.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.refreshStatus({ force: true }).finally(() => this.scheduleNextPoll(2));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getServices() {
|
|
118
|
+
return [
|
|
119
|
+
this.informationService,
|
|
120
|
+
this.valveService,
|
|
121
|
+
...(this.batteryService ? [this.batteryService] : [])
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
sleep(ms) {
|
|
126
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
mask(value) {
|
|
130
|
+
if (!value) return '***EMPTY***';
|
|
131
|
+
if (value.length <= 8) return '***MASKED***';
|
|
132
|
+
return `${value.slice(0, 4)}...${value.slice(-4)}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
url(path) {
|
|
136
|
+
return `${this.apiHost}${path}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
headers(json = true) {
|
|
140
|
+
const h = {
|
|
141
|
+
'Authorization': `Bearer ${this.bearerToken}`,
|
|
142
|
+
'Accept': 'application/json, application/problem+json; charset=utf-8',
|
|
143
|
+
'Accept-Language': 'ja',
|
|
144
|
+
'User-Agent': 'CFNetwork/3860.600.12 Darwin/25.5.0'
|
|
145
|
+
};
|
|
146
|
+
if (json) {
|
|
147
|
+
h['Content-Type'] = 'application/json; charset=utf-8';
|
|
148
|
+
}
|
|
149
|
+
return h;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sanitizeErrorText(text) {
|
|
153
|
+
return String(text || '')
|
|
154
|
+
.replace(this.bearerToken || '__NO_TOKEN__', '***TOKEN***')
|
|
155
|
+
.replace(this.deviceId || '__NO_DEVICE__', '<DEVICE_ID>');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async request(method, property, body) {
|
|
159
|
+
if (!this.deviceId || !this.bearerToken) {
|
|
160
|
+
throw new Error('deviceId or bearerToken is empty');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const encodedDeviceId = encodeURIComponent(this.deviceId);
|
|
164
|
+
const path = property
|
|
165
|
+
? `/api/v1/devices/${encodedDeviceId}/properties/${property}`
|
|
166
|
+
: `/api/v1/devices/${encodedDeviceId}/properties`;
|
|
167
|
+
|
|
168
|
+
const controller = new AbortController();
|
|
169
|
+
const timer = setTimeout(() => controller.abort(), this.httpTimeoutMs);
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const options = {
|
|
173
|
+
method,
|
|
174
|
+
headers: this.headers(true),
|
|
175
|
+
signal: controller.signal
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
if (body !== undefined) {
|
|
179
|
+
options.body = JSON.stringify(body);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const res = await fetch(this.url(path), options);
|
|
183
|
+
const text = await res.text();
|
|
184
|
+
|
|
185
|
+
let data = null;
|
|
186
|
+
try {
|
|
187
|
+
data = text ? JSON.parse(text) : {};
|
|
188
|
+
} catch (e) {
|
|
189
|
+
data = { raw: text };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!res.ok) {
|
|
193
|
+
const err = new Error(`HTTP ${res.status} ${res.statusText}: ${this.sanitizeErrorText(text)}`);
|
|
194
|
+
err.status = res.status;
|
|
195
|
+
err.code = data && data.code;
|
|
196
|
+
err.bodyText = text;
|
|
197
|
+
throw err;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return data;
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e.name === 'AbortError') {
|
|
203
|
+
const err = new Error(`HTTP timeout after ${this.httpTimeoutMs}ms`);
|
|
204
|
+
err.status = 0;
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
throw e;
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
isRetryableStatusError(e) {
|
|
214
|
+
return e && (
|
|
215
|
+
e.status === 502 ||
|
|
216
|
+
e.status === 503 ||
|
|
217
|
+
e.status === 504 ||
|
|
218
|
+
e.code === 'Q2E07-00009'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async getProperties() {
|
|
223
|
+
try {
|
|
224
|
+
return await this.request('GET', '', undefined);
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (this.isRetryableStatusError(e)) {
|
|
227
|
+
this.log.debug(`status GET retry after ${this.statusRetryDelayMs}ms: ${this.sanitizeErrorText(e.message)}`);
|
|
228
|
+
await this.sleep(this.statusRetryDelayMs);
|
|
229
|
+
return await this.request('GET', '', undefined);
|
|
230
|
+
}
|
|
231
|
+
throw e;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async putBathReheating(value) {
|
|
236
|
+
return this.request('PUT', 'bathReheating', { bathReheating: Boolean(value) });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
clampPercent(n) {
|
|
240
|
+
const x = Number(n);
|
|
241
|
+
if (!Number.isFinite(x)) return 0;
|
|
242
|
+
return Math.max(0, Math.min(100, Math.round(x)));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
calcBatteryLevel(data) {
|
|
246
|
+
const liter = Number(data.availableHotWaterLiter);
|
|
247
|
+
const rate = Number(data.availableHotWaterRate);
|
|
248
|
+
|
|
249
|
+
if (this.batteryLevelMode === 'rate_x10' && Number.isFinite(rate)) {
|
|
250
|
+
return this.clampPercent(rate * 10);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (Number.isFinite(liter)) {
|
|
254
|
+
return this.clampPercent(liter / 10);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (Number.isFinite(rate)) {
|
|
258
|
+
return this.clampPercent(rate * 10);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return this.currentBatteryLevel;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
hasOwn(data, key) {
|
|
265
|
+
return data && Object.prototype.hasOwnProperty.call(data, key);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
applyStatus(data, source) {
|
|
269
|
+
if (!data || typeof data !== 'object') return;
|
|
270
|
+
|
|
271
|
+
const nextReheating = this.hasOwn(data, 'bathReheating')
|
|
272
|
+
? Boolean(data.bathReheating)
|
|
273
|
+
: this.currentReheating;
|
|
274
|
+
|
|
275
|
+
const nextWaterHeating = this.hasOwn(data, 'waterHeatingStatus')
|
|
276
|
+
? Boolean(data.waterHeatingStatus)
|
|
277
|
+
: this.currentWaterHeating;
|
|
278
|
+
|
|
279
|
+
const nextBatteryLevel = (
|
|
280
|
+
this.hasOwn(data, 'availableHotWaterLiter') ||
|
|
281
|
+
this.hasOwn(data, 'availableHotWaterRate')
|
|
282
|
+
) ? this.calcBatteryLevel(data) : this.currentBatteryLevel;
|
|
283
|
+
|
|
284
|
+
if (this.currentReheating !== nextReheating) {
|
|
285
|
+
this.log.info(`bathReheating changed by ${source}: ${this.currentReheating} => ${nextReheating}`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (this.currentWaterHeating !== nextWaterHeating) {
|
|
289
|
+
this.log.info(`waterHeatingStatus changed by ${source}: ${this.currentWaterHeating} => ${nextWaterHeating}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
this.currentReheating = nextReheating;
|
|
293
|
+
this.currentWaterHeating = nextWaterHeating;
|
|
294
|
+
this.currentBatteryLevel = nextBatteryLevel;
|
|
295
|
+
|
|
296
|
+
this.valveService.updateCharacteristic(
|
|
297
|
+
Characteristic.Active,
|
|
298
|
+
nextReheating ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
this.valveService.updateCharacteristic(
|
|
302
|
+
Characteristic.InUse,
|
|
303
|
+
nextReheating ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (this.batteryService) {
|
|
307
|
+
this.batteryService.updateCharacteristic(Characteristic.BatteryLevel, nextBatteryLevel);
|
|
308
|
+
this.batteryService.updateCharacteristic(
|
|
309
|
+
Characteristic.StatusLowBattery,
|
|
310
|
+
nextBatteryLevel <= this.lowWaterLevelThreshold
|
|
311
|
+
? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
|
|
312
|
+
: Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
|
|
313
|
+
);
|
|
314
|
+
this.batteryService.updateCharacteristic(
|
|
315
|
+
Characteristic.ChargingState,
|
|
316
|
+
nextWaterHeating
|
|
317
|
+
? Characteristic.ChargingState.CHARGING
|
|
318
|
+
: Characteristic.ChargingState.NOT_CHARGING
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
statusCacheFresh() {
|
|
324
|
+
if (!this.statusCache) return false;
|
|
325
|
+
if (this.statusCacheTtlSeconds <= 0) return false;
|
|
326
|
+
return (Date.now() - this.statusCacheAt) < (this.statusCacheTtlSeconds * 1000);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
logStatusError(e) {
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const msg = this.sanitizeErrorText(e && e.message ? e.message : String(e));
|
|
332
|
+
const key = msg;
|
|
333
|
+
const throttleMs = this.statusErrorLogThrottleSeconds * 1000;
|
|
334
|
+
|
|
335
|
+
if (this.lastStatusErrorKey === key && (now - this.lastStatusErrorAt) < throttleMs) {
|
|
336
|
+
this.statusErrorSuppressed += 1;
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const suffix = this.statusErrorSuppressed > 0
|
|
341
|
+
? `; suppressed ${this.statusErrorSuppressed} similar status errors`
|
|
342
|
+
: '';
|
|
343
|
+
|
|
344
|
+
this.statusErrorSuppressed = 0;
|
|
345
|
+
this.lastStatusErrorKey = key;
|
|
346
|
+
this.lastStatusErrorAt = now;
|
|
347
|
+
|
|
348
|
+
this.log.warn(`status failed: ${msg}${suffix}`);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
clearStatusErrorAfterSuccess() {
|
|
352
|
+
if (this.statusErrorSuppressed > 0) {
|
|
353
|
+
this.log.info(`status recovered; suppressed ${this.statusErrorSuppressed} previous status errors`);
|
|
354
|
+
}
|
|
355
|
+
this.statusErrorSuppressed = 0;
|
|
356
|
+
this.lastStatusErrorKey = '';
|
|
357
|
+
this.lastStatusErrorAt = 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async refreshStatus(options = {}) {
|
|
361
|
+
const force = Boolean(options.force);
|
|
362
|
+
const allowCache = options.allowCache !== false;
|
|
363
|
+
|
|
364
|
+
if (!force && allowCache && this.statusCacheFresh()) {
|
|
365
|
+
return this.statusCache;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (this.statusFetchPromise) {
|
|
369
|
+
return this.statusFetchPromise;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
this.statusFetchPromise = (async () => {
|
|
373
|
+
try {
|
|
374
|
+
const data = await this.getProperties();
|
|
375
|
+
this.statusCache = data;
|
|
376
|
+
this.statusCacheAt = Date.now();
|
|
377
|
+
this.applyStatus(data, 'cloud-status');
|
|
378
|
+
this.clearStatusErrorAfterSuccess();
|
|
379
|
+
|
|
380
|
+
this.log.debug(
|
|
381
|
+
`status bathReheating=${Boolean(data.bathReheating)} ` +
|
|
382
|
+
`waterHeatingStatus=${Boolean(data.waterHeatingStatus)} ` +
|
|
383
|
+
`availableHotWaterLiter=${data.availableHotWaterLiter} ` +
|
|
384
|
+
`availableHotWaterRate=${data.availableHotWaterRate} ` +
|
|
385
|
+
`bathTemperature=${data.bathTemperature} bathOperationMode=${data.bathOperationMode}`
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return data;
|
|
389
|
+
} catch (e) {
|
|
390
|
+
this.logStatusError(e);
|
|
391
|
+
return null;
|
|
392
|
+
} finally {
|
|
393
|
+
this.statusFetchPromise = null;
|
|
394
|
+
}
|
|
395
|
+
})();
|
|
396
|
+
|
|
397
|
+
return this.statusFetchPromise;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
clearPollTimer() {
|
|
401
|
+
if (this.pollTimer) {
|
|
402
|
+
clearTimeout(this.pollTimer);
|
|
403
|
+
this.pollTimer = null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
scheduleNextPoll(delaySeconds) {
|
|
408
|
+
this.clearPollTimer();
|
|
409
|
+
|
|
410
|
+
const interval = Number.isFinite(delaySeconds)
|
|
411
|
+
? delaySeconds
|
|
412
|
+
: (this.currentReheating ? this.activePollSeconds : this.statusPollSeconds);
|
|
413
|
+
|
|
414
|
+
this.pollTimer = setTimeout(async () => {
|
|
415
|
+
try {
|
|
416
|
+
await this.refreshStatus({ allowCache: false });
|
|
417
|
+
} finally {
|
|
418
|
+
this.scheduleNextPoll();
|
|
419
|
+
}
|
|
420
|
+
}, Math.max(1, interval) * 1000);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async handleGetActive() {
|
|
424
|
+
await this.refreshStatus({ allowCache: true });
|
|
425
|
+
return this.currentReheating
|
|
426
|
+
? Characteristic.Active.ACTIVE
|
|
427
|
+
: Characteristic.Active.INACTIVE;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async handleGetInUse() {
|
|
431
|
+
await this.refreshStatus({ allowCache: true });
|
|
432
|
+
return this.currentReheating
|
|
433
|
+
? Characteristic.InUse.IN_USE
|
|
434
|
+
: Characteristic.InUse.NOT_IN_USE;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async handleSetActive(value) {
|
|
438
|
+
const desired = Number(value) === Characteristic.Active.ACTIVE;
|
|
439
|
+
|
|
440
|
+
if (this.commandInFlight) {
|
|
441
|
+
this.log.warn('command already in flight; refreshing status instead');
|
|
442
|
+
await this.refreshStatus({ force: true });
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
this.commandInFlight = true;
|
|
447
|
+
this.log.info(`HomeKit Valve set ${desired ? 'ACTIVE/ON' : 'INACTIVE/OFF'}; sending REAL bathReheating=${desired}`);
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
|
|
451
|
+
const data = await this.putBathReheating(desired);
|
|
452
|
+
this.log.info(`REAL command ok bathReheating=${Boolean(data.bathReheating)}`);
|
|
453
|
+
|
|
454
|
+
this.statusCache = data;
|
|
455
|
+
this.statusCacheAt = Date.now();
|
|
456
|
+
this.applyStatus(data, 'homekit-command');
|
|
457
|
+
|
|
458
|
+
setTimeout(() => {
|
|
459
|
+
this.refreshStatus({ force: true }).catch(() => {});
|
|
460
|
+
}, 1500);
|
|
461
|
+
} catch (e) {
|
|
462
|
+
this.log.error(`set failed: ${this.sanitizeErrorText(e.message)}`);
|
|
463
|
+
await this.refreshStatus({ force: true });
|
|
464
|
+
throw e;
|
|
465
|
+
} finally {
|
|
466
|
+
this.commandInFlight = false;
|
|
467
|
+
this.scheduleNextPoll(this.currentReheating ? this.activePollSeconds : 2);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class PanasonicEcocuteWarmthChargeValve extends PanasonicEcocuteBathReheatingValve {
|
|
475
|
+
constructor(log, config) {
|
|
476
|
+
super(log, Object.assign({
|
|
477
|
+
name: 'エコキュート ぬくもりチャージ',
|
|
478
|
+
valveType: 'shower'
|
|
479
|
+
}, config || {}));
|
|
480
|
+
|
|
481
|
+
this.currentPermission = false;
|
|
482
|
+
this.permissionSensorType = String(this.config.permissionSensorType || 'none').toLowerCase();
|
|
483
|
+
this.permissionService = this.createPermissionService();
|
|
484
|
+
|
|
485
|
+
this.informationService
|
|
486
|
+
.setCharacteristic(Characteristic.Model, 'Ecocute Warmth Charge Cloud Valve')
|
|
487
|
+
.setCharacteristic(Characteristic.SerialNumber, 'panasonic-ecocute-warmth-charge')
|
|
488
|
+
.setCharacteristic(Characteristic.FirmwareRevision, '0.2.0');
|
|
489
|
+
|
|
490
|
+
this.valveService.getCharacteristic(Characteristic.StatusFault)
|
|
491
|
+
.onGet(async () => {
|
|
492
|
+
await this.refreshStatus({ allowCache: true });
|
|
493
|
+
return Characteristic.StatusFault.NO_FAULT;
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
this.log.info('warmthCharge mode enabled endpoint=warmthCharging');
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async putBathReheating(value) {
|
|
500
|
+
return this.request('PUT', 'warmthCharging', {
|
|
501
|
+
warmthCharge: {
|
|
502
|
+
status: Boolean(value)
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
applyStatus(data, source) {
|
|
508
|
+
if (!data || typeof data !== 'object') return;
|
|
509
|
+
|
|
510
|
+
const warmth = data.warmthCharge && typeof data.warmthCharge === 'object'
|
|
511
|
+
? data.warmthCharge
|
|
512
|
+
: null;
|
|
513
|
+
|
|
514
|
+
const nextActive = warmth && this.hasOwn(warmth, 'status')
|
|
515
|
+
? Boolean(warmth.status)
|
|
516
|
+
: this.currentReheating;
|
|
517
|
+
|
|
518
|
+
const nextPermission = warmth && this.hasOwn(warmth, 'permission')
|
|
519
|
+
? Boolean(warmth.permission)
|
|
520
|
+
: this.currentPermission;
|
|
521
|
+
|
|
522
|
+
const nextWaterHeating = this.hasOwn(data, 'waterHeatingStatus')
|
|
523
|
+
? Boolean(data.waterHeatingStatus)
|
|
524
|
+
: this.currentWaterHeating;
|
|
525
|
+
|
|
526
|
+
const nextBatteryLevel = (
|
|
527
|
+
this.hasOwn(data, 'availableHotWaterLiter') ||
|
|
528
|
+
this.hasOwn(data, 'availableHotWaterRate')
|
|
529
|
+
) ? this.calcBatteryLevel(data) : this.currentBatteryLevel;
|
|
530
|
+
|
|
531
|
+
if (this.currentReheating !== nextActive) {
|
|
532
|
+
this.log.info(`warmthCharge.status changed by ${source}: ${this.currentReheating} => ${nextActive}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (this.currentPermission !== nextPermission) {
|
|
536
|
+
this.log.info(`warmthCharge.permission changed by ${source}: ${this.currentPermission} => ${nextPermission}`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (this.currentWaterHeating !== nextWaterHeating) {
|
|
540
|
+
this.log.info(`waterHeatingStatus changed by ${source}: ${this.currentWaterHeating} => ${nextWaterHeating}`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
this.currentReheating = nextActive;
|
|
544
|
+
this.currentPermission = nextPermission;
|
|
545
|
+
this.currentWaterHeating = nextWaterHeating;
|
|
546
|
+
this.currentBatteryLevel = nextBatteryLevel;
|
|
547
|
+
|
|
548
|
+
this.valveService.updateCharacteristic(
|
|
549
|
+
Characteristic.Active,
|
|
550
|
+
nextActive ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE
|
|
551
|
+
);
|
|
552
|
+
|
|
553
|
+
this.valveService.updateCharacteristic(
|
|
554
|
+
Characteristic.InUse,
|
|
555
|
+
nextActive ? Characteristic.InUse.IN_USE : Characteristic.InUse.NOT_IN_USE
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
this.valveService.updateCharacteristic(
|
|
559
|
+
Characteristic.StatusFault,
|
|
560
|
+
Characteristic.StatusFault.NO_FAULT
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
this.updatePermissionService(nextPermission);
|
|
564
|
+
|
|
565
|
+
if (this.batteryService) {
|
|
566
|
+
this.batteryService.updateCharacteristic(Characteristic.BatteryLevel, nextBatteryLevel);
|
|
567
|
+
this.batteryService.updateCharacteristic(
|
|
568
|
+
Characteristic.StatusLowBattery,
|
|
569
|
+
nextBatteryLevel <= this.lowWaterLevelThreshold
|
|
570
|
+
? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW
|
|
571
|
+
: Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL
|
|
572
|
+
);
|
|
573
|
+
this.batteryService.updateCharacteristic(
|
|
574
|
+
Characteristic.ChargingState,
|
|
575
|
+
nextWaterHeating
|
|
576
|
+
? Characteristic.ChargingState.CHARGING
|
|
577
|
+
: Characteristic.ChargingState.NOT_CHARGING
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async handleSetActive(value) {
|
|
583
|
+
const desired = Number(value) === Characteristic.Active.ACTIVE;
|
|
584
|
+
|
|
585
|
+
if (this.commandInFlight) {
|
|
586
|
+
this.log.warn('command already in flight; refreshing status instead');
|
|
587
|
+
await this.refreshStatus({ force: true });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
this.commandInFlight = true;
|
|
592
|
+
this.log.info(`HomeKit Valve set ${desired ? 'ACTIVE/ON' : 'INACTIVE/OFF'}; sending REAL warmthCharging=${desired}`);
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
if (desired) {
|
|
596
|
+
const latest = await this.refreshStatus({ force: true });
|
|
597
|
+
const allowed = latest &&
|
|
598
|
+
latest.warmthCharge &&
|
|
599
|
+
typeof latest.warmthCharge === 'object' &&
|
|
600
|
+
latest.warmthCharge.permission === true;
|
|
601
|
+
|
|
602
|
+
if (!allowed) {
|
|
603
|
+
this.log.warn('BLOCK warmthCharge permission=false_or_unknown');
|
|
604
|
+
this.currentReheating = false;
|
|
605
|
+
this.currentPermission = false;
|
|
606
|
+
this.valveService.updateCharacteristic(Characteristic.Active, Characteristic.Active.INACTIVE);
|
|
607
|
+
this.valveService.updateCharacteristic(Characteristic.InUse, Characteristic.InUse.NOT_IN_USE);
|
|
608
|
+
this.valveService.updateCharacteristic(Characteristic.StatusFault, Characteristic.StatusFault.NO_FAULT);
|
|
609
|
+
this.updatePermissionService(false);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const data = await this.putBathReheating(desired);
|
|
615
|
+
const result = Boolean(data && data.warmthCharge && data.warmthCharge.status);
|
|
616
|
+
|
|
617
|
+
this.log.info(`REAL command ok warmthCharge.status=${result}`);
|
|
618
|
+
|
|
619
|
+
this.statusCache = data;
|
|
620
|
+
this.statusCacheAt = Date.now();
|
|
621
|
+
this.applyStatus(data, 'homekit-command');
|
|
622
|
+
|
|
623
|
+
setTimeout(() => {
|
|
624
|
+
this.refreshStatus({ force: true }).catch(() => {});
|
|
625
|
+
}, 1500);
|
|
626
|
+
} catch (e) {
|
|
627
|
+
this.log.error(`set failed: ${this.sanitizeErrorText(e.message)}`);
|
|
628
|
+
await this.refreshStatus({ force: true });
|
|
629
|
+
throw e;
|
|
630
|
+
} finally {
|
|
631
|
+
this.commandInFlight = false;
|
|
632
|
+
this.scheduleNextPoll(this.currentReheating ? this.activePollSeconds : 2);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
PanasonicEcocuteWarmthChargeValve.prototype.createPermissionService = function() {
|
|
640
|
+
const sensorName = `${this.name} 可能`;
|
|
641
|
+
|
|
642
|
+
if (this.permissionSensorType === 'contact') {
|
|
643
|
+
const svc = new Service.ContactSensor(sensorName, 'warmth-charge-permission-contact');
|
|
644
|
+
svc.getCharacteristic(Characteristic.ContactSensorState)
|
|
645
|
+
.onGet(async () => {
|
|
646
|
+
await this.refreshStatus({ allowCache: true });
|
|
647
|
+
return this.currentPermission
|
|
648
|
+
? Characteristic.ContactSensorState.CONTACT_DETECTED
|
|
649
|
+
: Characteristic.ContactSensorState.CONTACT_NOT_DETECTED;
|
|
650
|
+
});
|
|
651
|
+
return svc;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (this.permissionSensorType === 'occupancy') {
|
|
655
|
+
const svc = new Service.OccupancySensor(sensorName, 'warmth-charge-permission-occupancy');
|
|
656
|
+
svc.getCharacteristic(Characteristic.OccupancyDetected)
|
|
657
|
+
.onGet(async () => {
|
|
658
|
+
await this.refreshStatus({ allowCache: true });
|
|
659
|
+
return this.currentPermission
|
|
660
|
+
? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED
|
|
661
|
+
: Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED;
|
|
662
|
+
});
|
|
663
|
+
return svc;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (this.permissionSensorType === 'motion') {
|
|
667
|
+
const svc = new Service.MotionSensor(sensorName, 'warmth-charge-permission-motion');
|
|
668
|
+
svc.getCharacteristic(Characteristic.MotionDetected)
|
|
669
|
+
.onGet(async () => {
|
|
670
|
+
await this.refreshStatus({ allowCache: true });
|
|
671
|
+
return Boolean(this.currentPermission);
|
|
672
|
+
});
|
|
673
|
+
return svc;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (this.permissionSensorType === 'leak') {
|
|
677
|
+
const svc = new Service.LeakSensor(sensorName, 'warmth-charge-permission-leak');
|
|
678
|
+
svc.getCharacteristic(Characteristic.LeakDetected)
|
|
679
|
+
.onGet(async () => {
|
|
680
|
+
await this.refreshStatus({ allowCache: true });
|
|
681
|
+
return this.currentPermission
|
|
682
|
+
? Characteristic.LeakDetected.LEAK_DETECTED
|
|
683
|
+
: Characteristic.LeakDetected.LEAK_NOT_DETECTED;
|
|
684
|
+
});
|
|
685
|
+
return svc;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return null;
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
PanasonicEcocuteWarmthChargeValve.prototype.updatePermissionService = function(value) {
|
|
692
|
+
if (!this.permissionService) return;
|
|
693
|
+
|
|
694
|
+
if (this.permissionSensorType === 'contact') {
|
|
695
|
+
this.permissionService.updateCharacteristic(
|
|
696
|
+
Characteristic.ContactSensorState,
|
|
697
|
+
value
|
|
698
|
+
? Characteristic.ContactSensorState.CONTACT_DETECTED
|
|
699
|
+
: Characteristic.ContactSensorState.CONTACT_NOT_DETECTED
|
|
700
|
+
);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (this.permissionSensorType === 'occupancy') {
|
|
705
|
+
this.permissionService.updateCharacteristic(
|
|
706
|
+
Characteristic.OccupancyDetected,
|
|
707
|
+
value
|
|
708
|
+
? Characteristic.OccupancyDetected.OCCUPANCY_DETECTED
|
|
709
|
+
: Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
|
|
710
|
+
);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (this.permissionSensorType === 'motion') {
|
|
715
|
+
this.permissionService.updateCharacteristic(Characteristic.MotionDetected, Boolean(value));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (this.permissionSensorType === 'leak') {
|
|
720
|
+
this.permissionService.updateCharacteristic(
|
|
721
|
+
Characteristic.LeakDetected,
|
|
722
|
+
value
|
|
723
|
+
? Characteristic.LeakDetected.LEAK_DETECTED
|
|
724
|
+
: Characteristic.LeakDetected.LEAK_NOT_DETECTED
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
PanasonicEcocuteWarmthChargeValve.prototype.getServices = function() {
|
|
730
|
+
const baseServices = PanasonicEcocuteBathReheatingValve.prototype.getServices.call(this);
|
|
731
|
+
return [
|
|
732
|
+
...baseServices,
|
|
733
|
+
...(this.permissionService ? [this.permissionService] : [])
|
|
734
|
+
];
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
class PanasonicEcocuteBathExtra {
|
|
739
|
+
constructor(log, config) {
|
|
740
|
+
const cfg = config || {};
|
|
741
|
+
const commandType = String(cfg.commandType || cfg.commandKind || 'warmthCharge').toLowerCase();
|
|
742
|
+
|
|
743
|
+
if (commandType === 'bathreheating' || commandType === 'bath_reheating' || commandType === 'reheating') {
|
|
744
|
+
return new PanasonicEcocuteBathReheatingValve(log, Object.assign({}, cfg, { commandType: 'bathReheating' }));
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (commandType !== 'warmthcharge' && commandType !== 'warmth_charge') {
|
|
748
|
+
log.warn(`unknown commandType=${commandType}; using warmthCharge`);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return new PanasonicEcocuteWarmthChargeValve(log, Object.assign({}, cfg, { commandType: 'warmthCharge' }));
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
class PanasonicEcocuteBathExtraPlatform {
|
|
757
|
+
constructor(log, config) {
|
|
758
|
+
this.log = log;
|
|
759
|
+
this.config = config || {};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
childLog(name) {
|
|
763
|
+
const parent = this.log;
|
|
764
|
+
const prefix = "[" + String(name || "Accessory") + "] ";
|
|
765
|
+
const fmt = (args) => prefix + Array.from(args).map((v) => typeof v === "string" ? v : JSON.stringify(v)).join(" ");
|
|
766
|
+
const child = function() {
|
|
767
|
+
if (typeof parent === "function") return parent(fmt(arguments));
|
|
768
|
+
if (parent && typeof parent.info === "function") return parent.info(fmt(arguments));
|
|
769
|
+
};
|
|
770
|
+
for (const level of ["info","warn","error","debug","success"]) {
|
|
771
|
+
child[level] = function() {
|
|
772
|
+
if (parent && typeof parent[level] === "function") return parent[level](fmt(arguments));
|
|
773
|
+
return child.apply(null, arguments);
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
return child;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
accessories(callback) {
|
|
780
|
+
const commonKeys = ["apiHost","deviceId","bearerToken","statusPollSeconds","activePollSeconds","statusCacheTtlSeconds","statusRetryDelayMs","statusErrorLogThrottleSeconds","lowWaterLevelThreshold","batteryLevelMode"];
|
|
781
|
+
const common = {};
|
|
782
|
+
for (const key of commonKeys) {
|
|
783
|
+
if (this.config[key] !== undefined) common[key] = this.config[key];
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (this.config.advancedStatus && typeof this.config.advancedStatus === "object") {
|
|
787
|
+
Object.assign(common, this.config.advancedStatus);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const commands = Array.isArray(this.config.commands) ? this.config.commands : [];
|
|
791
|
+
const accessories = commands.map((cmd) => {
|
|
792
|
+
const cfg = Object.assign({}, common, cmd || {});
|
|
793
|
+
const type = String(cfg.commandType || "warmthCharge").toLowerCase();
|
|
794
|
+
if (type === "bathreheating" || type === "bath_reheating" || type === "reheating") {
|
|
795
|
+
cfg.commandType = "bathReheating";
|
|
796
|
+
return new PanasonicEcocuteBathReheatingValve(this.childLog(cfg.name || "Bath Reheating"), cfg);
|
|
797
|
+
}
|
|
798
|
+
cfg.commandType = "warmthCharge";
|
|
799
|
+
return new PanasonicEcocuteWarmthChargeValve(this.childLog(cfg.name || "Warmth Charge"), cfg);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
this.log.info(`platform created ${accessories.length} bath extra accessories`);
|
|
803
|
+
callback(accessories);
|
|
804
|
+
}
|
|
805
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-panasonic-ecocute-bath-extra-command",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Homebridge platform for Panasonic Ecocute bath extra commands via Panasonic Cloud API",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"homebridge-plugin",
|
|
8
|
+
"homebridge",
|
|
9
|
+
"panasonic",
|
|
10
|
+
"ecocute",
|
|
11
|
+
"bath",
|
|
12
|
+
"reheating",
|
|
13
|
+
"valve"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"homebridge": ">=1.6.0",
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"README.md",
|
|
22
|
+
"index.js",
|
|
23
|
+
"config.schema.json",
|
|
24
|
+
"docs/images/homekit-bath-extra-room-tiles.jpg",
|
|
25
|
+
"docs/images/homekit-warmth-charge-control.jpg",
|
|
26
|
+
"docs/images/homekit-reheating-active-detail.jpg"
|
|
27
|
+
]
|
|
28
|
+
}
|