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
+ ![HomeKit bath extra room tiles](docs/images/homekit-bath-extra-room-tiles.jpg)
105
+
106
+ ![Warmth Charge control](docs/images/homekit-warmth-charge-control.jpg)
107
+
108
+ ![Bath Reheating active detail](docs/images/homekit-reheating-active-detail.jpg)
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
+ }
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
+ }