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

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/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ ## 0.2.5
4
+
5
+ - Improved the snap behavior of the HomeKit tilt slider for AiSEG2 awning windows.
6
+ - Tilt values now snap more firmly to 0°, 15°, 30°, 45°, and 60°.
7
+ - Updated the README and CHANGELOG documentation.
8
+ - Hid internal AiSEG request parameters to simplify the Homebridge UI settings.
9
+
10
+ ### Notes
11
+
12
+ This plugin is designed and tested for Sankyo Aluminum awning windows connected to AiSEG2.
13
+
14
+ Normal users only need:
15
+
16
+ - AiSEG2 IP address or host name
17
+ - AiSEG web password
18
+ - Node ID for each window
19
+
20
+ Each window's Node ID can be checked on the AiSEG2 web page under **Device Control > Window Sash**.
21
+ Open the browser developer tools with **F12**, go to **Network > Fetch/XHR**, press an action button, open the `operation` request, and check the 9-digit `nodeId` value in the request payload.
22
+
23
+ ## 0.2.4
24
+
25
+ - Refactored Python scripts to use a shared `aiseg_device.py` core.
26
+ - Kept `aiseg_window.py` as a compatibility wrapper.
27
+ - Continued to bundle package-local Python scripts.
28
+ - Continued to avoid Python `requests` dependency.
29
+
30
+ ## 0.2.3
31
+
32
+ - Bundled the AiSEG awning Python script inside the npm package.
33
+ - Removed fixed private IP, password, and node ID values from the packaged script.
34
+ - Passed user-specific values from Homebridge config to Python via environment variables.
35
+ - Removed dependency on the external Python `requests` package.
36
+
37
+ ## 0.2.2
38
+
39
+ - Improved HomeKit awning window behavior.
40
+ - Added better motion guard handling for open, close, and tilt operations.
41
+ - Improved HomeKit display consistency during AiSEG state polling.
42
+
43
+ ## 0.2.1 and earlier
44
+
45
+ - Early development releases for AiSEG2 awning window control.
package/README.md CHANGED
@@ -1,62 +1,95 @@
1
1
  # homebridge-aiseg-awning-window-command
2
2
 
3
- Homebridge plugin for AiSEG2 awning windows
3
+ Homebridge plugin for AiSEG2-connected awning windows.
4
4
 
5
- ## Overview
5
+ This plugin exposes AiSEG2 awning windows as HomeKit `WindowCovering` accessories.
6
6
 
7
- This is a command-based Homebridge platform plugin for AiSEG-controlled devices.
7
+ ## Features
8
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.
9
+ - Open / close control from HomeKit
10
+ - HomeKit horizontal tilt angle control
11
+ - Stop switch accessory
12
+ - Status polling from AiSEG2
13
+ - Motion guard to reduce stale-state jumps
14
+ - Strong tilt snapping for HomeKit
15
+ - Package-local Python scripts included
16
+ - No external Python `requests` dependency
17
+ - Shared AiSEG Python core used internally
11
18
 
12
- ## Requirements
19
+ ## Supported HomeKit tilt values
13
20
 
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
21
+ The HomeKit tilt slider is snapped to the following values:
18
22
 
19
- ## Installation
23
+ | HomeKit tilt | AiSEG command |
24
+ |---:|---|
25
+ | 0° | close |
26
+ | 15° | angle1 |
27
+ | 30° | angle2 |
28
+ | 45° | angle3 |
29
+ | 60° | open |
20
30
 
21
- ```bash
22
- npm install -g homebridge-aiseg-awning-window-command
23
- ```
31
+ Starting from v0.2.5, the plugin more strongly echoes the snapped tilt value back to HomeKit.
32
+ This helps prevent the Home app slider from visually stopping around intermediate values such as 25°.
24
33
 
25
- ## Configuration
34
+ ## Notes
26
35
 
27
- Use the Homebridge UI to configure this plugin.
36
+ This plugin is designed and tested for Sankyo Aluminum awning windows connected to AiSEG2.
28
37
 
29
- Typical settings include:
38
+ Normal users only need to configure:
30
39
 
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
40
+ - AiSEG2 IP address or host name
41
+ - AiSEG web password
42
+ - Node ID for each window
37
43
 
