homebridge-aiseg-awning-window-command 0.2.3 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-aiseg-awning-window-command",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "Homebridge plugin for AiSEG2 awning windows",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -22,6 +22,7 @@
22
22
  "config.schema.json",
23
23
  "README.md",
24
24
  "LICENSE",
25
- "scripts/aiseg_window.py"
25
+ "scripts/aiseg_window.py",
26
+ "scripts/aiseg_device.py"
26
27
  ]
27
28
  }
@@ -0,0 +1,332 @@
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
+ PROFILES = {
12
+ "shutter": {
13
+ "status_root": "shutter",
14
+ "default_device_id": "3252",
15
+ "page_extra_name": "page325",
16
+ "page_extra_env": "AISEG_PAGE325",
17
+ "default_page_extra": "1",
18
+ "default_track": "325",
19
+ "default_eoj": "0x026301",
20
+ "default_type": "0x0e",
21
+ "usage": "aiseg_shutter.py nodeId open|close|stop|halfopen|angle1|angle2|angle3|angle4|angle5|angle6|status",
22
+ "actions": {
23
+ "open": "0",
24
+ "close": "1",
25
+ "stop": "2",
26
+ "halfopen": "3",
27
+ "angle1": "4",
28
+ "angle2": "5",
29
+ "angle3": "6",
30
+ "angle4": "7",
31
+ "angle5": "8",
32
+ "angle6": "9",
33
+ },
34
+ },
35
+ "window": {
36
+ "status_root": "sash",
37
+ "default_device_id": "3261",
38
+ "page_extra_name": "page326",
39
+ "page_extra_env": "AISEG_PAGE326",
40
+ "default_page_extra": "1",
41
+ "default_track": "326",
42
+ "default_eoj": "0x026501",
43
+ "default_type": "0x0f",
44
+ "usage": "aiseg_window.py nodeId open|close|stop|angle1|angle2|angle3|status",
45
+ "actions": {
46
+ "open": "0",
47
+ "close": "1",
48
+ "stop": "2",
49
+ "angle1": "3",
50
+ "angle2": "4",
51
+ "angle3": "5",
52
+ },
53
+ },
54
+ }
55
+
56
+
57
+ def getenv_required(name):
58
+ value = os.environ.get(name, "").strip()
59
+ if not value:
60
+ raise RuntimeError(f"{name} is not set")
61
+ return value
62
+
63
+
64
+ def getenv(name, default=""):
65
+ return os.environ.get(name, default)
66
+
67
+
68
+ def build_aiseg_host():
69
+ host = (os.environ.get("AISEG_HOST") or os.environ.get("AISEG_IP") or "").strip()
70
+ if not host:
71
+ raise RuntimeError("AISEG_HOST or AISEG_IP is not set")
72
+ if not host.startswith("http://") and not host.startswith("https://"):
73
+ host = "http://" + host
74
+ return host.rstrip("/")
75
+
76
+
77
+ class AiSegContext:
78
+ def __init__(self, kind):
79
+ if kind not in PROFILES:
80
+ raise RuntimeError(f"unknown device kind: {kind}")
81
+
82
+ self.kind = kind
83
+ self.profile = PROFILES[kind]
84
+
85
+ self.host = build_aiseg_host()
86
+ self.user = getenv("AISEG_USER", "aiseg")
87
+ self.password = getenv_required("AISEG_PASSWORD")
88
+
89
+ self.device_id = getenv("AISEG_DEVICE_ID", self.profile["default_device_id"])
90
+ self.page = getenv("AISEG_PAGE", "1")
91
+ self.page_extra_name = self.profile["page_extra_name"]
92
+ self.page_extra = getenv(
93
+ self.profile["page_extra_env"],
94
+ self.profile["default_page_extra"],
95
+ )
96
+ self.track = getenv("AISEG_TRACK", self.profile["default_track"])
97
+ self.request_by_form = getenv("AISEG_REQUEST_BY_FORM", "1")
98
+ self.accept_id = getenv("AISEG_ACCEPT_ID", "65550")
99
+ self.default_eoj = getenv("AISEG_EOJ", self.profile["default_eoj"])
100
+ self.default_type = getenv("AISEG_TYPE", self.profile["default_type"])
101
+
102
+ self.actions = self.profile["actions"]
103
+ self.status_root = self.profile["status_root"]
104
+
105
+
106
+ class AiSegHttpClient:
107
+ def __init__(self, host, user, password):
108
+ password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
109
+ password_mgr.add_password(None, host, user, password)
110
+ auth_handler = urllib.request.HTTPDigestAuthHandler(password_mgr)
111
+ self.opener = urllib.request.build_opener(auth_handler)
112
+
113
+ def request(self, method, url, data=None, timeout=10):
114
+ headers = {
115
+ "Content-Type": "application/x-www-form-urlencoded",
116
+ "User-Agent": "Node.js",
117
+ }
118
+
119
+ body = None
120
+ if data is not None:
121
+ body = data.encode("utf-8") if isinstance(data, str) else data
122
+
123
+ req = urllib.request.Request(url, data=body, headers=headers, method=method)
124
+
125
+ try:
126
+ with self.opener.open(req, timeout=timeout) as response:
127
+ text = response.read().decode("utf-8", errors="replace")
128
+ return response.status, text
129
+ except urllib.error.HTTPError as e:
130
+ text = e.read().decode("utf-8", errors="replace")
131
+ raise RuntimeError(f"HTTP_STATUS={e.code} {text}") from e
132
+
133
+
134
+ def get_device_info(ctx, target):
135
+ env_node_id = (
136
+ os.environ.get("AISEG_NODE_ID")
137
+ or os.environ.get("AISEG_ID")
138
+ or os.environ.get("AISEG_UNIQUE_WINDOW_ID")
139
+ or ""
140
+ ).strip()
141
+
142
+ node_id = env_node_id or str(target).strip()
143
+
144
+ if not re.fullmatch(r"\d+", node_id):
145
+ raise RuntimeError("AISEG_NODE_ID is not set and argument is not a numeric nodeId")
146
+
147
+ return {
148
+ "nodeId": node_id,
149
+ "eoj": ctx.default_eoj,
150
+ "type": ctx.default_type,
151
+ }
152
+
153
+
154
+ def get_device_page_url(ctx, info):
155
+ return (
156
+ f"{ctx.host}/page/devices/device/{ctx.device_id}"
157
+ f"?page={ctx.page}"
158
+ f"&{ctx.page_extra_name}={ctx.page_extra}"
159
+ f"&nodeId={info['nodeId']}"
160
+ f"&eoj={info['eoj']}"
161
+ f"&type={info['type']}"
162
+ f"&track={ctx.track}"
163
+ f"&request_by_form={ctx.request_by_form}"
164
+ )
165
+
166
+
167
+ def get_operation_pu_url(ctx, info):
168
+ return (
169
+ f"{ctx.host}/page/devices/device/{ctx.device_id}/operation_pu"
170
+ f"?page={ctx.page}"
171
+ f"&{ctx.page_extra_name}={ctx.page_extra}"
172
+ f"&nodeId={info['nodeId']}"
173
+ f"&eoj={info['eoj']}"
174
+ f"&type={info['type']}"
175
+ f"&track={ctx.track}"
176
+ f"&request_by_form={ctx.request_by_form}"
177
+ f"&acceptId={ctx.accept_id}"
178
+ )
179
+
180
+
181
+ def extract_hidden_values(html):
182
+ return re.findall(
183
+ r'<span class="setting_value" style="display:none;">(.*?)</span>',
184
+ html,
185
+ )
186
+
187
+
188
+ def get_operation_token(ctx, client, info):
189
+ url = get_operation_pu_url(ctx, info)
190
+ status, text = client.request("GET", url, timeout=10)
191
+
192
+ if status != 200:
193
+ raise RuntimeError(f"operation token取得失敗 HTTP_STATUS={status}")
194
+
195
+ values = extract_hidden_values(text)
196
+
197
+ if len(values) < 2:
198
+ raise RuntimeError(f"operation token取得失敗 values={values}")
199
+
200
+ return values[1]
201
+
202
+
203
+ def convert_open_state(open_state):
204
+ open_state = str(open_state)
205
+
206
+ if open_state == "0x41":
207
+ return "open"
208
+ if open_state == "0x42":
209
+ return "closed"
210
+ if open_state == "0x43":
211
+ return "opening"
212
+ if open_state == "0x44":
213
+ return "closing"
214
+ if open_state == "0x45":
215
+ return "vent"
216
+ return "unknown"
217
+
218
+
219
+ def extract_init_json(html):
220
+ m = re.search(
221
+ r'window\.onload\s*=\s*init\((.*?)\);',
222
+ html,
223
+ re.S,
224
+ )
225
+
226
+ if not m:
227
+ return None
228
+
229
+ return m.group(1)
230
+
231
+
232
+ def get_status(ctx, client, target):
233
+ info = get_device_info(ctx, target)
234
+ url = get_device_page_url(ctx, info)
235
+
236
+ status, text = client.request("GET", url, timeout=10)
237
+
238
+ if status != 200:
239
+ print("unknown")
240
+ print(f"HTTP_STATUS={status}", file=sys.stderr)
241
+ print(text, file=sys.stderr)
242
+ return
243
+
244
+ init_json = extract_init_json(text)
245
+
246
+ if not init_json:
247
+ print("unknown")
248
+ print("init JSON not found", file=sys.stderr)
249
+ return
250
+
251
+ try:
252
+ data = json.loads(init_json)
253
+ except Exception as e:
254
+ print("unknown")
255
+ print(f"init JSON parse error: {e}", file=sys.stderr)
256
+ return
257
+
258
+ open_state = data.get(ctx.status_root, {}).get("openState", "unknown")
259
+ print(convert_open_state(open_state))
260
+
261
+
262
+ def send_command(ctx, client, target, action):
263
+ if action not in ctx.actions:
264
+ print("unknown action", file=sys.stderr)
265
+ sys.exit(1)
266
+
267
+ info = get_device_info(ctx, target)
268
+ open_value = ctx.actions[action]
269
+ token = get_operation_token(ctx, client, info)
270
+
271
+ payload = (
272
+ "data="
273
+ + json.dumps(
274
+ {
275
+ "objSendData": json.dumps(
276
+ {
277
+ "nodeId": info["nodeId"],
278
+ "eoj": info["eoj"],
279
+ "type": info["type"],
280
+ "device": {
281
+ "open": open_value,
282
+ },
283
+ }
284
+ ),
285
+ "token": token,
286
+ }
287
+ )
288
+ )
289
+
290
+ url = f"{ctx.host}/action/devices/device/{ctx.device_id}/operation"
291
+ status, text = client.request("POST", url, data=payload, timeout=10)
292
+
293
+ if status < 200 or status >= 300:
294
+ raise RuntimeError(f"command failed HTTP_STATUS={status}")
295
+
296
+ print(text)
297
+
298
+
299
+ def main(default_kind=None):
300
+ args = list(sys.argv[1:])
301
+
302
+ if default_kind is None:
303
+ if len(args) != 3:
304
+ print("usage: aiseg_device.py shutter|window nodeId action", file=sys.stderr)
305
+ sys.exit(1)
306
+ kind = args.pop(0).strip().lower()
307
+ else:
308
+ kind = str(default_kind).strip().lower()
309
+
310
+ if kind not in PROFILES:
311
+ print(f"unknown device kind: {kind}", file=sys.stderr)
312
+ sys.exit(1)
313
+
314
+ if len(args) != 2:
315
+ print(f"usage: {PROFILES[kind]['usage']}", file=sys.stderr)
316
+ sys.exit(1)
317
+
318
+ target = args[0].strip()
319
+ action = args[1].strip().lower()
320
+
321
+ ctx = AiSegContext(kind)
322
+ client = AiSegHttpClient(ctx.host, ctx.user, ctx.password)
323
+
324
+ if action == "status":
325
+ get_status(ctx, client, target)
326
+ return
327
+
328
+ send_command(ctx, client, target, action)
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
@@ -1,273 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ from pathlib import Path
3
4
  import sys
4
- import os
5
- import json
6
- import re
7
- import urllib.request
8
- import urllib.error
9
5
 
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
10
7
 
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
-
8
+ from aiseg_device import main
271
9
 
272
10
  if __name__ == "__main__":
273
- main()
11
+ main("window")