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.
@@ -94,9 +94,9 @@
94
94
  "description": "Homebridge上で表示する名前を入力します。"
95
95
  },
96
96
  "aisegId": {
97
- "title": "Id",
97
+ "title": "Node ID / AiSEG ID",
98
98
  "type": "string",
99
- "description": "このオーニング窓のAiSEG2固有IDを入力します。HomeKit用の一意IDはこの値から自動生成されます。"
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 /homebridge/scripts/aiseg_window.py ${shellQuote(scriptKey)} ${shellQuote(action)}`;
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.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()