38
- ## Notes
44
+ Internal AiSEG request parameters are hidden from the Homebridge UI because they normally do not need to be changed.
39
45
 
40
- This plugin does not include AiSEG login or operation scripts.
41
- Prepare and test the command scripts before configuring Homebridge.
46
+ ## How to find the Node ID
42
47
 
43
- A command should be tested manually first, for example:
48
+ You can check each window's Node ID from the AiSEG2 web page:
49
+
50
+ 1. Open the AiSEG2 web interface.
51
+ 2. Go to **Device Control > Window Sash**.
52
+ 3. Open the browser developer tools with **F12**.
53
+ 4. Go to the **Network** tab and filter by **Fetch/XHR**.
54
+ 5. Press an action button for the target window.
55
+ 6. Open the `operation` request.
56
+ 7. Check the request payload. The window Node ID is the 9-digit `nodeId` value.
57
+
58
+ ## Tested environment
59
+
60
+ - AiSEG2
61
+ - Sankyo Aluminum awning windows
62
+ - AiSEG adapter / related Panasonic equipment: MKN7751K
63
+ - Homebridge 2.x
64
+ - Node.js 24.x
44
65
 
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
- ```
66
+ ## Hidden internal settings
50
67
 
51
- ## Troubleshooting
68
+ The following AiSEG request parameters are intentionally hidden from the Homebridge UI:
69
+
70
+ - device type
71
+ - page
72
+ - page326
73
+ - track
74
+ - request_by_form
75
+ - acceptId
76
+
77
+ The plugin still uses internal defaults for these values.
78
+
79
+ ## Related plugin
80
+
81
+ For AiSEG3 blind shutters, use:
82
+
83
+ - `homebridge-aiseg-shutter-command`
84
+
85
+ ## Requirements
52
86
 
53
- If the accessory does not respond:
87
+ - Homebridge
88
+ - Node.js
89
+ - Python 3 available in the Homebridge environment
54
90
 
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.
91
+ No additional Python package installation is required.
59
92
 
60
- ## Disclaimer
93
+ ## Version history
61
94
 
62
- This project is not affiliated with Apple, Homebridge, Panasonic, or AiSEG.
95
+ See [CHANGELOG.md](./CHANGELOG.md).
@@ -115,42 +115,6 @@
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"
154
118
  }
155
119
  },
156
120
  "required": [
package/index.js CHANGED
@@ -162,17 +162,52 @@ class AwningWindow {
162
162
  .onGet(() => this.positionState);
163
163
 
164
164
  this.service.getCharacteristic(this.Characteristic.CurrentHorizontalTiltAngle)
165
- .setProps({ minValue: 0, maxValue: 60, minStep: 15 })
165
+ .setProps({ minValue: 0, maxValue: 60, minStep: 15, validValues: [0, 15, 30, 45, 60] })
166
166
  .onGet(() => this.currentTilt);
167
167
 
168
168
  this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle)
169
- .setProps({ minValue: 0, maxValue: 60, minStep: 15 })
169
+ .setProps({ minValue: 0, maxValue: 60, minStep: 15, validValues: [0, 15, 30, 45, 60] })
170
170
  .onGet(() => this.targetTilt)
171
171
  .onSet(async (value) => this.setTargetTilt(value));
172
172
 
173
173
  this.updateHomeKit();
174
174
  }
175
175
 
176
+
177
+ forceTiltSnap(snapped, source, mirrorCurrent) {
178
+ const v = clamp(Number(snapped), 0, 60);
179
+
180
+ this.targetTilt = v;
181
+
182
+ const targetChar = this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle);
183
+ const currentChar = this.service.getCharacteristic(this.Characteristic.CurrentHorizontalTiltAngle);
184
+
185
+ targetChar.updateValue(v);
186
+
187
+ if (mirrorCurrent) {
188
+ this.currentTilt = v;
189
+ currentChar.updateValue(v);
190
+ }
191
+
192
+ // HomeKit sometimes keeps the raw slider value briefly.
193
+ // Echo the snapped value several times so 25° etc. returns to 15/30/45/60 cleanly.
194
+ for (const delay of [100, 350, 900]) {
195
+ setTimeout(() => {
196
+ if (this.targetTilt === v || Date.now() < this.motionUntil) {
197
+ targetChar.updateValue(v);
198
+
199
+ if (mirrorCurrent) {
200
+ currentChar.updateValue(v);
201
+ }
202
+
203
+ if (source && this.lastUnknownStatus === "__never__") {
204
+ // no-op: keep linter-free structure without extra log noise
205
+ }
206
+ }
207
+ }, delay);
208
+ }
209
+ }
210
+
176
211
  async setTargetPosition(value) {
177
212
  const raw = clamp(Number(value), 0, 100);
178
213
  const normalized = raw <= 50 ? 0 : 100;
@@ -201,14 +236,14 @@ class AwningWindow {
201
236
 
202
237
  this.log.info(`[${this.name}] TargetHorizontalTiltAngle ${raw} -> ${snapped}`);
203
238
 
204
- this.targetTilt = snapped;
205
- this.service.getCharacteristic(this.Characteristic.TargetHorizontalTiltAngle).updateValue(snapped);
239
+ this.forceTiltSnap(snapped, "setTargetTilt-start", false);
206
240
 
207
241
  if (snapped === 0) {
208
242
  this.targetPosition = 0;
209
243
  this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(0);
210
244
  this.startMotion("closed", this.motionGuardSeconds, 0);
211
245
  await this.runAction("close", "tilt 0 / close");
246
+ this.forceTiltSnap(0, "tilt-close-done", true);
212
247
  return;
213
248
  }
214
249
 
@@ -217,6 +252,7 @@ class AwningWindow {
217
252
  this.service.getCharacteristic(this.Characteristic.TargetPosition).updateValue(100);
218
253
  this.startMotion("open", Math.max(this.motionGuardSeconds, this.openAcceptDelaySeconds), 60);
219
254
  await this.runAction("open", "tilt 60 / open");
255
+ this.forceTiltSnap(60, "tilt-open-done", true);
220
256
  return;
221
257
  }
222
258
 
@@ -227,12 +263,15 @@ class AwningWindow {
227
263
  if (snapped === 15) {
228
264
  this.startMotion("angle1", this.tiltMotionGuardSeconds, 15);
229
265
  await this.runAction("angle1", "angle1");
266
+ this.forceTiltSnap(15, "angle1-done", true);
230
267
  } else if (snapped === 30) {
231
268
  this.startMotion("angle2", this.tiltMotionGuardSeconds, 30);
232
269
  await this.runAction("angle2", "angle2");
270
+ this.forceTiltSnap(30, "angle2-done", true);
233
271
  } else {
234
272
  this.startMotion("angle3", this.tiltMotionGuardSeconds, 45);
235
273
  await this.runAction("angle3", "angle3");
274
+ this.forceTiltSnap(45, "angle3-done", true);
236
275
  }
237
276
  }
238
277
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-aiseg-awning-window-command",
3
- "version": "0.2.3",
4
- "description": "Homebridge plugin for AiSEG2 awning windows",
3
+ "version": "0.2.5",
4
+ "description": "Homebridge plugin for AiSEG2 awning windows with HomeKit tilt snapping",
5
5
  "main": "index.js",
6
6
  "keywords": [
7
7
  "homebridge-plugin",
@@ -10,7 +10,13 @@
10
10
  "window",
11
11
  "homekit",
12
12
  "homebridge",
13
- "command"
13
+ "command",
14
+ "aiseg2",
15
+ "window-covering",
16
+ "tilt",
17
+ "sankyo",
18
+ "sankyo-aluminum",
19
+ "panasonic"
14
20
  ],
15
21
  "engines": {
16
22
  "homebridge": ">=1.8.0",
@@ -22,6 +28,9 @@
22
28
  "config.schema.json",
23
29
  "README.md",
24
30
  "LICENSE",
25
- "scripts/aiseg_window.py"
26
- ]
31
+ "scripts/aiseg_window.py",
32
+ "scripts/aiseg_device.py",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "displayName": "Homebridge AiSEG Awning Window Command"
27
36
  }
@@ -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")