homebridge-aiseg-awning-window-command 0.2.2 → 0.2.3
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/config.schema.json +44 -3
- package/index.js +11 -11
- package/package.json +3 -2
- package/scripts/aiseg_window.py +273 -0
package/config.schema.json
CHANGED
|
@@ -94,9 +94,9 @@
|
|
|
94
94
|
"description": "Homebridge上で表示する名前を入力します。"
|
|
95
95
|
},
|
|
96
96
|
"aisegId": {
|
|
97
|
-
"title": "
|
|
97
|
+
"title": "Node ID / AiSEG ID",
|
|
98
98
|
"type": "string",
|
|
99
|
-
"description": "このオーニング窓の
|
|
99
|
+
"description": "このオーニング窓のAiSEG Node IDを入力します。例: 268xxxxxx。HomeKit用の一意IDはこの値から自動生成されます。"
|
|
100
100
|
},
|
|
101
101
|
"showStopSwitch": {
|
|
102
102
|
"title": "停止操作",
|
|
@@ -115,7 +115,48 @@
|
|
|
115
115
|
"aisegId"
|
|
116
116
|
]
|
|
117
117
|
}
|
|
118
|
+
},
|
|
119
|
+
"deviceType": {
|
|
120
|
+
"title": "種別",
|
|
121
|
+
"description": "AiSEG2オーニング窓の種別です。通常は 0x0f です。",
|
|
122
|
+
"type": "string",
|
|
123
|
+
"default": "0x0f"
|
|
124
|
+
},
|
|
125
|
+
"page": {
|
|
126
|
+
"title": "Page",
|
|
127
|
+
"description": "詳細設定です。通常は 1 です。",
|
|
128
|
+
"type": "string",
|
|
129
|
+
"default": "1"
|
|
130
|
+
},
|
|
131
|
+
"page326": {
|
|
132
|
+
"title": "Page326",
|
|
133
|
+
"description": "詳細設定です。通常は 1 です。",
|
|
134
|
+
"type": "string",
|
|
135
|
+
"default": "1"
|
|
136
|
+
},
|
|
137
|
+
"track": {
|
|
138
|
+
"title": "Track",
|
|
139
|
+
"description": "詳細設定です。通常は 326 です。",
|
|
140
|
+
"type": "string",
|
|
141
|
+
"default": "326"
|
|
142
|
+
},
|
|
143
|
+
"requestByForm": {
|
|
144
|
+
"title": "Request By Form",
|
|
145
|
+
"description": "詳細設定です。通常は 1 です。",
|
|
146
|
+
"type": "string",
|
|
147
|
+
"default": "1"
|
|
148
|
+
},
|
|
149
|
+
"acceptId": {
|
|
150
|
+
"title": "Accept ID",
|
|
151
|
+
"description": "詳細設定です。通常は 65550 です。",
|
|
152
|
+
"type": "string",
|
|
153
|
+
"default": "65550"
|
|
118
154
|
}
|
|
119
|
-
}
|
|
155
|
+
},
|
|
156
|
+
"required": [
|
|
157
|
+
"host",
|
|
158
|
+
"password",
|
|
159
|
+
"windows"
|
|
160
|
+
]
|
|
120
161
|
}
|
|
121
162
|
}
|
package/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const { exec } = require("child_process");
|
|
4
|
+
const path = require("path");
|
|
4
5
|
|
|
5
6
|
const PLUGIN_NAME = "homebridge-aiseg-awning-window-command";
|
|
6
7
|
const PLATFORM_NAME = "AiSegAwningWindowCommand";
|
|
8
|
+
const DEFAULT_SCRIPT_PATH = path.join(__dirname, "scripts", "aiseg_window.py");
|
|
7
9
|
|
|
8
10
|
module.exports = (api) => {
|
|
9
11
|
api.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, AiSegAwningWindowPlatform);
|
|
@@ -478,21 +480,11 @@ function getWindowScriptKey(config) {
|
|
|
478
480
|
return explicit;
|
|
479
481
|
}
|
|
480
482
|
|
|
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
483
|
return getWindowAisegId(config);
|
|
492
484
|
}
|
|
493
485
|
|
|
494
486
|
function buildAisegWindowCommand(scriptKey, action) {
|
|
495
|
-
return `python3
|
|
487
|
+
return `python3 ${shellQuote(DEFAULT_SCRIPT_PATH)} ${shellQuote(scriptKey)} ${shellQuote(action)}`;
|
|
496
488
|
}
|
|
497
489
|
|
|
498
490
|
function buildAisegEnv(platformConfig, windowConfig) {
|
|
@@ -517,8 +509,16 @@ function buildAisegEnv(platformConfig, windowConfig) {
|
|
|
517
509
|
const aisegId = getWindowAisegId(windowConfig);
|
|
518
510
|
if (aisegId) {
|
|
519
511
|
env.AISEG_ID = aisegId;
|
|
512
|
+
env.AISEG_NODE_ID = aisegId;
|
|
520
513
|
}
|
|
521
514
|
|
|
515
|
+
env.AISEG_TYPE = String((platformConfig && platformConfig.deviceType) || "0x0f");
|
|
516
|
+
env.AISEG_PAGE = String((platformConfig && platformConfig.page) || "1");
|
|
517
|
+
env.AISEG_PAGE326 = String((platformConfig && platformConfig.page326) || "1");
|
|
518
|
+
env.AISEG_TRACK = String((platformConfig && platformConfig.track) || "326");
|
|
519
|
+
env.AISEG_REQUEST_BY_FORM = String((platformConfig && platformConfig.requestByForm) || "1");
|
|
520
|
+
env.AISEG_ACCEPT_ID = String((platformConfig && platformConfig.acceptId) || "65550");
|
|
521
|
+
|
|
522
522
|
return env;
|
|
523
523
|
}
|
|
524
524
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-aiseg-awning-window-command",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Homebridge plugin for AiSEG2 awning windows",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"keywords": [
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"index.js",
|
|
22
22
|
"config.schema.json",
|
|
23
23
|
"README.md",
|
|
24
|
-
"LICENSE"
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"scripts/aiseg_window.py"
|
|
25
26
|
]
|
|
26
27
|
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import urllib.request
|
|
8
|
+
import urllib.error
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
USER = os.environ.get("AISEG_USER", "aiseg")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def getenv_required(name):
|
|
15
|
+
value = os.environ.get(name, "").strip()
|
|
16
|
+
if not value:
|
|
17
|
+
raise RuntimeError(f"{name} is not set")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def getenv(name, default=""):
|
|
22
|
+
return os.environ.get(name, default)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_aiseg_host():
|
|
26
|
+
host = (os.environ.get("AISEG_HOST") or os.environ.get("AISEG_IP") or "").strip()
|
|
27
|
+
if not host:
|
|
28
|
+
raise RuntimeError("AISEG_HOST or AISEG_IP is not set")
|
|
29
|
+
if not host.startswith("http://") and not host.startswith("https://"):
|
|
30
|
+
host = "http://" + host
|
|
31
|
+
return host.rstrip("/")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
AISEG_HOST = build_aiseg_host()
|
|
35
|
+
PASSWORD = getenv_required("AISEG_PASSWORD")
|
|
36
|
+
|
|
37
|
+
DEVICE_ID = getenv("AISEG_DEVICE_ID", "3261")
|
|
38
|
+
|
|
39
|
+
PAGE = getenv("AISEG_PAGE", "1")
|
|
40
|
+
PAGE326 = getenv("AISEG_PAGE326", "1")
|
|
41
|
+
TRACK = getenv("AISEG_TRACK", "326")
|
|
42
|
+
REQUEST_BY_FORM = getenv("AISEG_REQUEST_BY_FORM", "1")
|
|
43
|
+
ACCEPT_ID = getenv("AISEG_ACCEPT_ID", "65550")
|
|
44
|
+
|
|
45
|
+
DEFAULT_EOJ = getenv("AISEG_EOJ", "0x026501")
|
|
46
|
+
DEFAULT_TYPE = getenv("AISEG_TYPE", "0x0f")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ACTIONS = {
|
|
50
|
+
"open": "0",
|
|
51
|
+
"close": "1",
|
|
52
|
+
"stop": "2",
|
|
53
|
+
"angle1": "3",
|
|
54
|
+
"angle2": "4",
|
|
55
|
+
"angle3": "5",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AiSegHttpClient:
|
|
60
|
+
def __init__(self, host, user, password):
|
|
61
|
+
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
|
|
62
|
+
password_mgr.add_password(None, host, user, password)
|
|
63
|
+
auth_handler = urllib.request.HTTPDigestAuthHandler(password_mgr)
|
|
64
|
+
self.opener = urllib.request.build_opener(auth_handler)
|
|
65
|
+
|
|
66
|
+
def request(self, method, url, data=None, timeout=10):
|
|
67
|
+
headers = {
|
|
68
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
69
|
+
"User-Agent": "Node.js",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
body = None
|
|
73
|
+
if data is not None:
|
|
74
|
+
body = data.encode("utf-8") if isinstance(data, str) else data
|
|
75
|
+
|
|
76
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
with self.opener.open(req, timeout=timeout) as response:
|
|
80
|
+
text = response.read().decode("utf-8", errors="replace")
|
|
81
|
+
return response.status, text
|
|
82
|
+
except urllib.error.HTTPError as e:
|
|
83
|
+
text = e.read().decode("utf-8", errors="replace")
|
|
84
|
+
raise RuntimeError(f"HTTP_STATUS={e.code} {text}") from e
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_window_info(window):
|
|
88
|
+
env_node_id = (
|
|
89
|
+
os.environ.get("AISEG_NODE_ID")
|
|
90
|
+
or os.environ.get("AISEG_ID")
|
|
91
|
+
or os.environ.get("AISEG_UNIQUE_WINDOW_ID")
|
|
92
|
+
or ""
|
|
93
|
+
).strip()
|
|
94
|
+
|
|
95
|
+
node_id = env_node_id or str(window).strip()
|
|
96
|
+
|
|
97
|
+
if not re.fullmatch(r"\d+", node_id):
|
|
98
|
+
raise RuntimeError("AISEG_NODE_ID is not set and argument is not a numeric nodeId")
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"nodeId": node_id,
|
|
102
|
+
"eoj": DEFAULT_EOJ,
|
|
103
|
+
"type": DEFAULT_TYPE,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_device_page_url(info):
|
|
108
|
+
return (
|
|
109
|
+
f"{AISEG_HOST}/page/devices/device/{DEVICE_ID}"
|
|
110
|
+
f"?page={PAGE}"
|
|
111
|
+
f"&page326={PAGE326}"
|
|
112
|
+
f"&nodeId={info['nodeId']}"
|
|
113
|
+
f"&eoj={info['eoj']}"
|
|
114
|
+
f"&type={info['type']}"
|
|
115
|
+
f"&track={TRACK}"
|
|
116
|
+
f"&request_by_form={REQUEST_BY_FORM}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_operation_pu_url(info):
|
|
121
|
+
return (
|
|
122
|
+
f"{AISEG_HOST}/page/devices/device/{DEVICE_ID}/operation_pu"
|
|
123
|
+
f"?page={PAGE}"
|
|
124
|
+
f"&page326={PAGE326}"
|
|
125
|
+
f"&nodeId={info['nodeId']}"
|
|
126
|
+
f"&eoj={info['eoj']}"
|
|
127
|
+
f"&type={info['type']}"
|
|
128
|
+
f"&track={TRACK}"
|
|
129
|
+
f"&request_by_form={REQUEST_BY_FORM}"
|
|
130
|
+
f"&acceptId={ACCEPT_ID}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_hidden_values(html):
|
|
135
|
+
return re.findall(
|
|
136
|
+
r'<span class="setting_value" style="display:none;">(.*?)</span>',
|
|
137
|
+
html,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_operation_token(client, info):
|
|
142
|
+
url = get_operation_pu_url(info)
|
|
143
|
+
status, text = client.request("GET", url, timeout=10)
|
|
144
|
+
|
|
145
|
+
if status != 200:
|
|
146
|
+
raise RuntimeError(f"operation token取得失敗 HTTP_STATUS={status}")
|
|
147
|
+
|
|
148
|
+
values = extract_hidden_values(text)
|
|
149
|
+
|
|
150
|
+
if len(values) < 2:
|
|
151
|
+
raise RuntimeError(f"operation token取得失敗 values={values}")
|
|
152
|
+
|
|
153
|
+
return values[1]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def convert_open_state(open_state):
|
|
157
|
+
open_state = str(open_state)
|
|
158
|
+
|
|
159
|
+
if open_state == "0x41":
|
|
160
|
+
return "open"
|
|
161
|
+
if open_state == "0x42":
|
|
162
|
+
return "closed"
|
|
163
|
+
if open_state == "0x43":
|
|
164
|
+
return "opening"
|
|
165
|
+
if open_state == "0x44":
|
|
166
|
+
return "closing"
|
|
167
|
+
if open_state == "0x45":
|
|
168
|
+
return "vent"
|
|
169
|
+
return "unknown"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def extract_init_json(html):
|
|
173
|
+
m = re.search(
|
|
174
|
+
r'window\.onload\s*=\s*init\((.*?)\);',
|
|
175
|
+
html,
|
|
176
|
+
re.S,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
if not m:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
return m.group(1)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_window_status(client, window):
|
|
186
|
+
info = get_window_info(window)
|
|
187
|
+
url = get_device_page_url(info)
|
|
188
|
+
|
|
189
|
+
status, text = client.request("GET", url, timeout=10)
|
|
190
|
+
|
|
191
|
+
if status != 200:
|
|
192
|
+
print("unknown")
|
|
193
|
+
print(f"HTTP_STATUS={status}", file=sys.stderr)
|
|
194
|
+
print(text, file=sys.stderr)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
init_json = extract_init_json(text)
|
|
198
|
+
|
|
199
|
+
if not init_json:
|
|
200
|
+
print("unknown")
|
|
201
|
+
print("init JSON not found", file=sys.stderr)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(init_json)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
print("unknown")
|
|
208
|
+
print(f"init JSON parse error: {e}", file=sys.stderr)
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
open_state = data.get("sash", {}).get("openState", "unknown")
|
|
212
|
+
print(convert_open_state(open_state))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def send_window_command(client, window, action):
|
|
216
|
+
if action not in ACTIONS:
|
|
217
|
+
print("unknown action", file=sys.stderr)
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
|
|
220
|
+
info = get_window_info(window)
|
|
221
|
+
open_value = ACTIONS[action]
|
|
222
|
+
token = get_operation_token(client, info)
|
|
223
|
+
|
|
224
|
+
payload = (
|
|
225
|
+
"data="
|
|
226
|
+
+ json.dumps(
|
|
227
|
+
{
|
|
228
|
+
"objSendData": json.dumps(
|
|
229
|
+
{
|
|
230
|
+
"nodeId": info["nodeId"],
|
|
231
|
+
"eoj": info["eoj"],
|
|
232
|
+
"type": info["type"],
|
|
233
|
+
"device": {
|
|
234
|
+
"open": open_value,
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
),
|
|
238
|
+
"token": token,
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
url = f"{AISEG_HOST}/action/devices/device/{DEVICE_ID}/operation"
|
|
244
|
+
status, text = client.request("POST", url, data=payload, timeout=10)
|
|
245
|
+
|
|
246
|
+
if status < 200 or status >= 300:
|
|
247
|
+
raise RuntimeError(f"command failed HTTP_STATUS={status}")
|
|
248
|
+
|
|
249
|
+
print(text)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def main():
|
|
253
|
+
if len(sys.argv) != 3:
|
|
254
|
+
print(
|
|
255
|
+
"usage: aiseg_window.py nodeId open|close|stop|angle1|angle2|angle3|status",
|
|
256
|
+
file=sys.stderr,
|
|
257
|
+
)
|
|
258
|
+
sys.exit(1)
|
|
259
|
+
|
|
260
|
+
window = sys.argv[1].strip()
|
|
261
|
+
action = sys.argv[2].strip().lower()
|
|
262
|
+
|
|
263
|
+
client = AiSegHttpClient(AISEG_HOST, USER, PASSWORD)
|
|
264
|
+
|
|
265
|
+
if action == "status":
|
|
266
|
+
get_window_status(client, window)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
send_window_command(client, window, action)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
main()
|