homebridge-aiseg-awning-window-command 0.2.2
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/LICENSE +21 -0
- package/README.md +62 -0
- package/config.schema.json +121 -0
- package/index.js +583 -0
- package/package.json +26 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the Software), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# homebridge-aiseg-awning-window-command
|
|
2
|
+
|
|
3
|
+
Homebridge plugin for AiSEG2 awning windows
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
This is a command-based Homebridge platform plugin for AiSEG-controlled devices.
|
|
8
|
+
|
|
9
|
+
The plugin calls external commands for actions and optional status polling.
|
|
10
|
+
It is designed for environments where a separate script can already operate the target AiSEG device.
|
|
11
|
+
|
|
12
|
+
## Requirements
|
|
13
|
+
|
|
14
|
+
- Homebridge 2.x
|
|
15
|
+
- Node.js 22 or later
|
|
16
|
+
- Working command or script for device operation
|
|
17
|
+
- Optional status command for state polling
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g homebridge-aiseg-awning-window-command
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Configuration
|
|
26
|
+
|
|
27
|
+
Use the Homebridge UI to configure this plugin.
|
|
28
|
+
|
|
29
|
+
Typical settings include:
|
|
30
|
+
|
|
31
|
+
- display name
|
|
32
|
+
- command path
|
|
33
|
+
- open / close / stop / position commands where supported
|
|
34
|
+
- status command
|
|
35
|
+
- polling interval
|
|
36
|
+
- movement guard / transition timing where supported
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
This plugin does not include AiSEG login or operation scripts.
|
|
41
|
+
Prepare and test the command scripts before configuring Homebridge.
|
|
42
|
+
|
|
43
|
+
A command should be tested manually first, for example:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python3 /path/to/script.py target status
|
|
47
|
+
python3 /path/to/script.py target open
|
|
48
|
+
python3 /path/to/script.py target close
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Troubleshooting
|
|
52
|
+
|
|
53
|
+
If the accessory does not respond:
|
|
54
|
+
|
|
55
|
+
1. Run the configured command manually.
|
|
56
|
+
2. Confirm the command path is correct inside the Homebridge environment.
|
|
57
|
+
3. Confirm the Homebridge container has permission to execute the command.
|
|
58
|
+
4. Check Homebridge logs for command timeout or stderr output.
|
|
59
|
+
|
|
60
|
+
## Disclaimer
|
|
61
|
+
|
|
62
|
+
This project is not affiliated with Apple, Homebridge, Panasonic, or AiSEG.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "AiSegAwningWindowCommand",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"schema": {
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"name": {
|
|
9
|
+
"title": "名前",
|
|
10
|
+
"type": "string",
|
|
11
|
+
"default": "AiSEG Awning Windows",
|
|
12
|
+
"description": "Homebridge上で表示する名前を入力します。"
|
|
13
|
+
},
|
|
14
|
+
"host": {
|
|
15
|
+
"title": "AiSEG IPアドレス",
|
|
16
|
+
"description": "AiSEG本体のIPアドレスまたはホスト名を入力します。公開版では既定値を設定していません。",
|
|
17
|
+
"type": "string"
|
|
18
|
+
},
|
|
19
|
+
"deviceId": {
|
|
20
|
+
"title": "デバイスID",
|
|
21
|
+
"description": "通常は 3261です。必要な場合のみ変更します。",
|
|
22
|
+
"type": "string",
|
|
23
|
+
"default": "3261"
|
|
24
|
+
},
|
|
25
|
+
"eoj": {
|
|
26
|
+
"title": "EOJ(機器種別)",
|
|
27
|
+
"description": "AiSEG2のEOJです。通常は 0x026501 です。",
|
|
28
|
+
"type": "string",
|
|
29
|
+
"default": "0x026501"
|
|
30
|
+
},
|
|
31
|
+
"password": {
|
|
32
|
+
"title": "パスワード",
|
|
33
|
+
"description": "AiSEGのパスワードを入力します。公開版では既定値を設定していません。",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"format": "password"
|
|
36
|
+
},
|
|
37
|
+
"pollIntervalSeconds": {
|
|
38
|
+
"title": "状態確認間隔",
|
|
39
|
+
"description": "状態を確認する間隔を秒単位で指定します。短くしすぎると機器やネットワークの負荷が増える場合があります。",
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"default": 5,
|
|
42
|
+
"minimum": 1
|
|
43
|
+
},
|
|
44
|
+
"motionGuardSeconds": {
|
|
45
|
+
"title": "動作中ガード時間 秒",
|
|
46
|
+
"description": "開閉操作中に古い状態通知を無視するためのガード時間です。",
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"default": 5,
|
|
49
|
+
"minimum": 0
|
|
50
|
+
},
|
|
51
|
+
"openAcceptDelaySeconds": {
|
|
52
|
+
"title": "開操作",
|
|
53
|
+
"description": "開操作に関する設定です。",
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"default": 45,
|
|
56
|
+
"minimum": 0
|
|
57
|
+
},
|
|
58
|
+
"tiltMotionGuardSeconds": {
|
|
59
|
+
"title": "角度操作ガード時間 秒",
|
|
60
|
+
"description": "角度操作中に古い状態通知を無視するための最大ガード時間です。",
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"default": 65,
|
|
63
|
+
"minimum": 0
|
|
64
|
+
},
|
|
65
|
+
"tiltVentAcceptDelaySeconds": {
|
|
66
|
+
"title": "半開状態受け入れ待機時間 秒",
|
|
67
|
+
"description": "半開・換気状態を角度操作完了として受け入れるまでの最小待機時間です。",
|
|
68
|
+
"type": "integer",
|
|
69
|
+
"default": 12,
|
|
70
|
+
"minimum": 0
|
|
71
|
+
},
|
|
72
|
+
"commandTimeoutSeconds": {
|
|
73
|
+
"title": "タイムアウト",
|
|
74
|
+
"description": "コマンドや通信のタイムアウト時間を秒単位で指定します。",
|
|
75
|
+
"type": "integer",
|
|
76
|
+
"minimum": 1
|
|
77
|
+
},
|
|
78
|
+
"stopSwitchAutoOffMs": {
|
|
79
|
+
"title": "停止操作",
|
|
80
|
+
"description": "停止操作に関する設定です。",
|
|
81
|
+
"type": "integer",
|
|
82
|
+
"default": 1000,
|
|
83
|
+
"minimum": 100
|
|
84
|
+
},
|
|
85
|
+
"windows": {
|
|
86
|
+
"title": "窓",
|
|
87
|
+
"type": "array",
|
|
88
|
+
"items": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"name": {
|
|
92
|
+
"title": "名前",
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Homebridge上で表示する名前を入力します。"
|
|
95
|
+
},
|
|
96
|
+
"aisegId": {
|
|
97
|
+
"title": "Id",
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "このオーニング窓のAiSEG2固有IDを入力します。HomeKit用の一意IDはこの値から自動生成されます。"
|
|
100
|
+
},
|
|
101
|
+
"showStopSwitch": {
|
|
102
|
+
"title": "停止操作",
|
|
103
|
+
"type": "boolean",
|
|
104
|
+
"default": true,
|
|
105
|
+
"description": "停止操作に関する設定です。"
|
|
106
|
+
},
|
|
107
|
+
"stopSwitchName": {
|
|
108
|
+
"title": "停止操作",
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "停止操作に関する設定です。"
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"required": [
|
|
114
|
+
"name",
|
|
115
|
+
"aisegId"
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { exec } = require("child_process");
|
|
4
|
+
|
|
5
|
+
const PLUGIN_NAME = "homebridge-aiseg-awning-window-command";
|
|
6
|
+
const PLATFORM_NAME = "AiSegAwningWindowCommand";
|
|
7
|
+
|
|
8
|
+
module.exports = (api) => {
|
|
9
|
+
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, AiSegAwningWindowPlatform);
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
class AiSegAwningWindowPlatform {
|
|
13
|
+
constructor(log, config, api) {
|
|
14
|
+
this.log = log;
|
|
15
|
+
this.config = config || {};
|
|
16
|
+
this.api = api;
|
|
17
|
+
this.Service = api.hap.Service;
|
|
18
|
+
this.Characteristic = api.hap.Characteristic;
|
|
19
|
+
this.cachedAccessories = [];
|
|
20
|
+
|
|
21
|
+
this.api.on("didFinishLaunching", () => {
|
|
22
|
+
this.discoverDevices();
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
configureAccessory(accessory) {
|
|
27
|
+
this.cachedAccessories.push(accessory);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
discoverDevices() {
|
|
31
|
+
const windows = Array.isArray(this.config.windows) ? this.config.windows : [];
|
|
32
|
+
const activeUUIDs = new Set();
|
|
33
|
+
|
|
34
|
+
for (const cfg of windows) {
|
|
35
|
+
const aisegId = getWindowAisegId(cfg);
|
|
36
|
+
const name = cfg.name || aisegId || "AiSEG Window";
|
|
37
|
+
|
|
38
|
+
if (!aisegId) {
|
|
39
|
+
this.log.warn(`[${name}] skipped because Id is missing`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const windowUUID = this.api.hap.uuid.generate(`${PLUGIN_NAME}:window:${aisegId}`);
|
|
44
|
+
activeUUIDs.add(windowUUID);
|
|
45
|
+
|
|
46
|
+
let windowAccessory = this.cachedAccessories.find((a) => a.UUID === windowUUID);
|
|
47
|
+
const isNewWindow = !windowAccessory;
|
|
48
|
+
|
|
49
|
+
if (!windowAccessory) {
|
|
50
|
+
windowAccessory = new this.api.platformAccessory(name, windowUUID);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
windowAccessory.context.plugin = PLUGIN_NAME;
|
|
54
|
+
windowAccessory.context.kind = "window";
|
|
55
|
+
windowAccessory.context.aisegId = aisegId;
|
|
56
|
+
|
|
57
|
+
new AwningWindow(this, windowAccessory, cfg);
|
|
58
|
+
|
|
59
|
+
if (isNewWindow) {
|
|
60
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [windowAccessory]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (cfg.showStopSwitch !== false) {
|
|
64
|
+
const stopName = cfg.stopSwitchName || `${name} 停止`;
|
|
65
|
+
const stopUUID = this.api.hap.uuid.generate(`${PLUGIN_NAME}:stop:${aisegId}`);
|
|
66
|
+
activeUUIDs.add(stopUUID);
|
|
67
|
+
|
|
68
|
+
let stopAccessory = this.cachedAccessories.find((a) => a.UUID === stopUUID);
|
|
69
|
+
const isNewStop = !stopAccessory;
|
|
70
|
+
|
|
71
|
+
if (!stopAccessory) {
|
|
72
|
+
stopAccessory = new this.api.platformAccessory(stopName, stopUUID);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stopAccessory.context.plugin = PLUGIN_NAME;
|
|
76
|
+
stopAccessory.context.kind = "stop";
|
|
77
|
+
stopAccessory.context.aisegId = aisegId;
|
|
78
|
+
|
|
79
|
+
new StopSwitch(this, stopAccessory, cfg, stopName);
|
|
80
|
+
|
|
81
|
+
if (isNewStop) {
|
|
82
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [stopAccessory]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stale = this.cachedAccessories.filter((a) => {
|
|
88
|
+
return a.context && a.context.plugin === PLUGIN_NAME && !activeUUIDs.has(a.UUID);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (stale.length > 0) {
|
|
92
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.log.info(`Configured ${windows.length} AiSEG awning window(s)`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class AwningWindow {
|
|
100
|
+
constructor(platform, accessory, config) {
|
|
101
|
+
this.platform = platform;
|
|
102
|
+
this.log = platform.log;
|
|
103
|
+
this.Service = platform.Service;
|
|
104
|
+
this.Characteristic = platform.Characteristic;
|
|
105
|
+
|
|
106
|
+
this.accessory = accessory;
|
|
107
|
+
this.config = config;
|
|
108
|
+
this.name = config.name || getWindowAisegId(config) || "AiSEG Window";
|
|
109
|
+
|
|
110
|
+
this.pollIntervalSeconds = numberOr(config.pollIntervalSeconds, platform.config.pollIntervalSeconds, 5);
|
|
111
|
+
this.commandTimeoutSeconds = numberOr(config.commandTimeoutSeconds, platform.config.commandTimeoutSeconds, 20);
|
|
112
|
+
this.motionGuardSeconds = numberOr(config.motionGuardSeconds, platform.config.motionGuardSeconds, 5);
|
|
113
|
+
this.openAcceptDelaySeconds = numberOr(config.openAcceptDelaySeconds, platform.config.openAcceptDelaySeconds, 45);
|
|
114
|
+
this.tiltMotionGuardSeconds = numberOr(config.tiltMotionGuardSeconds, platform.config.tiltMotionGuardSeconds, 65);
|
|
115
|
+
this.tiltVentAcceptDelaySeconds = numberOr(config.tiltVentAcceptDelaySeconds, platform.config.tiltVentAcceptDelaySeconds, 12);
|
|
116
|
+
|
|
117
|
+
this.currentPosition = 0;
|
|
118
|
+
this.targetPosition = 0;
|
|
119
|
+
this.currentTilt = 0;
|
|
120
|
+
this.targetTilt = 0;
|
|
121
|
+
this.lastHalfTilt = 30;
|
|
122
|
+
this.positionState = this.Characteristic.PositionState.STOPPED;
|
|
123
|
+
|
|
124
|
+
this.expectedState = null;
|
|
125
|
+
this.motionStartedAt = 0;
|
|
126
|
+
this.motionUntil = 0;
|
|
127
|
+
this.lastUnknownStatus = "";
|
|
128
|
+
|
|
129
|
+
this.setup();
|
|
130
|
+
|
|
131
|
+
this.pollStatus();
|
|
132
|
+
|
|
133
|
+
if (this.pollIntervalSeconds > 0) {
|
|
134
|
+
setInterval(() => this.pollStatus(), this.pollIntervalSeconds * 1000);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setup() {
|
|
139
|
+
this.accessory.getService(this.Service.AccessoryInformation)
|
|
140
|
+
.setCharacteristic(this.Characteristic.Manufacturer, "AiSEG2")
|
|
141
|
+
.setCharacteristic(this.Characteristic.Model, "Awning Window Command")
|
|
142
|
+
.setCharacteristic(this.Characteristic.SerialNumber, getWindowAisegId(this.config) || this.name);
|
|
143
|
+
|
|
144
|
+
this.service =
|
|
145
|
+
this.accessory.getService(this.Service.WindowCovering) ||
|
|
146
|
+
this.accessory.addService(this.Service.WindowCovering, this.name);
|
|
147
|
+
|
|
148
|
+
this.service.setCharacteristic(this.Characteristic.Name, this.name);
|
|
149
|
+
|
|
150
|
+
this.service.getCharacteristic(this.Characteristic.CurrentPosition)
|
|
151
|
+
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
|
|
152
|
+
.onGet(() => this.currentPosition);
|
|
153
|
+
|
|
154
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition)
|
|
155
|
+
.setProps({ minValue: 0, maxValue: 100, minStep: 1 })
|
|
156
|
+
.onGet(() => this.targetPosition)
|
|
157
|
+
.onSet(async (value) => this.setTargetPosition(value));
|
|
158
|
+
|
|
159
|
+
this.service.getCharacteristic(this.Characteristic.PositionState)
|
|
160
|
+
.onGet(() => this.positionState);
|
|
161
|
+
|
|
162
|
+
this.service.getCharacteristic(this.Characteristic.CurrentHorizontalTiltAngle)
|
|
163
|
+
.setProps({ minValue: 0, maxValue: 60, minStep: 15 })
|
|
164
|
+
.onGet(() => this.currentTilt);
|
|
165
|
+
|
|
166
|
+
this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle)
|
|
167
|
+
.setProps({ minValue: 0, maxValue: 60, minStep: 15 })
|
|
168
|
+
.onGet(() => this.targetTilt)
|
|
169
|
+
.onSet(async (value) => this.setTargetTilt(value));
|
|
170
|
+
|
|
171
|
+
this.updateHomeKit();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async setTargetPosition(value) {
|
|
175
|
+
const raw = clamp(Number(value), 0, 100);
|
|
176
|
+
const normalized = raw <= 50 ? 0 : 100;
|
|
177
|
+
|
|
178
|
+
this.log.info(`[${this.name}] TargetPosition ${raw} -> ${normalized}`);
|
|
179
|
+
|
|
180
|
+
this.targetPosition = normalized;
|
|
181
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(normalized);
|
|
182
|
+
|
|
183
|
+
if (normalized === 0) {
|
|
184
|
+
this.targetTilt = 0;
|
|
185
|
+
this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle).updateValue(0);
|
|
186
|
+
this.startMotion("closed", this.motionGuardSeconds, 0);
|
|
187
|
+
await this.runAction("close", "close");
|
|
188
|
+
} else {
|
|
189
|
+
this.targetTilt = 60;
|
|
190
|
+
this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle).updateValue(60);
|
|
191
|
+
this.startMotion("open", Math.max(this.motionGuardSeconds, this.openAcceptDelaySeconds), 60);
|
|
192
|
+
await this.runAction("open", "open");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async setTargetTilt(value) {
|
|
197
|
+
const raw = clamp(Number(value), 0, 60);
|
|
198
|
+
const snapped = snapTilt(raw);
|
|
199
|
+
|
|
200
|
+
this.log.info(`[${this.name}] TargetHorizontalTiltAngle ${raw} -> ${snapped}`);
|
|
201
|
+
|
|
202
|
+
this.targetTilt = snapped;
|
|
203
|
+
this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle).updateValue(snapped);
|
|
204
|
+
|
|
205
|
+
if (snapped === 0) {
|
|
206
|
+
this.targetPosition = 0;
|
|
207
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(0);
|
|
208
|
+
this.startMotion("closed", this.motionGuardSeconds, 0);
|
|
209
|
+
await this.runAction("close", "tilt 0 / close");
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (snapped === 60) {
|
|
214
|
+
this.targetPosition = 100;
|
|
215
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(100);
|
|
216
|
+
this.startMotion("open", Math.max(this.motionGuardSeconds, this.openAcceptDelaySeconds), 60);
|
|
217
|
+
await this.runAction("open", "tilt 60 / open");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.lastHalfTilt = snapped;
|
|
222
|
+
this.targetPosition = 50;
|
|
223
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(50);
|
|
224
|
+
|
|
225
|
+
if (snapped === 15) {
|
|
226
|
+
this.startMotion("angle1", this.tiltMotionGuardSeconds, 15);
|
|
227
|
+
await this.runAction("angle1", "angle1");
|
|
228
|
+
} else if (snapped === 30) {
|
|
229
|
+
this.startMotion("angle2", this.tiltMotionGuardSeconds, 30);
|
|
230
|
+
await this.runAction("angle2", "angle2");
|
|
231
|
+
} else {
|
|
232
|
+
this.startMotion("angle3", this.tiltMotionGuardSeconds, 45);
|
|
233
|
+
await this.runAction("angle3", "angle3");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
startMotion(expectedState, guardSeconds, expectedTilt) {
|
|
238
|
+
this.expectedState = expectedState;
|
|
239
|
+
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
this.motionStartedAt = now;
|
|
242
|
+
const seconds = numberOr(guardSeconds, this.motionGuardSeconds, 5);
|
|
243
|
+
this.motionUntil = now + seconds * 1000;
|
|
244
|
+
|
|
245
|
+
const expectedPosition =
|
|
246
|
+
expectedState === "closed" ? 0 :
|
|
247
|
+
expectedState === "open" ? 100 :
|
|
248
|
+
50;
|
|
249
|
+
|
|
250
|
+
const numericExpectedTilt = Number(expectedTilt);
|
|
251
|
+
const isTiltMotion =
|
|
252
|
+
expectedState === "angle1" ||
|
|
253
|
+
expectedState === "angle2" ||
|
|
254
|
+
expectedState === "angle3";
|
|
255
|
+
|
|
256
|
+
if (expectedPosition > this.currentPosition) {
|
|
257
|
+
this.positionState = this.Characteristic.PositionState.INCREASING;
|
|
258
|
+
} else if (expectedPosition < this.currentPosition) {
|
|
259
|
+
this.positionState = this.Characteristic.PositionState.DECREASING;
|
|
260
|
+
} else if (Number.isFinite(numericExpectedTilt) && numericExpectedTilt > this.currentTilt) {
|
|
261
|
+
this.positionState = this.Characteristic.PositionState.INCREASING;
|
|
262
|
+
} else if (Number.isFinite(numericExpectedTilt) && numericExpectedTilt < this.currentTilt) {
|
|
263
|
+
this.positionState = this.Characteristic.PositionState.DECREASING;
|
|
264
|
+
} else {
|
|
265
|
+
this.positionState = this.Characteristic.PositionState.STOPPED;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.targetPosition = expectedPosition;
|
|
269
|
+
|
|
270
|
+
if (isTiltMotion && this.currentPosition === expectedPosition) {
|
|
271
|
+
if (this.positionState === this.Characteristic.PositionState.INCREASING) {
|
|
272
|
+
this.currentPosition = 49;
|
|
273
|
+
} else if (this.positionState === this.Characteristic.PositionState.DECREASING) {
|
|
274
|
+
this.currentPosition = 51;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
this.service.getCharacteristic(this.Characteristic.CurrentPosition).updateValue(this.currentPosition);
|
|
279
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(this.targetPosition);
|
|
280
|
+
this.service.getCharacteristic(this.Characteristic.PositionState).updateValue(this.positionState);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async pollStatus() {
|
|
284
|
+
try {
|
|
285
|
+
const stdout = await this.runAction("status", "status", true);
|
|
286
|
+
const parsed = this.parseStatus(stdout);
|
|
287
|
+
|
|
288
|
+
if (!parsed) {
|
|
289
|
+
const text = String(stdout || "").trim();
|
|
290
|
+
if (text && text !== this.lastUnknownStatus) {
|
|
291
|
+
this.lastUnknownStatus = text;
|
|
292
|
+
this.log.warn(`[${this.name}] Unknown status: ${text}`);
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.lastUnknownStatus = "";
|
|
298
|
+
this.applyStatus(parsed);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
this.log.warn(`[${this.name}] status failed: ${err.message}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
parseStatus(stdout) {
|
|
305
|
+
const text = String(stdout || "").trim().toLowerCase();
|
|
306
|
+
|
|
307
|
+
if (!text || text === "unknown") {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (text.includes("angle1")) {
|
|
312
|
+
return { state: "angle1", pos: 50, tilt: 15, target: 50 };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (text.includes("angle2")) {
|
|
316
|
+
return { state: "angle2", pos: 50, tilt: 30, target: 50 };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (text.includes("angle3")) {
|
|
320
|
+
return { state: "angle3", pos: 50, tilt: 45, target: 50 };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (text.includes("closed") || text === "close") {
|
|
324
|
+
return { state: "closed", pos: 0, tilt: 0, target: 0 };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (text.includes("open")) {
|
|
328
|
+
return { state: "open", pos: 100, tilt: 60, target: 100 };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (text.includes("vent") || text.includes("half") || text.includes("tilt") || text.includes("angle")) {
|
|
332
|
+
return { state: "vent", pos: 50, tilt: this.lastHalfTilt || 30, target: 50 };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
applyStatus(parsed) {
|
|
339
|
+
if (Date.now() < this.motionUntil && this.expectedState) {
|
|
340
|
+
if (!this.acceptStatusDuringMotion(parsed.state, this.expectedState)) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.expectedState = null;
|
|
346
|
+
this.motionUntil = 0;
|
|
347
|
+
|
|
348
|
+
this.currentPosition = parsed.pos;
|
|
349
|
+
this.targetPosition = parsed.target;
|
|
350
|
+
this.currentTilt = parsed.tilt;
|
|
351
|
+
this.targetTilt = parsed.tilt;
|
|
352
|
+
this.positionState = this.Characteristic.PositionState.STOPPED;
|
|
353
|
+
|
|
354
|
+
if (parsed.pos === 50 && parsed.tilt > 0) {
|
|
355
|
+
this.lastHalfTilt = parsed.tilt;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.updateHomeKit();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
acceptStatusDuringMotion(actual, expected) {
|
|
362
|
+
if (expected === "closed") {
|
|
363
|
+
return actual === "closed";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (expected === "open") {
|
|
367
|
+
const elapsedSeconds = (Date.now() - (this.motionStartedAt || 0)) / 1000;
|
|
368
|
+
return actual === "open" && elapsedSeconds >= this.openAcceptDelaySeconds;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const elapsedSeconds = (Date.now() - (this.motionStartedAt || 0)) / 1000;
|
|
372
|
+
const acceptVent =
|
|
373
|
+
actual === "vent" &&
|
|
374
|
+
elapsedSeconds >= this.tiltVentAcceptDelaySeconds;
|
|
375
|
+
|
|
376
|
+
if (expected === "angle1") {
|
|
377
|
+
return actual === "angle1" || acceptVent;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (expected === "angle2") {
|
|
381
|
+
return actual === "angle2" || acceptVent;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (expected === "angle3") {
|
|
385
|
+
return actual === "angle3" || acceptVent;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
updateHomeKit() {
|
|
392
|
+
this.service.getCharacteristic(this.Characteristic.CurrentPosition).updateValue(this.currentPosition);
|
|
393
|
+
this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(this.targetPosition);
|
|
394
|
+
this.service.getCharacteristic(this.Characteristic.PositionState).updateValue(this.positionState);
|
|
395
|
+
this.service.getCharacteristic(this.Characteristic.CurrentHorizontalTiltAngle).updateValue(this.currentTilt);
|
|
396
|
+
this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle).updateValue(this.targetTilt);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async runAction(action, label, returnStdout) {
|
|
400
|
+
const command = buildAisegWindowCommand(getWindowScriptKey(this.config), action);
|
|
401
|
+
const env = buildAisegEnv(this.platform.config, this.config);
|
|
402
|
+
|
|
403
|
+
if (!returnStdout) {
|
|
404
|
+
this.log.info(`[${this.name}] run ${label}: ${command}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const stdout = await runCommand(command, this.commandTimeoutSeconds, env);
|
|
408
|
+
return stdout;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
class StopSwitch {
|
|
413
|
+
constructor(platform, accessory, config, name) {
|
|
414
|
+
this.platform = platform;
|
|
415
|
+
this.log = platform.log;
|
|
416
|
+
this.Service = platform.Service;
|
|
417
|
+
this.Characteristic = platform.Characteristic;
|
|
418
|
+
this.accessory = accessory;
|
|
419
|
+
this.config = config;
|
|
420
|
+
this.name = name;
|
|
421
|
+
|
|
422
|
+
this.commandTimeoutSeconds = numberOr(config.commandTimeoutSeconds, platform.config.commandTimeoutSeconds, 20);
|
|
423
|
+
this.autoOffMs = numberOr(config.stopSwitchAutoOffMs, platform.config.stopSwitchAutoOffMs, 1000);
|
|
424
|
+
|
|
425
|
+
this.setup();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
setup() {
|
|
429
|
+
this.accessory.getService(this.Service.AccessoryInformation)
|
|
430
|
+
.setCharacteristic(this.Characteristic.Manufacturer, "AiSEG2")
|
|
431
|
+
.setCharacteristic(this.Characteristic.Model, "Awning Window Stop Switch")
|
|
432
|
+
.setCharacteristic(this.Characteristic.SerialNumber, `${getWindowAisegId(this.config) || this.name}-stop`);
|
|
433
|
+
|
|
434
|
+
this.service =
|
|
435
|
+
this.accessory.getService(this.Service.Switch) ||
|
|
436
|
+
this.accessory.addService(this.Service.Switch, this.name);
|
|
437
|
+
|
|
438
|
+
this.service.setCharacteristic(this.Characteristic.Name, this.name);
|
|
439
|
+
|
|
440
|
+
this.service.getCharacteristic(this.Characteristic.On)
|
|
441
|
+
.onGet(() => false)
|
|
442
|
+
.onSet(async (value) => {
|
|
443
|
+
if (value === true) {
|
|
444
|
+
await this.stop();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
this.service.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async stop() {
|
|
452
|
+
const command = buildAisegWindowCommand(getWindowScriptKey(this.config), "stop");
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
this.log.info(`[${this.name}] run stop: ${command}`);
|
|
456
|
+
await runCommand(command, this.commandTimeoutSeconds, buildAisegEnv(this.platform.config, this.config));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
this.log.error(`[${this.name}] stop failed: ${err.message}`);
|
|
459
|
+
} finally {
|
|
460
|
+
setTimeout(() => {
|
|
461
|
+
this.service.getCharacteristic(this.Characteristic.On).updateValue(false);
|
|
462
|
+
}, this.autoOffMs);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function getWindowAisegId(config) {
|
|
468
|
+
return String((config && (config.aisegId || config.id)) || "").trim();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function getWindowScriptKey(config) {
|
|
472
|
+
if (!config) {
|
|
473
|
+
return "";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const explicit = String(config.scriptKey || "").trim();
|
|
477
|
+
if (explicit) {
|
|
478
|
+
return explicit;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const name = String(config.name || "").toLowerCase();
|
|
482
|
+
|
|
483
|
+
if (name.includes("東") || name.includes("east")) {
|
|
484
|
+
return "east";
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (name.includes("西") || name.includes("west")) {
|
|
488
|
+
return "west";
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return getWindowAisegId(config);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function buildAisegWindowCommand(scriptKey, action) {
|
|
495
|
+
return `python3 /homebridge/scripts/aiseg_window.py ${shellQuote(scriptKey)} ${shellQuote(action)}`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function buildAisegEnv(platformConfig, windowConfig) {
|
|
499
|
+
const env = Object.assign({}, process.env);
|
|
500
|
+
|
|
501
|
+
env.AISEG_USER = "aiseg";
|
|
502
|
+
|
|
503
|
+
const host = platformConfig && platformConfig.host;
|
|
504
|
+
if (host !== undefined && host !== null && String(host) !== "") {
|
|
505
|
+
env.AISEG_HOST = String(host);
|
|
506
|
+
env.AISEG_IP = String(host);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const password = platformConfig && platformConfig.password;
|
|
510
|
+
if (password !== undefined && password !== null && String(password) !== "") {
|
|
511
|
+
env.AISEG_PASSWORD = String(password);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
env.AISEG_DEVICE_ID = String((platformConfig && platformConfig.deviceId) || "3261");
|
|
515
|
+
env.AISEG_EOJ = String((platformConfig && platformConfig.eoj) || "0x026501");
|
|
516
|
+
|
|
517
|
+
const aisegId = getWindowAisegId(windowConfig);
|
|
518
|
+
if (aisegId) {
|
|
519
|
+
env.AISEG_ID = aisegId;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return env;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function runCommand(command, timeoutSeconds, env) {
|
|
526
|
+
return new Promise((resolve, reject) => {
|
|
527
|
+
exec(command, {
|
|
528
|
+
timeout: timeoutSeconds * 1000,
|
|
529
|
+
maxBuffer: 1024 * 1024,
|
|
530
|
+
env: env || process.env
|
|
531
|
+
}, (err, stdout, stderr) => {
|
|
532
|
+
if (err) {
|
|
533
|
+
reject(new Error(`${err.message}${stderr ? " / " + stderr.trim() : ""}`));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
resolve(stdout);
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function snapTilt(value) {
|
|
543
|
+
if (value <= 7) {
|
|
544
|
+
return 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (value <= 22) {
|
|
548
|
+
return 15;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (value <= 37) {
|
|
552
|
+
return 30;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (value <= 52) {
|
|
556
|
+
return 45;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return 60;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function clamp(value, min, max) {
|
|
563
|
+
if (!Number.isFinite(value)) {
|
|
564
|
+
return min;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return Math.max(min, Math.min(max, value));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function numberOr(...values) {
|
|
571
|
+
for (const value of values) {
|
|
572
|
+
const num = Number(value);
|
|
573
|
+
if (Number.isFinite(num)) {
|
|
574
|
+
return num;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return 0;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function shellQuote(value) {
|
|
582
|
+
return "'" + String(value).replace(/'/g, "'\\''") + "'";
|
|
583
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-aiseg-awning-window-command",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Homebridge plugin for AiSEG2 awning windows",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"homebridge-plugin",
|
|
8
|
+
"aiseg",
|
|
9
|
+
"awning",
|
|
10
|
+
"window",
|
|
11
|
+
"homekit",
|
|
12
|
+
"homebridge",
|
|
13
|
+
"command"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"homebridge": ">=1.8.0",
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"files": [
|
|
21
|
+
"index.js",
|
|
22
|
+
"config.schema.json",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
]
|
|
26
|
+
}
|