suprema-biostar-mcp 1.0.1__py3-none-any.whl
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.
- biostar_x_mcp_server/__init__.py +25 -0
- biostar_x_mcp_server/__main__.py +15 -0
- biostar_x_mcp_server/config.py +87 -0
- biostar_x_mcp_server/handlers/__init__.py +35 -0
- biostar_x_mcp_server/handlers/access_handler.py +2162 -0
- biostar_x_mcp_server/handlers/audit_handler.py +489 -0
- biostar_x_mcp_server/handlers/auth_handler.py +216 -0
- biostar_x_mcp_server/handlers/base_handler.py +228 -0
- biostar_x_mcp_server/handlers/card_handler.py +746 -0
- biostar_x_mcp_server/handlers/device_handler.py +4344 -0
- biostar_x_mcp_server/handlers/door_handler.py +3969 -0
- biostar_x_mcp_server/handlers/event_handler.py +1331 -0
- biostar_x_mcp_server/handlers/file_handler.py +212 -0
- biostar_x_mcp_server/handlers/help_web_handler.py +379 -0
- biostar_x_mcp_server/handlers/log_handler.py +1051 -0
- biostar_x_mcp_server/handlers/navigation_handler.py +109 -0
- biostar_x_mcp_server/handlers/occupancy_handler.py +541 -0
- biostar_x_mcp_server/handlers/user_handler.py +3568 -0
- biostar_x_mcp_server/schemas/__init__.py +21 -0
- biostar_x_mcp_server/schemas/access.py +158 -0
- biostar_x_mcp_server/schemas/audit.py +73 -0
- biostar_x_mcp_server/schemas/auth.py +24 -0
- biostar_x_mcp_server/schemas/cards.py +128 -0
- biostar_x_mcp_server/schemas/devices.py +496 -0
- biostar_x_mcp_server/schemas/doors.py +306 -0
- biostar_x_mcp_server/schemas/events.py +104 -0
- biostar_x_mcp_server/schemas/files.py +7 -0
- biostar_x_mcp_server/schemas/help.py +29 -0
- biostar_x_mcp_server/schemas/logs.py +33 -0
- biostar_x_mcp_server/schemas/occupancy.py +19 -0
- biostar_x_mcp_server/schemas/tool_response.py +29 -0
- biostar_x_mcp_server/schemas/users.py +166 -0
- biostar_x_mcp_server/server.py +335 -0
- biostar_x_mcp_server/session.py +221 -0
- biostar_x_mcp_server/tool_manager.py +172 -0
- biostar_x_mcp_server/tools/__init__.py +45 -0
- biostar_x_mcp_server/tools/access.py +510 -0
- biostar_x_mcp_server/tools/audit.py +227 -0
- biostar_x_mcp_server/tools/auth.py +59 -0
- biostar_x_mcp_server/tools/cards.py +269 -0
- biostar_x_mcp_server/tools/categories.py +197 -0
- biostar_x_mcp_server/tools/devices.py +1552 -0
- biostar_x_mcp_server/tools/doors.py +865 -0
- biostar_x_mcp_server/tools/events.py +305 -0
- biostar_x_mcp_server/tools/files.py +28 -0
- biostar_x_mcp_server/tools/help.py +80 -0
- biostar_x_mcp_server/tools/logs.py +123 -0
- biostar_x_mcp_server/tools/navigation.py +89 -0
- biostar_x_mcp_server/tools/occupancy.py +91 -0
- biostar_x_mcp_server/tools/users.py +1113 -0
- biostar_x_mcp_server/utils/__init__.py +31 -0
- biostar_x_mcp_server/utils/category_mapper.py +206 -0
- biostar_x_mcp_server/utils/decorators.py +101 -0
- biostar_x_mcp_server/utils/language_detector.py +51 -0
- biostar_x_mcp_server/utils/search.py +42 -0
- biostar_x_mcp_server/utils/timezone.py +122 -0
- suprema_biostar_mcp-1.0.1.dist-info/METADATA +163 -0
- suprema_biostar_mcp-1.0.1.dist-info/RECORD +61 -0
- suprema_biostar_mcp-1.0.1.dist-info/WHEEL +4 -0
- suprema_biostar_mcp-1.0.1.dist-info/entry_points.txt +2 -0
- suprema_biostar_mcp-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,3969 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Sequence, Dict, Any, List, Optional, Set, Tuple
|
|
3
|
+
from mcp.types import TextContent
|
|
4
|
+
import httpx
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from .base_handler import BaseHandler
|
|
8
|
+
import unicodedata
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DoorHandler(BaseHandler):
|
|
14
|
+
"""Handle door-related operations."""
|
|
15
|
+
|
|
16
|
+
async def get_doors(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
17
|
+
"""
|
|
18
|
+
Get list of doors (enhanced):
|
|
19
|
+
- Honors API params (limit, order_by) with sensible defaults.
|
|
20
|
+
- Adds retry/backoff and timeouts for resilience.
|
|
21
|
+
- Normalizes nested fields and casts numbers/booleans.
|
|
22
|
+
- Provides rich client-side filters for LLM-friendly use.
|
|
23
|
+
- Optional include_raw to attach original API rows for debugging.
|
|
24
|
+
"""
|
|
25
|
+
try:
|
|
26
|
+
self.check_auth()
|
|
27
|
+
|
|
28
|
+
# -------- 1) Compose API query params (spec-compliant) --------
|
|
29
|
+
# CRITICAL: limit=0 means "return 0 results"! Use 9999 for "all".
|
|
30
|
+
limit = int(args.get("limit", 9999))
|
|
31
|
+
order_by = str(args.get("order_by", "id:true"))
|
|
32
|
+
params = {"limit": str(limit), "order_by": order_by}
|
|
33
|
+
|
|
34
|
+
headers = {
|
|
35
|
+
"bs-session-id": self.get_session_id(),
|
|
36
|
+
"Content-Type": "application/json"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# -------- 2) HTTP call with timeout + simple retries --------
|
|
40
|
+
response = None
|
|
41
|
+
last_exc = None
|
|
42
|
+
for attempt in range(3):
|
|
43
|
+
try:
|
|
44
|
+
async with httpx.AsyncClient(
|
|
45
|
+
verify=False,
|
|
46
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
47
|
+
) as client:
|
|
48
|
+
response = await client.get(
|
|
49
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
50
|
+
headers=headers,
|
|
51
|
+
params=params
|
|
52
|
+
)
|
|
53
|
+
# Retry on transient server errors
|
|
54
|
+
if response.status_code >= 500:
|
|
55
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
56
|
+
continue
|
|
57
|
+
break
|
|
58
|
+
except Exception as e:
|
|
59
|
+
last_exc = e
|
|
60
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
61
|
+
|
|
62
|
+
if response is None:
|
|
63
|
+
return self.error_response(f"API temporary failure: {repr(last_exc)}")
|
|
64
|
+
|
|
65
|
+
if response.status_code != 200:
|
|
66
|
+
return self.error_response(f"API call failed: {response.status_code} - {response.text}")
|
|
67
|
+
|
|
68
|
+
data = response.json()
|
|
69
|
+
collection = (data.get("DoorCollection") or {})
|
|
70
|
+
rows = collection.get("rows") or []
|
|
71
|
+
total_from_api = int(collection.get("total") or len(rows))
|
|
72
|
+
|
|
73
|
+
# -------- 3) Helpers for normalization --------
|
|
74
|
+
def _to_int(x, default=None):
|
|
75
|
+
try:
|
|
76
|
+
return int(x)
|
|
77
|
+
except Exception:
|
|
78
|
+
return default
|
|
79
|
+
|
|
80
|
+
def _to_bool(x):
|
|
81
|
+
if isinstance(x, bool):
|
|
82
|
+
return x
|
|
83
|
+
s = str(x).strip().lower()
|
|
84
|
+
return s in ("1", "true", "t", "yes", "y")
|
|
85
|
+
|
|
86
|
+
# Parse filtering args (LLM-friendly)
|
|
87
|
+
ids = args.get("ids") or []
|
|
88
|
+
if isinstance(ids, (str, int)):
|
|
89
|
+
ids = [ids]
|
|
90
|
+
ids_set = {_to_int(i) for i in ids if _to_int(i) is not None}
|
|
91
|
+
|
|
92
|
+
name_equals = (args.get("name") or "").strip() or None
|
|
93
|
+
name_contains = (args.get("name_contains") or "").strip() or None
|
|
94
|
+
|
|
95
|
+
group_id_val = _to_int(args.get("group_id")) if args.get("group_id") is not None else None
|
|
96
|
+
group_name_arg = (args.get("group_name") or "").strip() or None
|
|
97
|
+
|
|
98
|
+
status_val = str(args.get("status")).strip() if args.get("status") is not None else None
|
|
99
|
+
|
|
100
|
+
include_raw = bool(args.get("include_raw", False))
|
|
101
|
+
minimal = bool(args.get("minimal", False)) # ← For access level creation: return only id+name
|
|
102
|
+
|
|
103
|
+
# -------- 4) Normalize nested door structure (flatten + cast) --------
|
|
104
|
+
def _dev_slim(dev_obj):
|
|
105
|
+
if not dev_obj:
|
|
106
|
+
return None
|
|
107
|
+
return {
|
|
108
|
+
"id": _to_int(dev_obj.get("id")),
|
|
109
|
+
"name": dev_obj.get("name")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def normalize_door(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
113
|
+
group = d.get("door_group_id") or {}
|
|
114
|
+
entry = d.get("entry_device_id") or {}
|
|
115
|
+
exit_dev = d.get("exit_device_id") or {}
|
|
116
|
+
relay = d.get("relay_output_id") or {}
|
|
117
|
+
relay_dev = relay.get("device_id") or {}
|
|
118
|
+
sensor = d.get("sensor_input_id") or {}
|
|
119
|
+
sensor_dev = sensor.get("device_id") or {}
|
|
120
|
+
exit_btn = d.get("exit_button_input_id") or {}
|
|
121
|
+
exit_btn_dev = exit_btn.get("device_id") or {}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"id": _to_int(d.get("id")),
|
|
125
|
+
"name": d.get("name"),
|
|
126
|
+
"description": d.get("description"),
|
|
127
|
+
"status": str(d.get("status")) if d.get("status") is not None else None,
|
|
128
|
+
"open_duration_sec": _to_int(d.get("open_duration")),
|
|
129
|
+
"open_timeout_sec": _to_int(d.get("open_timeout")),
|
|
130
|
+
"open_once": _to_bool(d.get("open_once")),
|
|
131
|
+
"unconditional_lock": _to_bool(d.get("unconditional_lock")),
|
|
132
|
+
"extended_auto_lock_timeout_sec": _to_int(d.get("extended_auto_lock_timeout")),
|
|
133
|
+
"group": {
|
|
134
|
+
"id": _to_int(group.get("id")),
|
|
135
|
+
"name": group.get("name")
|
|
136
|
+
},
|
|
137
|
+
"devices": {
|
|
138
|
+
"entry": _dev_slim(entry),
|
|
139
|
+
"exit": _dev_slim(exit_dev),
|
|
140
|
+
"relay_output": (
|
|
141
|
+
{
|
|
142
|
+
"device": _dev_slim(relay_dev),
|
|
143
|
+
"relay_index": _to_int(relay.get("relay_index"))
|
|
144
|
+
} if relay else None
|
|
145
|
+
),
|
|
146
|
+
"sensor_input": (
|
|
147
|
+
{
|
|
148
|
+
"device": _dev_slim(sensor_dev),
|
|
149
|
+
"input_index": _to_int(sensor.get("input_index")),
|
|
150
|
+
"type": _to_int(sensor.get("type")),
|
|
151
|
+
"apb_use_door_sensor": _to_bool(sensor.get("apb_use_door_sensor"))
|
|
152
|
+
} if sensor else None
|
|
153
|
+
),
|
|
154
|
+
"exit_button_input": (
|
|
155
|
+
{
|
|
156
|
+
"device": _dev_slim(exit_btn_dev),
|
|
157
|
+
"input_index": _to_int(exit_btn.get("input_index")),
|
|
158
|
+
"type": _to_int(exit_btn.get("type"))
|
|
159
|
+
} if exit_btn else None
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
doors_norm = [normalize_door(d) for d in rows]
|
|
165
|
+
|
|
166
|
+
# -------- 5) Apply client-side filters --------
|
|
167
|
+
if ids_set:
|
|
168
|
+
doors_norm = [d for d in doors_norm if d.get("id") in ids_set]
|
|
169
|
+
if name_equals:
|
|
170
|
+
doors_norm = [d for d in doors_norm if (d.get("name") or "") == name_equals]
|
|
171
|
+
if name_contains:
|
|
172
|
+
q = name_contains.lower()
|
|
173
|
+
doors_norm = [d for d in doors_norm if q in (d.get("name") or "").lower()]
|
|
174
|
+
if group_id_val is not None:
|
|
175
|
+
doors_norm = [d for d in doors_norm if ((d.get("group") or {}).get("id") == group_id_val)]
|
|
176
|
+
if group_name_arg:
|
|
177
|
+
gq = group_name_arg.lower()
|
|
178
|
+
doors_norm = [d for d in doors_norm if gq in ((d.get("group") or {}).get("name") or "").lower()]
|
|
179
|
+
if status_val is not None:
|
|
180
|
+
doors_norm = [d for d in doors_norm if str(d.get("status")) == status_val]
|
|
181
|
+
|
|
182
|
+
# Attach raw API row when requested (useful for debugging/tracing)
|
|
183
|
+
if include_raw:
|
|
184
|
+
raw_by_id = {}
|
|
185
|
+
for r in rows:
|
|
186
|
+
rid = _to_int(r.get("id"))
|
|
187
|
+
raw_by_id[rid] = r
|
|
188
|
+
for d in doors_norm:
|
|
189
|
+
d["raw"] = raw_by_id.get(d.get("id"))
|
|
190
|
+
|
|
191
|
+
# -------- 6) Lightweight summary for LLM-friendly responses --------
|
|
192
|
+
# CRITICAL: For access level creation, return ONLY id+name (minimal mode)
|
|
193
|
+
if minimal:
|
|
194
|
+
minimal_doors = []
|
|
195
|
+
for d in doors_norm:
|
|
196
|
+
door_id = d.get("id")
|
|
197
|
+
door_name = d.get("name", "")
|
|
198
|
+
if door_id is not None:
|
|
199
|
+
minimal_doors.append({"id": door_id, "name": door_name})
|
|
200
|
+
|
|
201
|
+
logger.info(f" Returning {len(minimal_doors)} doors in minimal mode (id+name only) for access level creation")
|
|
202
|
+
|
|
203
|
+
return self.success_response({
|
|
204
|
+
"message": f"Found {len(minimal_doors)} doors (minimal mode for access level creation)",
|
|
205
|
+
"total": len(minimal_doors),
|
|
206
|
+
"doors": minimal_doors
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
# Normal mode: return full details
|
|
210
|
+
groups_breakdown = {}
|
|
211
|
+
for d in doors_norm:
|
|
212
|
+
gname = (d.get("group") or {}).get("name") or "Unknown"
|
|
213
|
+
groups_breakdown[gname] = groups_breakdown.get(gname, 0) + 1
|
|
214
|
+
|
|
215
|
+
return self.success_response({
|
|
216
|
+
"message": f"Fetched {len(doors_norm)} doors (API total={total_from_api}, limit={limit}, order_by='{order_by}')",
|
|
217
|
+
"total": len(doors_norm),
|
|
218
|
+
"api_total": total_from_api,
|
|
219
|
+
"groups_breakdown": groups_breakdown,
|
|
220
|
+
"doors": doors_norm
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
return await self.handle_api_error(e)
|
|
225
|
+
|
|
226
|
+
async def get_door(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
227
|
+
"""
|
|
228
|
+
Search→disambiguate→fetch one door.
|
|
229
|
+
Behavior:
|
|
230
|
+
- If 'door_id' is provided, fetch details directly.
|
|
231
|
+
- Else, POST /api/v2/doors/search with {limit, search_text, door_group_id}.
|
|
232
|
+
- If 0 results: return a choice list of existing doors and require user input.
|
|
233
|
+
- If 1 result: auto-fetch details by id and return normalized payload.
|
|
234
|
+
- If >1 results: return the candidate list and require user input.
|
|
235
|
+
All tool-facing messages are in English. The chat layer may localize/ask the user.
|
|
236
|
+
"""
|
|
237
|
+
try:
|
|
238
|
+
self.check_auth()
|
|
239
|
+
|
|
240
|
+
headers = {
|
|
241
|
+
"bs-session-id": self.get_session_id(),
|
|
242
|
+
"Content-Type": "application/json"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
# ---------- helpers ----------
|
|
246
|
+
def _to_int(x, default=None):
|
|
247
|
+
try:
|
|
248
|
+
return int(x)
|
|
249
|
+
except Exception:
|
|
250
|
+
return default
|
|
251
|
+
|
|
252
|
+
def _to_bool(x):
|
|
253
|
+
if isinstance(x, bool):
|
|
254
|
+
return x
|
|
255
|
+
s = str(x).strip().lower()
|
|
256
|
+
return s in ("1", "true", "t", "yes", "y")
|
|
257
|
+
|
|
258
|
+
def _dev_slim(dev_obj):
|
|
259
|
+
if not dev_obj:
|
|
260
|
+
return None
|
|
261
|
+
return {"id": _to_int(dev_obj.get("id")), "name": dev_obj.get("name")}
|
|
262
|
+
|
|
263
|
+
def _normalize_brief(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
264
|
+
# Brief item for choice lists
|
|
265
|
+
g = d.get("door_group_id") or {}
|
|
266
|
+
return {
|
|
267
|
+
"id": _to_int(d.get("id")),
|
|
268
|
+
"name": d.get("name"),
|
|
269
|
+
"group": {"id": _to_int(g.get("id")), "name": g.get("name")}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def _normalize_detail(d: Dict[str, Any]) -> Dict[str, Any]:
|
|
273
|
+
# Full detail normalization (flatten + type cast)
|
|
274
|
+
g = d.get("door_group_id") or {}
|
|
275
|
+
entry = d.get("entry_device_id") or {}
|
|
276
|
+
exit_dev = d.get("exit_device_id") or {}
|
|
277
|
+
relay = d.get("relay_output_id") or {}
|
|
278
|
+
relay_dev = relay.get("device_id") or {}
|
|
279
|
+
sensor = d.get("sensor_input_id") or {}
|
|
280
|
+
sensor_dev = sensor.get("device_id") or {}
|
|
281
|
+
exit_btn = d.get("exit_button_input_id") or {}
|
|
282
|
+
exit_btn_dev = exit_btn.get("device_id") or {}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"id": _to_int(d.get("id")),
|
|
286
|
+
"name": d.get("name"),
|
|
287
|
+
"description": d.get("description"),
|
|
288
|
+
"status": str(d.get("status")) if d.get("status") is not None else None,
|
|
289
|
+
"open_duration_sec": _to_int(d.get("open_duration")),
|
|
290
|
+
"open_timeout_sec": _to_int(d.get("open_timeout")),
|
|
291
|
+
"open_once": _to_bool(d.get("open_once")),
|
|
292
|
+
"unconditional_lock": _to_bool(d.get("unconditional_lock")),
|
|
293
|
+
"group": {"id": _to_int(g.get("id")), "name": g.get("name")},
|
|
294
|
+
"devices": {
|
|
295
|
+
"entry": _dev_slim(entry),
|
|
296
|
+
"exit": _dev_slim(exit_dev),
|
|
297
|
+
"relay_output": (
|
|
298
|
+
{"device": _dev_slim(relay_dev), "relay_index": _to_int(relay.get("relay_index"))}
|
|
299
|
+
if relay else None
|
|
300
|
+
),
|
|
301
|
+
"sensor_input": (
|
|
302
|
+
{
|
|
303
|
+
"device": _dev_slim(sensor_dev),
|
|
304
|
+
"input_index": _to_int(sensor.get("input_index")),
|
|
305
|
+
"type": _to_int(sensor.get("type")),
|
|
306
|
+
"apb_use_door_sensor": _to_bool(sensor.get("apb_use_door_sensor"))
|
|
307
|
+
}
|
|
308
|
+
if sensor else None
|
|
309
|
+
),
|
|
310
|
+
"exit_button_input": (
|
|
311
|
+
{
|
|
312
|
+
"device": _dev_slim(exit_btn_dev),
|
|
313
|
+
"input_index": _to_int(exit_btn.get("input_index")),
|
|
314
|
+
"type": _to_int(exit_btn.get("type"))
|
|
315
|
+
}
|
|
316
|
+
if exit_btn else None
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async def _http_with_retry(method: str, url: str, **kwargs) -> httpx.Response:
|
|
322
|
+
# Simple retry/backoff for resilience
|
|
323
|
+
last_exc = None
|
|
324
|
+
for attempt in range(3):
|
|
325
|
+
try:
|
|
326
|
+
async with httpx.AsyncClient(
|
|
327
|
+
verify=False,
|
|
328
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
329
|
+
) as client:
|
|
330
|
+
resp = await client.request(method, url, **kwargs)
|
|
331
|
+
if resp.status_code >= 500:
|
|
332
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
333
|
+
continue
|
|
334
|
+
return resp
|
|
335
|
+
except Exception as e:
|
|
336
|
+
last_exc = e
|
|
337
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
338
|
+
raise RuntimeError(f"HTTP temporary failure: {repr(last_exc)}")
|
|
339
|
+
|
|
340
|
+
async def _fetch_detail(door_id: int) -> Dict[str, Any]:
|
|
341
|
+
# GET /api/doors/{id} and normalize "Door"
|
|
342
|
+
resp = await _http_with_retry(
|
|
343
|
+
"GET",
|
|
344
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
345
|
+
headers=headers
|
|
346
|
+
)
|
|
347
|
+
if resp.status_code != 200:
|
|
348
|
+
return self.error_response(f"API call failed: {resp.status_code} - {resp.text}")
|
|
349
|
+
raw = resp.json()
|
|
350
|
+
door_obj = (raw.get("Door") or {})
|
|
351
|
+
return self.success_response({
|
|
352
|
+
"status": "resolved",
|
|
353
|
+
"message": "Door detail fetched.",
|
|
354
|
+
"door": _normalize_detail(door_obj),
|
|
355
|
+
"api_calls": [
|
|
356
|
+
{"method": "GET", "path": f"/api/doors/{door_id}", "status": resp.status_code}
|
|
357
|
+
]
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
# ---------- direct by id ----------
|
|
361
|
+
if args.get("door_id") is not None:
|
|
362
|
+
did = _to_int(args.get("door_id"))
|
|
363
|
+
if did is None:
|
|
364
|
+
return self.error_response("Invalid 'door_id' provided.")
|
|
365
|
+
return await _fetch_detail(did)
|
|
366
|
+
|
|
367
|
+
# ---------- search-first path ----------
|
|
368
|
+
# POST /api/v2/doors/search with raw JSON payload: {"limit":50,"search_text":"...","door_group_id":1}
|
|
369
|
+
limit = _to_int(args.get("limit"), 50) or 50
|
|
370
|
+
payload = {"limit": limit}
|
|
371
|
+
|
|
372
|
+
if args.get("search_text") is not None:
|
|
373
|
+
payload["search_text"] = str(args.get("search_text"))
|
|
374
|
+
if args.get("door_group_id") is not None:
|
|
375
|
+
dg = _to_int(args.get("door_group_id"))
|
|
376
|
+
if dg is not None:
|
|
377
|
+
payload["door_group_id"] = dg
|
|
378
|
+
|
|
379
|
+
# Safety: ensure at least one search hint exists (search_text or door_group_id)
|
|
380
|
+
if "search_text" not in payload and "door_group_id" not in payload:
|
|
381
|
+
# If nothing to search by, instruct caller to provide at least one criterion.
|
|
382
|
+
return self.success_response({
|
|
383
|
+
"status": "need_user_input",
|
|
384
|
+
"reason": "missing_search_criteria",
|
|
385
|
+
"message": "Please provide at least one search criterion (e.g., 'search_text' or 'door_group_id'), or pass 'door_id' directly.",
|
|
386
|
+
"expected_params": {"door_id": "integer", "search_text": "string", "door_group_id": "integer", "limit": "integer"}
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
search_resp = await _http_with_retry(
|
|
390
|
+
"POST",
|
|
391
|
+
f"{self.session.config.biostar_url}/api/v2/doors/search",
|
|
392
|
+
headers=headers,
|
|
393
|
+
json=payload
|
|
394
|
+
)
|
|
395
|
+
if search_resp.status_code != 200:
|
|
396
|
+
return self.error_response(f"Search API failed: {search_resp.status_code} - {search_resp.text}")
|
|
397
|
+
|
|
398
|
+
search_json = search_resp.json()
|
|
399
|
+
# Accept both shapes: {DoorCollection:{rows:[...]}} or {rows:[...]}
|
|
400
|
+
collection = search_json.get("DoorCollection") or {}
|
|
401
|
+
rows = collection.get("rows")
|
|
402
|
+
if rows is None:
|
|
403
|
+
rows = search_json.get("rows") or []
|
|
404
|
+
|
|
405
|
+
candidates = [_normalize_brief(r) for r in rows if _to_int(r.get("id")) is not None]
|
|
406
|
+
|
|
407
|
+
# ---------- branch by candidate count ----------
|
|
408
|
+
api_calls_meta = [
|
|
409
|
+
{"method": "POST", "path": "/api/v2/doors/search", "status": search_resp.status_code, "payload": payload}
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
if len(candidates) == 0:
|
|
413
|
+
# When no match, list all existing doors so the user can choose from valid names/ids.
|
|
414
|
+
all_resp = await _http_with_retry(
|
|
415
|
+
"GET",
|
|
416
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
417
|
+
headers=headers,
|
|
418
|
+
params={"limit": "0", "order_by": "id:true"}
|
|
419
|
+
)
|
|
420
|
+
all_rows = []
|
|
421
|
+
if all_resp.status_code == 200:
|
|
422
|
+
all_json = all_resp.json()
|
|
423
|
+
all_rows = ((all_json.get("DoorCollection") or {}).get("rows") or [])
|
|
424
|
+
all_choices = [_normalize_brief(r) for r in all_rows if _to_int(r.get("id")) is not None]
|
|
425
|
+
|
|
426
|
+
return self.success_response({
|
|
427
|
+
"status": "need_user_input",
|
|
428
|
+
"reason": "no_match",
|
|
429
|
+
"message": "No matching doors found. Please choose one from 'choices' and call again with 'door_id'.",
|
|
430
|
+
"choices": all_choices,
|
|
431
|
+
"next_step": "Provide 'door_id' to fetch details.",
|
|
432
|
+
"api_calls": api_calls_meta + [
|
|
433
|
+
{"method": "GET", "path": "/api/doors?limit=0&order_by=id:true", "status": all_resp.status_code}
|
|
434
|
+
]
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if len(candidates) == 1:
|
|
438
|
+
# Auto-resolve by id → fetch detail
|
|
439
|
+
door_id = candidates[0]["id"]
|
|
440
|
+
detail = await _fetch_detail(door_id)
|
|
441
|
+
# Attach api_calls provenance
|
|
442
|
+
if isinstance(detail, list) and detail and isinstance(detail[0].text, str):
|
|
443
|
+
# keep default formatting
|
|
444
|
+
return detail
|
|
445
|
+
if isinstance(detail, dict) and "data" in detail:
|
|
446
|
+
# not expected given BaseHandler style; fallback to success_response path already used
|
|
447
|
+
return detail
|
|
448
|
+
# if detail is TextContent list from success_response, enrich is tricky; return as-is
|
|
449
|
+
return detail
|
|
450
|
+
|
|
451
|
+
# Multiple candidates: require user selection
|
|
452
|
+
return self.success_response({
|
|
453
|
+
"status": "need_user_input",
|
|
454
|
+
"reason": "multiple_matches",
|
|
455
|
+
"message": "Multiple doors matched. Please choose one from 'choices' and call again with 'door_id'.",
|
|
456
|
+
"choices": candidates,
|
|
457
|
+
"next_step": "Provide 'door_id' to fetch details.",
|
|
458
|
+
"api_calls": api_calls_meta
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
return await self.handle_api_error(e)
|
|
463
|
+
|
|
464
|
+
async def _get_device(self, *, device_id: int, headers: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
465
|
+
"""Fetch device detail; return None if not found."""
|
|
466
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
467
|
+
r = await client.get(f"{self.session.config.biostar_url}/api/devices/{device_id}", headers=headers)
|
|
468
|
+
if r.status_code != 200:
|
|
469
|
+
logger.info(f"Device {device_id} not found or API error: {r.status_code}")
|
|
470
|
+
return None
|
|
471
|
+
body = r.json()
|
|
472
|
+
return body.get("Device", body)
|
|
473
|
+
|
|
474
|
+
async def _list_registered_devices(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
475
|
+
"""List registered devices from the system."""
|
|
476
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
477
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/devices", headers=headers)
|
|
478
|
+
if resp.status_code != 200:
|
|
479
|
+
logger.warning(f"Failed to list registered devices: {resp.status_code} - {resp.text}")
|
|
480
|
+
return []
|
|
481
|
+
data = resp.json()
|
|
482
|
+
rows = (data.get("DeviceCollection") or {}).get("rows", []) or []
|
|
483
|
+
return rows
|
|
484
|
+
|
|
485
|
+
async def _get_used_device_ids(self, headers: Dict[str, str]) -> Set[str]:
|
|
486
|
+
"""Return a set of device ids that are already linked to any door."""
|
|
487
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
488
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
|
|
489
|
+
if resp.status_code != 200:
|
|
490
|
+
logger.warning(f"Failed to fetch doors for used ids: {resp.status_code}")
|
|
491
|
+
return set()
|
|
492
|
+
rows = (resp.json().get("DoorCollection") or {}).get("rows", []) or []
|
|
493
|
+
|
|
494
|
+
def _extract_dev_id(node: Any) -> Optional[str]:
|
|
495
|
+
if not isinstance(node, dict):
|
|
496
|
+
return None
|
|
497
|
+
if "id" in node and isinstance(node.get("id"), (str, int)):
|
|
498
|
+
return str(node["id"])
|
|
499
|
+
if "device_id" in node:
|
|
500
|
+
dv = node["device_id"]
|
|
501
|
+
if isinstance(dv, dict) and "id" in dv:
|
|
502
|
+
return str(dv["id"])
|
|
503
|
+
if isinstance(dv, (str, int)):
|
|
504
|
+
return str(dv)
|
|
505
|
+
return None
|
|
506
|
+
|
|
507
|
+
used: Set[str] = set()
|
|
508
|
+
for d in rows:
|
|
509
|
+
for key in ("entry_device_id", "relay_output_id", "exit_button_input_id", "sensor_input_id"):
|
|
510
|
+
dev = _extract_dev_id(d.get(key) or {})
|
|
511
|
+
if dev:
|
|
512
|
+
used.add(dev)
|
|
513
|
+
return used
|
|
514
|
+
|
|
515
|
+
async def _device_in_use_by_other_door(self, *, device_id: int, headers: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
516
|
+
"""Return door info if the given device_id is already used by a door; otherwise None."""
|
|
517
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
518
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
|
|
519
|
+
if resp.status_code != 200:
|
|
520
|
+
raise RuntimeError(f"Failed to fetch doors: {resp.status_code} - {resp.text}")
|
|
521
|
+
|
|
522
|
+
rows = (resp.json().get("DoorCollection") or {}).get("rows", []) or []
|
|
523
|
+
|
|
524
|
+
def _extract_dev_id(node: Any) -> Optional[str]:
|
|
525
|
+
# Support both {"id": X} and {"device_id": {"id": X}} shapes
|
|
526
|
+
if not isinstance(node, dict):
|
|
527
|
+
return None
|
|
528
|
+
if "id" in node and isinstance(node.get("id"), (str, int)):
|
|
529
|
+
return str(node["id"])
|
|
530
|
+
if "device_id" in node:
|
|
531
|
+
dv = node["device_id"]
|
|
532
|
+
if isinstance(dv, dict) and "id" in dv:
|
|
533
|
+
return str(dv["id"])
|
|
534
|
+
if isinstance(dv, (str, int)):
|
|
535
|
+
return str(dv)
|
|
536
|
+
return None
|
|
537
|
+
|
|
538
|
+
target = str(device_id)
|
|
539
|
+
for d in rows:
|
|
540
|
+
used_ids = set()
|
|
541
|
+
for key in ("entry_device_id", "relay_output_id", "exit_button_input_id", "sensor_input_id"):
|
|
542
|
+
dev = _extract_dev_id(d.get(key) or {})
|
|
543
|
+
if dev:
|
|
544
|
+
used_ids.add(dev)
|
|
545
|
+
if target in used_ids:
|
|
546
|
+
return {"door_id": d.get("id"), "door_name": d.get("name")}
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
async def _list_device_relay_indices(self, *, device_id: int, headers: Dict[str, str]) -> List[int]:
|
|
550
|
+
"""Try to infer available relay indices for the device; [] if device missing; fallback [0] when unknown."""
|
|
551
|
+
dev = await self._get_device(device_id=device_id, headers=headers)
|
|
552
|
+
if not dev:
|
|
553
|
+
return []
|
|
554
|
+
|
|
555
|
+
# 1) explicit list
|
|
556
|
+
for key in ("relays", "relay_outputs", "output_relays"):
|
|
557
|
+
if isinstance(dev.get(key), list) and dev[key]:
|
|
558
|
+
idxs: List[int] = []
|
|
559
|
+
for it in dev[key]:
|
|
560
|
+
if isinstance(it, dict):
|
|
561
|
+
for k2 in ("index", "relay_index", "id"):
|
|
562
|
+
if it.get(k2) is not None:
|
|
563
|
+
try:
|
|
564
|
+
idxs.append(int(it[k2]))
|
|
565
|
+
break
|
|
566
|
+
except Exception:
|
|
567
|
+
continue
|
|
568
|
+
if idxs:
|
|
569
|
+
return sorted(list(set(idxs)))
|
|
570
|
+
|
|
571
|
+
# 2) count-based inference
|
|
572
|
+
for key in ("relay_count", "num_of_relays", "number_of_relays"):
|
|
573
|
+
if dev.get(key) is not None:
|
|
574
|
+
try:
|
|
575
|
+
cnt = int(dev[key])
|
|
576
|
+
return list(range(max(cnt, 1)))
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
|
|
580
|
+
# 3) safe fallback
|
|
581
|
+
return [0]
|
|
582
|
+
|
|
583
|
+
async def _get_door_groups(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
584
|
+
"""Return list of door groups for selection prompts."""
|
|
585
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
586
|
+
response = await client.get(
|
|
587
|
+
f"{self.session.config.biostar_url}/api/door_groups",
|
|
588
|
+
headers=headers
|
|
589
|
+
)
|
|
590
|
+
if response.status_code != 200:
|
|
591
|
+
logger.warning(f"Failed to fetch door groups: {response.status_code} - {response.text}")
|
|
592
|
+
return []
|
|
593
|
+
data = response.json()
|
|
594
|
+
return (data.get("DoorGroupCollection") or {}).get("rows", []) or []
|
|
595
|
+
|
|
596
|
+
async def _udp_search_devices(self, headers: Dict[str, str]) -> Dict[str, Any]:
|
|
597
|
+
"""
|
|
598
|
+
Try UDP/network discovery endpoints without registering anything.
|
|
599
|
+
Returns a dict with 'endpoint_used', 'devices' (simplified list).
|
|
600
|
+
"""
|
|
601
|
+
candidates = [
|
|
602
|
+
("GET", "/api/devices/discover"),
|
|
603
|
+
("GET", "/api/devices/udp_search"),
|
|
604
|
+
("GET", "/api/devices/search"),
|
|
605
|
+
]
|
|
606
|
+
for method, path in candidates:
|
|
607
|
+
try:
|
|
608
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
609
|
+
if method == "GET":
|
|
610
|
+
resp = await client.get(f"{self.session.config.biostar_url}{path}", headers=headers)
|
|
611
|
+
else:
|
|
612
|
+
resp = await client.post(f"{self.session.config.biostar_url}{path}", headers=headers)
|
|
613
|
+
if resp.status_code != 200:
|
|
614
|
+
continue
|
|
615
|
+
data = resp.json()
|
|
616
|
+
rows = (
|
|
617
|
+
(data.get("DeviceCollection") or {}).get("rows", None)
|
|
618
|
+
or data.get("devices")
|
|
619
|
+
or data.get("rows")
|
|
620
|
+
)
|
|
621
|
+
if not isinstance(rows, list):
|
|
622
|
+
continue
|
|
623
|
+
|
|
624
|
+
simplified: List[Dict[str, Any]] = []
|
|
625
|
+
for d in rows:
|
|
626
|
+
if not isinstance(d, dict):
|
|
627
|
+
continue
|
|
628
|
+
simplified.append({
|
|
629
|
+
"id": d.get("id") or d.get("device_id") or d.get("serial") or d.get("mac"),
|
|
630
|
+
"name": d.get("name") or d.get("model_name") or d.get("model"),
|
|
631
|
+
"model": d.get("model") or d.get("type"),
|
|
632
|
+
"ip": d.get("ip") or d.get("ip_address")
|
|
633
|
+
})
|
|
634
|
+
return {"endpoint_used": path, "devices": simplified}
|
|
635
|
+
except Exception as ex:
|
|
636
|
+
logger.info(f"UDP search failed on {path}: {ex}")
|
|
637
|
+
|
|
638
|
+
return {"endpoint_used": None, "devices": []}
|
|
639
|
+
|
|
640
|
+
async def _list_doors(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
641
|
+
"""List doors (rows)."""
|
|
642
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
643
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
|
|
644
|
+
if resp.status_code != 200:
|
|
645
|
+
logger.warning(f"Failed to list doors: {resp.status_code} - {resp.text}")
|
|
646
|
+
return []
|
|
647
|
+
data = resp.json()
|
|
648
|
+
return (data.get("DoorCollection") or {}).get("rows", []) or []
|
|
649
|
+
|
|
650
|
+
async def _get_door_detail(self, door_id: int, headers: Dict[str, str]) -> Optional[Dict[str, Any]]:
|
|
651
|
+
"""Get a single door detail."""
|
|
652
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
653
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/doors/{door_id}", headers=headers)
|
|
654
|
+
if resp.status_code != 200:
|
|
655
|
+
logger.warning(f"Failed to get door {door_id}: {resp.status_code} - {resp.text}")
|
|
656
|
+
return None
|
|
657
|
+
return (resp.json() or {}).get("Door") or {}
|
|
658
|
+
|
|
659
|
+
async def _resolve_group_id(
|
|
660
|
+
self,
|
|
661
|
+
*,
|
|
662
|
+
group_id: Optional[int],
|
|
663
|
+
group_name: Optional[str],
|
|
664
|
+
headers: Dict[str, str]
|
|
665
|
+
) -> Tuple[Optional[int], Optional[str]]:
|
|
666
|
+
"""Resolve group id from (group_id | group_name). Return (id, error_message)."""
|
|
667
|
+
groups = await self._get_door_groups(headers)
|
|
668
|
+
if group_id is not None:
|
|
669
|
+
for g in groups:
|
|
670
|
+
try:
|
|
671
|
+
if int(g.get("id")) == int(group_id):
|
|
672
|
+
return int(group_id), None
|
|
673
|
+
except Exception:
|
|
674
|
+
continue
|
|
675
|
+
return None, f"Group id {group_id} not found."
|
|
676
|
+
if group_name:
|
|
677
|
+
matches = [g for g in groups if isinstance(g.get("name"), str) and g["name"].lower() == group_name.lower()]
|
|
678
|
+
if len(matches) == 1:
|
|
679
|
+
return int(matches[0]["id"]), None
|
|
680
|
+
if len(matches) > 1:
|
|
681
|
+
return None, f"Multiple groups named '{group_name}'. Use group_id. Matches: {[{'id': g.get('id'), 'name': g.get('name')} for g in matches]}"
|
|
682
|
+
return None, f"Group name '{group_name}' not found."
|
|
683
|
+
return None, "Either group_id or group_name is required."
|
|
684
|
+
|
|
685
|
+
async def _resolve_doors(
|
|
686
|
+
self,
|
|
687
|
+
*,
|
|
688
|
+
door_ids: Optional[List[int]],
|
|
689
|
+
door_names: Optional[List[str]],
|
|
690
|
+
headers: Dict[str, str]
|
|
691
|
+
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
692
|
+
"""Resolve doors from ids and/or names. Returns (list of {id, name}, error_message)."""
|
|
693
|
+
results: Dict[int, Dict[str, Any]] = {}
|
|
694
|
+
|
|
695
|
+
# resolve by ids
|
|
696
|
+
if door_ids:
|
|
697
|
+
for did in door_ids:
|
|
698
|
+
detail = await self._get_door_detail(int(did), headers=headers)
|
|
699
|
+
if not detail:
|
|
700
|
+
return [], f"Door id {did} not found."
|
|
701
|
+
results[int(did)] = {"id": int(did), "name": detail.get("name")}
|
|
702
|
+
|
|
703
|
+
# resolve by names
|
|
704
|
+
if door_names:
|
|
705
|
+
rows = await self._list_doors(headers)
|
|
706
|
+
index: Dict[str, List[Dict[str, Any]]] = {}
|
|
707
|
+
for r in rows:
|
|
708
|
+
nm = r.get("name")
|
|
709
|
+
if isinstance(nm, str):
|
|
710
|
+
index.setdefault(nm.lower(), []).append({"id": r.get("id"), "name": nm})
|
|
711
|
+
|
|
712
|
+
for nm in door_names:
|
|
713
|
+
cands = index.get(nm.lower(), [])
|
|
714
|
+
if len(cands) == 0:
|
|
715
|
+
return [], f"Door name '{nm}' not found."
|
|
716
|
+
if len(cands) > 1:
|
|
717
|
+
return [], f"Multiple doors found for name '{nm}'. Please specify door_id. Matches: {cands}"
|
|
718
|
+
d = cands[0]
|
|
719
|
+
results[int(d["id"])] = {"id": int(d["id"]), "name": d["name"]}
|
|
720
|
+
|
|
721
|
+
if not results:
|
|
722
|
+
return [], "Either door_ids or door_names must be provided."
|
|
723
|
+
|
|
724
|
+
return list(results.values()), None
|
|
725
|
+
|
|
726
|
+
async def _put_door_group_with_fallbacks(
|
|
727
|
+
self,
|
|
728
|
+
*,
|
|
729
|
+
door_id: int,
|
|
730
|
+
payloads: List[Dict[str, Any]],
|
|
731
|
+
headers: Dict[str, str]
|
|
732
|
+
) -> Tuple[bool, Optional[str], Optional[str]]:
|
|
733
|
+
"""
|
|
734
|
+
Try multiple minimal PUT payload variants until success.
|
|
735
|
+
Returns (ok, last_status, last_text).
|
|
736
|
+
"""
|
|
737
|
+
last_status = None
|
|
738
|
+
last_text = None
|
|
739
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
740
|
+
for body in payloads:
|
|
741
|
+
resp = await client.put(
|
|
742
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
743
|
+
headers=headers,
|
|
744
|
+
json=body
|
|
745
|
+
)
|
|
746
|
+
last_status = str(resp.status_code)
|
|
747
|
+
last_text = resp.text
|
|
748
|
+
if resp.status_code in (200, 204):
|
|
749
|
+
return True, last_status, last_text
|
|
750
|
+
return False, last_status, last_text
|
|
751
|
+
|
|
752
|
+
async def _fetch_doors_raw(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
|
|
753
|
+
"""
|
|
754
|
+
GET /api/doors and return its 'rows' as-is for strict name matching and validation.
|
|
755
|
+
"""
|
|
756
|
+
try:
|
|
757
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
758
|
+
resp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
|
|
759
|
+
if resp.status_code != 200:
|
|
760
|
+
logger.error("fetch_doors_raw failed: %s %s", resp.status_code, resp.text)
|
|
761
|
+
return []
|
|
762
|
+
data = resp.json() or {}
|
|
763
|
+
rows = data.get("DoorCollection", {}).get("rows") or data.get("rows") or []
|
|
764
|
+
return rows
|
|
765
|
+
except Exception as e:
|
|
766
|
+
logger.exception("fetch_doors_raw error: %s", e)
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
async def control_door(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
770
|
+
"""
|
|
771
|
+
Control door(s) with STRICT selection rules:
|
|
772
|
+
- NO typo correction. NO fuzzy matching.
|
|
773
|
+
- If 'door_names' is provided, perform EXACT (case-sensitive) name match.
|
|
774
|
+
* 0 match -> return error (door_not_found) + available list, DO NOT control.
|
|
775
|
+
* >1 match -> return error (ambiguous_name) + candidates, DO NOT control.
|
|
776
|
+
* 1 match -> resolve to that door id.
|
|
777
|
+
- If 'door_ids' is provided, validate existence; unknown ids -> error, DO NOT control.
|
|
778
|
+
- If both door_ids and door_names are provided, both sets MUST match exactly; else error.
|
|
779
|
+
- On success, POST /api/doors/{open|lock|unlock|release} with DoorCollection only.
|
|
780
|
+
|
|
781
|
+
Also:
|
|
782
|
+
- Parse DeviceResponse per door to report success/failure/unknown.
|
|
783
|
+
- DO NOT include 'duration' or any extra field for 'open' (spec compliance).
|
|
784
|
+
"""
|
|
785
|
+
try:
|
|
786
|
+
self.check_auth()
|
|
787
|
+
|
|
788
|
+
action = str(args["action"]).strip().lower()
|
|
789
|
+
raw_ids = args.get("door_ids")
|
|
790
|
+
raw_names = args.get("door_names")
|
|
791
|
+
|
|
792
|
+
headers = {
|
|
793
|
+
"bs-session-id": self.get_session_id(),
|
|
794
|
+
"Content-Type": "application/json"
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
# Map actions to endpoints (adjust here if your deployment differs)
|
|
798
|
+
action_map = {
|
|
799
|
+
"open": "open",
|
|
800
|
+
"lock": "lock",
|
|
801
|
+
"unlock": "unlock",
|
|
802
|
+
"release": "release", # change to "normal" etc. if your API uses a different path
|
|
803
|
+
}
|
|
804
|
+
if action not in action_map:
|
|
805
|
+
return self.error_response(
|
|
806
|
+
f"Invalid action: {action}. Allowed: {', '.join(action_map.keys())}"
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# Normalize IDs (accept scalar/str/list)
|
|
810
|
+
def _norm_ids(raw) -> List[int]:
|
|
811
|
+
if raw is None:
|
|
812
|
+
return []
|
|
813
|
+
if not isinstance(raw, (list, tuple, set)):
|
|
814
|
+
raw = [raw]
|
|
815
|
+
out, seen = [], set()
|
|
816
|
+
for x in raw:
|
|
817
|
+
s = str(x).strip()
|
|
818
|
+
if not s:
|
|
819
|
+
continue
|
|
820
|
+
try:
|
|
821
|
+
v = int(s)
|
|
822
|
+
if v not in seen:
|
|
823
|
+
seen.add(v); out.append(v)
|
|
824
|
+
except Exception:
|
|
825
|
+
continue
|
|
826
|
+
return out
|
|
827
|
+
|
|
828
|
+
door_ids_from_ids: List[int] = _norm_ids(raw_ids)
|
|
829
|
+
door_names_from_args: List[str] = []
|
|
830
|
+
if raw_names is not None:
|
|
831
|
+
# accept scalar or list of strings; NO normalization besides trimming
|
|
832
|
+
if not isinstance(raw_names, (list, tuple, set)):
|
|
833
|
+
raw_names = [raw_names]
|
|
834
|
+
door_names_from_args = [str(n) for n in raw_names]
|
|
835
|
+
|
|
836
|
+
if not door_ids_from_ids and not door_names_from_args:
|
|
837
|
+
return self.error_response(
|
|
838
|
+
"Provide either 'door_ids' or 'door_names' for strict selection."
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# Fetch all doors once for validation/resolution
|
|
842
|
+
all_rows = await self._fetch_doors_raw(headers)
|
|
843
|
+
known_id_set: Set[int] = set()
|
|
844
|
+
by_name: Dict[str, List[Dict[str, Any]]] = {}
|
|
845
|
+
|
|
846
|
+
for d in all_rows:
|
|
847
|
+
did = d.get("id")
|
|
848
|
+
try:
|
|
849
|
+
did_int = int(str(did))
|
|
850
|
+
known_id_set.add(did_int)
|
|
851
|
+
except Exception:
|
|
852
|
+
pass
|
|
853
|
+
nm = str(d.get("name") or "")
|
|
854
|
+
by_name.setdefault(nm, []).append(d)
|
|
855
|
+
|
|
856
|
+
# Resolve names strictly (case-sensitive equals)
|
|
857
|
+
resolved_from_names: List[int] = []
|
|
858
|
+
name_not_found: List[str] = []
|
|
859
|
+
name_ambiguous: List[Dict[str, Any]] = []
|
|
860
|
+
|
|
861
|
+
for nm in door_names_from_args:
|
|
862
|
+
exact = by_name.get(nm, [])
|
|
863
|
+
if len(exact) == 0:
|
|
864
|
+
name_not_found.append(nm)
|
|
865
|
+
elif len(exact) > 1:
|
|
866
|
+
# include rich candidates for the user to choose from
|
|
867
|
+
cands = []
|
|
868
|
+
for r in exact:
|
|
869
|
+
cands.append({
|
|
870
|
+
"id": r.get("id"),
|
|
871
|
+
"name": r.get("name"),
|
|
872
|
+
# include helpful fields if present in your deployment
|
|
873
|
+
"door_group_id_list": r.get("door_group_id_list"),
|
|
874
|
+
"entry_device_id": r.get("entry_device_id") or r.get("device_id"),
|
|
875
|
+
"description": r.get("description")
|
|
876
|
+
})
|
|
877
|
+
name_ambiguous.append({"name": nm, "candidates": cands})
|
|
878
|
+
else:
|
|
879
|
+
try:
|
|
880
|
+
resolved_from_names.append(int(str(exact[0].get("id"))))
|
|
881
|
+
except Exception:
|
|
882
|
+
name_not_found.append(nm)
|
|
883
|
+
|
|
884
|
+
# Hard stop on not-found or ambiguous names
|
|
885
|
+
if name_not_found or name_ambiguous:
|
|
886
|
+
return self.error_response(
|
|
887
|
+
"Strict name match failed; explicit selection required.",
|
|
888
|
+
{
|
|
889
|
+
"status": "needs_selection",
|
|
890
|
+
"unresolved_names": name_not_found,
|
|
891
|
+
"ambiguous": name_ambiguous,
|
|
892
|
+
"available_doors": [
|
|
893
|
+
{"id": r.get("id"), "name": r.get("name")}
|
|
894
|
+
for r in all_rows
|
|
895
|
+
if r.get("id") is not None
|
|
896
|
+
]
|
|
897
|
+
}
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
# Validate id existence (if provided)
|
|
901
|
+
unknown_ids = [i for i in door_ids_from_ids if i not in known_id_set]
|
|
902
|
+
if unknown_ids:
|
|
903
|
+
return self.error_response(
|
|
904
|
+
"Unknown door id(s). No action performed.",
|
|
905
|
+
{
|
|
906
|
+
"status": "unknown_ids",
|
|
907
|
+
"unknown_ids": unknown_ids,
|
|
908
|
+
"available_doors": [
|
|
909
|
+
{"id": r.get("id"), "name": r.get("name")}
|
|
910
|
+
for r in all_rows
|
|
911
|
+
if r.get("id") is not None
|
|
912
|
+
]
|
|
913
|
+
}
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
# Merge/consistency check if both names and ids are given
|
|
917
|
+
final_ids: List[int]
|
|
918
|
+
if door_ids_from_ids and resolved_from_names:
|
|
919
|
+
set_ids, set_names = set(door_ids_from_ids), set(resolved_from_names)
|
|
920
|
+
if set_ids != set_names:
|
|
921
|
+
return self.error_response(
|
|
922
|
+
"door_ids and door_names do not resolve to the same set. No action performed.",
|
|
923
|
+
{
|
|
924
|
+
"status": "mismatched_selection",
|
|
925
|
+
"ids_from_door_ids": sorted(list(set_ids)),
|
|
926
|
+
"ids_from_door_names": sorted(list(set_names))
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
final_ids = sorted(list(set_ids))
|
|
930
|
+
elif resolved_from_names:
|
|
931
|
+
final_ids = sorted(list(set(resolved_from_names)))
|
|
932
|
+
else:
|
|
933
|
+
final_ids = sorted(list(set(door_ids_from_ids)))
|
|
934
|
+
|
|
935
|
+
# Build spec-compliant payload
|
|
936
|
+
endpoint_action = action_map[action]
|
|
937
|
+
payload = {
|
|
938
|
+
"DoorCollection": {
|
|
939
|
+
"rows": [{"id": did} for did in final_ids]
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
944
|
+
resp = await client.post(
|
|
945
|
+
f"{self.session.config.biostar_url}/api/doors/{endpoint_action}",
|
|
946
|
+
headers=headers,
|
|
947
|
+
json=payload
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
if resp.status_code != 200:
|
|
951
|
+
try:
|
|
952
|
+
rj = resp.json() or {}
|
|
953
|
+
except Exception:
|
|
954
|
+
rj = {}
|
|
955
|
+
dev = rj.get("DeviceResponse")
|
|
956
|
+
return self.error_response(
|
|
957
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
958
|
+
{"request_body": payload, "device_response": dev}
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# Parse DeviceResponse per door
|
|
962
|
+
try:
|
|
963
|
+
body = resp.json() or {}
|
|
964
|
+
except Exception:
|
|
965
|
+
body = {}
|
|
966
|
+
device_resp = body.get("DeviceResponse") or {}
|
|
967
|
+
rows = device_resp.get("rows") or []
|
|
968
|
+
|
|
969
|
+
successes: List[int] = []
|
|
970
|
+
failures: List[Dict[str, Any]] = []
|
|
971
|
+
requested_set = set(final_ids)
|
|
972
|
+
|
|
973
|
+
for r in rows:
|
|
974
|
+
rid = r.get("id")
|
|
975
|
+
code = r.get("code")
|
|
976
|
+
try:
|
|
977
|
+
rid_int = int(str(rid))
|
|
978
|
+
except Exception:
|
|
979
|
+
rid_int = None
|
|
980
|
+
is_ok = (str(code) == "0" or code == 0)
|
|
981
|
+
if is_ok and rid_int is not None:
|
|
982
|
+
successes.append(rid_int)
|
|
983
|
+
else:
|
|
984
|
+
failures.append({
|
|
985
|
+
"id": rid if rid is not None else None,
|
|
986
|
+
"code": code,
|
|
987
|
+
"message": r.get("message")
|
|
988
|
+
})
|
|
989
|
+
|
|
990
|
+
echoed = {i for i in successes} | {int(str(f["id"])) for f in failures if f.get("id") is not None}
|
|
991
|
+
unknown_ids_echo = sorted(list(requested_set - echoed))
|
|
992
|
+
|
|
993
|
+
summary_msg = (
|
|
994
|
+
f"{action.capitalize()} executed: "
|
|
995
|
+
f"{len(successes)} success"
|
|
996
|
+
f"{', ' + str(len(failures)) + ' failure' if failures else ''}"
|
|
997
|
+
f"{', ' + str(len(unknown_ids_echo)) + ' unknown' if unknown_ids_echo else ''}."
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
return self.success_response({
|
|
1001
|
+
"message": summary_msg,
|
|
1002
|
+
"action": action,
|
|
1003
|
+
"requested": final_ids,
|
|
1004
|
+
"success_ids": sorted(successes),
|
|
1005
|
+
"failed": failures,
|
|
1006
|
+
"unknown_ids": unknown_ids_echo,
|
|
1007
|
+
"device_response": device_resp,
|
|
1008
|
+
"request_body": payload
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
except Exception as e:
|
|
1012
|
+
return await self.handle_api_error(e)
|
|
1013
|
+
|
|
1014
|
+
# ----------------------------
|
|
1015
|
+
# Creation with guarded flow and conflict UDP listing
|
|
1016
|
+
# ----------------------------
|
|
1017
|
+
|
|
1018
|
+
async def create_door(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1019
|
+
"""
|
|
1020
|
+
Create a new door using a guarded flow:
|
|
1021
|
+
- No automatic discovery/registration or automatic door creation with other devices.
|
|
1022
|
+
- If confirm=False (default), returns a plan preview and group list.
|
|
1023
|
+
- On 1:1 conflict, returns only UDP discovery list (filtered to not-in-use devices) and stops.
|
|
1024
|
+
- On success, returns door info and the full list of door groups.
|
|
1025
|
+
"""
|
|
1026
|
+
try:
|
|
1027
|
+
self.check_auth()
|
|
1028
|
+
|
|
1029
|
+
headers = {
|
|
1030
|
+
"bs-session-id": self.get_session_id(),
|
|
1031
|
+
"Content-Type": "application/json"
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
confirm: bool = bool(args.get("confirm", False))
|
|
1035
|
+
default_group_id: int = int(args.get("default_group_id", 1))
|
|
1036
|
+
|
|
1037
|
+
# Minimal fields to start a plan
|
|
1038
|
+
minimal_required = ["name", "entry_device_id"]
|
|
1039
|
+
minimal_missing = [f for f in minimal_required if args.get(f) in (None, "")]
|
|
1040
|
+
if minimal_missing:
|
|
1041
|
+
return self.error_response(
|
|
1042
|
+
"Missing required fields.",
|
|
1043
|
+
{"missing_fields": minimal_missing, "required_minimal": minimal_required}
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
entry_device_id = int(args["entry_device_id"])
|
|
1047
|
+
|
|
1048
|
+
# Device must exist; no auto-registration here
|
|
1049
|
+
device = await self._get_device(device_id=entry_device_id, headers=headers)
|
|
1050
|
+
if not device:
|
|
1051
|
+
groups = await self._get_door_groups(headers)
|
|
1052
|
+
return self.error_response(
|
|
1053
|
+
"Specified device is not registered in the system.",
|
|
1054
|
+
{
|
|
1055
|
+
"device_id": entry_device_id,
|
|
1056
|
+
"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups],
|
|
1057
|
+
"note": "Register the device first, then retry."
|
|
1058
|
+
}
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
# Enforce 1:1 mapping
|
|
1062
|
+
conflict = await self._device_in_use_by_other_door(device_id=entry_device_id, headers=headers)
|
|
1063
|
+
if conflict:
|
|
1064
|
+
# UDP discovery only; filter out devices already in use
|
|
1065
|
+
udp_info = await self._udp_search_devices(headers)
|
|
1066
|
+
used_ids = await self._get_used_device_ids(headers)
|
|
1067
|
+
filtered_devices: List[Dict[str, Any]] = []
|
|
1068
|
+
for d in udp_info.get("devices", []):
|
|
1069
|
+
did = d.get("id")
|
|
1070
|
+
if did is None:
|
|
1071
|
+
continue
|
|
1072
|
+
if str(did) in used_ids:
|
|
1073
|
+
continue
|
|
1074
|
+
filtered_devices.append(d)
|
|
1075
|
+
|
|
1076
|
+
# Stop here without any follow-up or hints
|
|
1077
|
+
return self.error_response(
|
|
1078
|
+
"This device is already mapped to another door (1:1 policy).",
|
|
1079
|
+
{
|
|
1080
|
+
"conflict": {
|
|
1081
|
+
"device_id": entry_device_id,
|
|
1082
|
+
"door_id": conflict["door_id"],
|
|
1083
|
+
"door_name": conflict["door_name"]
|
|
1084
|
+
},
|
|
1085
|
+
"udp_discovery": {
|
|
1086
|
+
"endpoint_used": udp_info.get("endpoint_used"),
|
|
1087
|
+
"devices": filtered_devices
|
|
1088
|
+
},
|
|
1089
|
+
"note": "Select one device from the list...y manually. No automatic registration or creation is performed."
|
|
1090
|
+
}
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
# relay_index must be provided to create; otherwise only show options
|
|
1094
|
+
relay_index = args.get("relay_index", None)
|
|
1095
|
+
if relay_index is None:
|
|
1096
|
+
relay_options = await self._list_device_relay_indices(device_id=entry_device_id, headers=headers)
|
|
1097
|
+
groups = await self._get_door_groups(headers)
|
|
1098
|
+
return self.success_response({
|
|
1099
|
+
"message": "relay_index is required to create the door.",
|
|
1100
|
+
"device_id": entry_device_id,
|
|
1101
|
+
"relay_options": relay_options,
|
|
1102
|
+
"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups]
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
# Determine group used at creation time
|
|
1106
|
+
create_group_id = int(args.get("door_group_id", default_group_id))
|
|
1107
|
+
|
|
1108
|
+
# ---- XOR-safe mode defaults and validation ----
|
|
1109
|
+
# Default both 'open_once' and 'unconditional_lock' to OFF if not provided.
|
|
1110
|
+
def _b(x, default=False) -> bool:
|
|
1111
|
+
if x is None:
|
|
1112
|
+
return default
|
|
1113
|
+
if isinstance(x, bool):
|
|
1114
|
+
return x
|
|
1115
|
+
s = str(x).strip().lower()
|
|
1116
|
+
return s in ("1", "true", "t", "yes", "y")
|
|
1117
|
+
|
|
1118
|
+
desired_open_once = _b(args.get("open_once"), False) # default OFF
|
|
1119
|
+
desired_auto = _b(args.get("unconditional_lock"), False) # default OFF
|
|
1120
|
+
|
|
1121
|
+
# XOR rule: they cannot both be true
|
|
1122
|
+
if desired_open_once and desired_auto:
|
|
1123
|
+
return self.error_response(
|
|
1124
|
+
"Business rule violation: 'open_once' and 'unconditional_lock' cannot both be true on creation.",
|
|
1125
|
+
{"hint": "Turn one of them off.", "received": {"open_once": True, "unconditional_lock": True}}
|
|
1126
|
+
)
|
|
1127
|
+
|
|
1128
|
+
# Build payload
|
|
1129
|
+
planned_payload: Dict[str, Any] = {
|
|
1130
|
+
"Door": {
|
|
1131
|
+
"name": args["name"],
|
|
1132
|
+
"description": args.get("description") or "",
|
|
1133
|
+
"door_group_id": {"id": create_group_id},
|
|
1134
|
+
"open_timeout": int(args.get("open_timeout", 10)),
|
|
1135
|
+
"open_duration": str(int(args.get("open_duration", 5))),
|
|
1136
|
+
# Enforce defaults (OFF) and XOR-safe values
|
|
1137
|
+
"open_once": "true" if desired_open_once else "false",
|
|
1138
|
+
"unconditional_lock": "true" if desired_auto else "false",
|
|
1139
|
+
"entry_device_id": {"id": entry_device_id},
|
|
1140
|
+
"relay_output_id": {
|
|
1141
|
+
"device_id": {"id": entry_device_id},
|
|
1142
|
+
"relay_index": int(relay_index)
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
# Helper function to safely convert to int
|
|
1148
|
+
def safe_int(value, default=0):
|
|
1149
|
+
"""Safely convert value to int, handling 'none', 'false', 'true', etc."""
|
|
1150
|
+
if value is None or value == "" or str(value).lower() == "none":
|
|
1151
|
+
return default
|
|
1152
|
+
if isinstance(value, bool):
|
|
1153
|
+
return 1 if value else 0
|
|
1154
|
+
if isinstance(value, str):
|
|
1155
|
+
if value.lower() == "false":
|
|
1156
|
+
return 0
|
|
1157
|
+
if value.lower() == "true":
|
|
1158
|
+
return 1
|
|
1159
|
+
try:
|
|
1160
|
+
return int(value)
|
|
1161
|
+
except (ValueError, TypeError):
|
|
1162
|
+
return default
|
|
1163
|
+
|
|
1164
|
+
required_for_creation = [
|
|
1165
|
+
"exit_device_id", "exit_input_index", "exit_input_type",
|
|
1166
|
+
"sensor_device_id", "sensor_input_index", "sensor_input_type", "apb_use_door_sensor"
|
|
1167
|
+
]
|
|
1168
|
+
# Filter out 'none' and invalid values
|
|
1169
|
+
missing_for_creation = [
|
|
1170
|
+
f for f in required_for_creation
|
|
1171
|
+
if args.get(f) in (None, "", "none") or str(args.get(f)).lower() == "none"
|
|
1172
|
+
]
|
|
1173
|
+
if not missing_for_creation:
|
|
1174
|
+
planned_payload["Door"]["exit_button_input_id"] = {
|
|
1175
|
+
"device_id": {"id": safe_int(args["exit_device_id"])},
|
|
1176
|
+
"input_index": safe_int(args["exit_input_index"]),
|
|
1177
|
+
"type": str(safe_int(args["exit_input_type"]))
|
|
1178
|
+
}
|
|
1179
|
+
planned_payload["Door"]["sensor_input_id"] = {
|
|
1180
|
+
"device_id": {"id": safe_int(args["sensor_device_id"])},
|
|
1181
|
+
"input_index": safe_int(args["sensor_input_index"]),
|
|
1182
|
+
"type": str(safe_int(args["sensor_input_type"])),
|
|
1183
|
+
"apb_use_door_sensor": str(safe_int(args["apb_use_door_sensor"]))
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
# Always provide group list in preview
|
|
1187
|
+
groups_for_prompt = await self._get_door_groups(headers)
|
|
1188
|
+
|
|
1189
|
+
if not confirm:
|
|
1190
|
+
return self.success_response({
|
|
1191
|
+
"message": "Preview the door creation plan.",
|
|
1192
|
+
"needs_confirmation": True,
|
|
1193
|
+
"group_prompt": {
|
|
1194
|
+
"group_used_for_creation": create_group_id,
|
|
1195
|
+
"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups_for_prompt],
|
|
1196
|
+
"question": "Would you like to assign the door to one of these groups after creation?"
|
|
1197
|
+
},
|
|
1198
|
+
"missing_for_creation": missing_for_creation,
|
|
1199
|
+
"relay_index": int(relay_index),
|
|
1200
|
+
"request_body_preview": planned_payload
|
|
1201
|
+
})
|
|
1202
|
+
|
|
1203
|
+
# Confirmed path requires all blocks
|
|
1204
|
+
if missing_for_creation:
|
|
1205
|
+
return self.error_response(
|
|
1206
|
+
"Missing fields required for confirmed creation.",
|
|
1207
|
+
{"missing_fields": missing_for_creation, "request_body_preview": planned_payload}
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
# Create now
|
|
1211
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
1212
|
+
response = await client.post(
|
|
1213
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
1214
|
+
headers=headers,
|
|
1215
|
+
json=planned_payload
|
|
1216
|
+
)
|
|
1217
|
+
|
|
1218
|
+
if response.status_code not in (200, 201):
|
|
1219
|
+
return self.error_response(
|
|
1220
|
+
f"API call failed: {response.status_code} - {response.text}",
|
|
1221
|
+
{"request_body": planned_payload}
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
try:
|
|
1225
|
+
resp_json = response.json()
|
|
1226
|
+
except Exception:
|
|
1227
|
+
resp_json = {}
|
|
1228
|
+
new_id = (
|
|
1229
|
+
resp_json.get("Door", {}).get("id")
|
|
1230
|
+
or resp_json.get("id")
|
|
1231
|
+
or resp_json.get("Door", {}).get("door_id")
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
# Post-creation: include door groups for optional assignment
|
|
1235
|
+
groups_after = await self._get_door_groups(headers)
|
|
1236
|
+
return self.success_response({
|
|
1237
|
+
"message": f"Door '{args['name']}' has been created.",
|
|
1238
|
+
"door_id": new_id,
|
|
1239
|
+
"request_body": planned_payload,
|
|
1240
|
+
"group_prompt": {
|
|
1241
|
+
"group_used_for_creation": create_group_id,
|
|
1242
|
+
"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups_after],
|
|
1243
|
+
"question": "Would you like to move the door to a specific group now?"
|
|
1244
|
+
}
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
except Exception as e:
|
|
1248
|
+
return await self.handle_api_error(e)
|
|
1249
|
+
|
|
1250
|
+
async def update_door(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1251
|
+
"""Update an existing door."""
|
|
1252
|
+
try:
|
|
1253
|
+
self.check_auth()
|
|
1254
|
+
|
|
1255
|
+
door_id = args["door_id"]
|
|
1256
|
+
|
|
1257
|
+
headers = {
|
|
1258
|
+
"bs-session-id": self.get_session_id(),
|
|
1259
|
+
"Content-Type": "application/json"
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
# First get existing door data
|
|
1263
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
1264
|
+
get_response = await client.get(
|
|
1265
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
1266
|
+
headers=headers
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
if get_response.status_code != 200:
|
|
1270
|
+
return self.error_response(f"Failed to get door: {get_response.status_code}")
|
|
1271
|
+
|
|
1272
|
+
door_data = get_response.json()
|
|
1273
|
+
|
|
1274
|
+
# Update only provided fields
|
|
1275
|
+
if "name" in args:
|
|
1276
|
+
door_data["Door"]["name"] = args["name"]
|
|
1277
|
+
if "open_duration" in args:
|
|
1278
|
+
door_data["Door"]["open_duration"] = args["open_duration"]
|
|
1279
|
+
if "open_timeout" in args:
|
|
1280
|
+
door_data["Door"]["open_timeout"] = args["open_timeout"]
|
|
1281
|
+
if "dual_auth_required" in args:
|
|
1282
|
+
door_data["Door"]["dual_auth_required"] = args["dual_auth_required"]
|
|
1283
|
+
|
|
1284
|
+
# Update the door
|
|
1285
|
+
update_response = await client.put(
|
|
1286
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
1287
|
+
headers=headers,
|
|
1288
|
+
json=door_data
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
if update_response.status_code != 200:
|
|
1292
|
+
return self.error_response(f"API call failed: {update_response.status_code} - {update_response.text}")
|
|
1293
|
+
|
|
1294
|
+
return self.success_response({
|
|
1295
|
+
"message": f"Door {door_id} updated successfully"
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
except Exception as e:
|
|
1299
|
+
return await self.handle_api_error(e)
|
|
1300
|
+
|
|
1301
|
+
async def delete_door(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1302
|
+
"""Delete a door."""
|
|
1303
|
+
try:
|
|
1304
|
+
self.check_auth()
|
|
1305
|
+
|
|
1306
|
+
door_id = args["door_id"]
|
|
1307
|
+
|
|
1308
|
+
headers = {
|
|
1309
|
+
"bs-session-id": self.get_session_id(),
|
|
1310
|
+
"Content-Type": "application/json"
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
1314
|
+
response = await client.delete(
|
|
1315
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
1316
|
+
headers=headers
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
if response.status_code not in [200, 204]:
|
|
1320
|
+
return self.error_response(f"API call failed: {response.status_code} - {response.text}")
|
|
1321
|
+
|
|
1322
|
+
return self.success_response({
|
|
1323
|
+
"message": f"Door {door_id} deleted successfully"
|
|
1324
|
+
})
|
|
1325
|
+
|
|
1326
|
+
except Exception as e:
|
|
1327
|
+
return await self.handle_api_error(e)
|
|
1328
|
+
|
|
1329
|
+
async def get_doors_status(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1330
|
+
"""
|
|
1331
|
+
Get status of doors with normalization, filters, enrichment and summary.
|
|
1332
|
+
|
|
1333
|
+
Features:
|
|
1334
|
+
- Calls POST /api/doors/status with {"monitoring_permission": true} unless overridden.
|
|
1335
|
+
- Normalizes booleans/ints and derives a human-friendly label from open/unlock/alarm.
|
|
1336
|
+
- Optional enrichment to join door names/groups via GET /api/doors?limit=0.
|
|
1337
|
+
- Flexible filters: door_ids, is_open, is_unlocked, has_alarm, status_in, group/name (when enriched).
|
|
1338
|
+
- Returns summary breakdowns and optional device response analysis.
|
|
1339
|
+
- Supports include_raw for debugging and api_calls provenance.
|
|
1340
|
+
"""
|
|
1341
|
+
try:
|
|
1342
|
+
self.check_auth()
|
|
1343
|
+
|
|
1344
|
+
headers = {
|
|
1345
|
+
"bs-session-id": self.get_session_id(),
|
|
1346
|
+
"Content-Type": "application/json"
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
# ---------------- Helpers ----------------
|
|
1350
|
+
def _to_int(x, default=None):
|
|
1351
|
+
try:
|
|
1352
|
+
return int(str(x))
|
|
1353
|
+
except Exception:
|
|
1354
|
+
return default
|
|
1355
|
+
|
|
1356
|
+
def _to_bool(x):
|
|
1357
|
+
if isinstance(x, bool):
|
|
1358
|
+
return x
|
|
1359
|
+
s = str(x).strip().lower()
|
|
1360
|
+
return s in ("1", "true", "t", "yes", "y")
|
|
1361
|
+
|
|
1362
|
+
def _epoch_to_iso(ts) -> Optional[str]:
|
|
1363
|
+
v = _to_int(ts)
|
|
1364
|
+
if v is None or v <= 0:
|
|
1365
|
+
return None
|
|
1366
|
+
# Interpret as seconds since epoch
|
|
1367
|
+
try:
|
|
1368
|
+
from datetime import datetime, timezone
|
|
1369
|
+
return datetime.fromtimestamp(v, tz=timezone.utc).isoformat()
|
|
1370
|
+
except Exception:
|
|
1371
|
+
return None
|
|
1372
|
+
|
|
1373
|
+
async def _http_with_retry(method: str, url: str, **kwargs) -> httpx.Response:
|
|
1374
|
+
last_exc = None
|
|
1375
|
+
for attempt in range(3):
|
|
1376
|
+
try:
|
|
1377
|
+
async with httpx.AsyncClient(
|
|
1378
|
+
verify=False,
|
|
1379
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
1380
|
+
) as client:
|
|
1381
|
+
resp = await client.request(method, url, **kwargs)
|
|
1382
|
+
if resp.status_code >= 500:
|
|
1383
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
1384
|
+
continue
|
|
1385
|
+
return resp
|
|
1386
|
+
except Exception as e:
|
|
1387
|
+
last_exc = e
|
|
1388
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
1389
|
+
raise RuntimeError(f"HTTP temporary failure: {repr(last_exc)}")
|
|
1390
|
+
|
|
1391
|
+
# Heuristic label from flags; 'status' numeric mapping is vendor-specific, so we derive safely.
|
|
1392
|
+
def _derived_label(is_open: bool, is_unlocked: bool, has_alarm: bool) -> str:
|
|
1393
|
+
if has_alarm:
|
|
1394
|
+
return "Alarm"
|
|
1395
|
+
if is_open and is_unlocked:
|
|
1396
|
+
return "Open & Unlocked"
|
|
1397
|
+
if is_open and not is_unlocked:
|
|
1398
|
+
return "Open"
|
|
1399
|
+
if not is_open and is_unlocked:
|
|
1400
|
+
return "Unlocked"
|
|
1401
|
+
return "Normal" # closed & locked
|
|
1402
|
+
|
|
1403
|
+
# ---------------- Read args ----------------
|
|
1404
|
+
monitoring_permission = bool(args.get("monitoring_permission", True))
|
|
1405
|
+
include_raw = bool(args.get("include_raw", False))
|
|
1406
|
+
include_enrichment = bool(args.get("include_enrichment", True)) # join door names/groups
|
|
1407
|
+
include_device_response = bool(args.get("include_device_response", True))
|
|
1408
|
+
|
|
1409
|
+
# Basic filters
|
|
1410
|
+
door_ids = args.get("door_ids") or []
|
|
1411
|
+
if isinstance(door_ids, (int, str)):
|
|
1412
|
+
door_ids = [door_ids]
|
|
1413
|
+
door_ids = {_to_int(did) for did in door_ids if _to_int(did) is not None}
|
|
1414
|
+
|
|
1415
|
+
is_open_filter = args.get("is_open") # None or bool
|
|
1416
|
+
is_unlocked_filter = args.get("is_unlocked")
|
|
1417
|
+
has_alarm_filter = args.get("has_alarm")
|
|
1418
|
+
status_in = args.get("status_in") or [] # list of acceptable status codes as str/int
|
|
1419
|
+
status_in = {str(s) for s in status_in} if status_in else set()
|
|
1420
|
+
|
|
1421
|
+
# Enrichment filters (only applied when include_enrichment=True)
|
|
1422
|
+
group_id_filter = _to_int(args.get("group_id")) if args.get("group_id") is not None else None
|
|
1423
|
+
group_name_filter = (args.get("group_name") or "").strip().lower() or None
|
|
1424
|
+
name_contains_filter = (args.get("name_contains") or "").strip().lower() or None
|
|
1425
|
+
|
|
1426
|
+
# Sorting options
|
|
1427
|
+
sort_by = (args.get("sort_by") or "door_id").strip() # "door_id" | "name" | "group_name"
|
|
1428
|
+
sort_desc = bool(args.get("sort_desc", False))
|
|
1429
|
+
|
|
1430
|
+
# ---------------- Call Status API ----------------
|
|
1431
|
+
payload = {"monitoring_permission": monitoring_permission}
|
|
1432
|
+
api_calls_meta = []
|
|
1433
|
+
status_resp = await _http_with_retry(
|
|
1434
|
+
"POST",
|
|
1435
|
+
f"{self.session.config.biostar_url}/api/doors/status",
|
|
1436
|
+
headers=headers,
|
|
1437
|
+
json=payload
|
|
1438
|
+
)
|
|
1439
|
+
api_calls_meta.append({"method": "POST", "path": "/api/doors/status", "status": status_resp.status_code, "payload": payload})
|
|
1440
|
+
|
|
1441
|
+
if status_resp.status_code != 200:
|
|
1442
|
+
return self.error_response(f"API call failed: {status_resp.status_code} - {status_resp.text}")
|
|
1443
|
+
|
|
1444
|
+
status_json = status_resp.json() or {}
|
|
1445
|
+
ds_coll = status_json.get("DoorStatusCollection") or {}
|
|
1446
|
+
ds_rows = ds_coll.get("rows") or []
|
|
1447
|
+
|
|
1448
|
+
# DeviceResponse normalization (optional)
|
|
1449
|
+
device_resp_info = None
|
|
1450
|
+
if include_device_response:
|
|
1451
|
+
dev = status_json.get("DeviceResponse") or {}
|
|
1452
|
+
dev_rows = dev.get("rows") or []
|
|
1453
|
+
nonzero = [r for r in dev_rows if str(r.get("code")) not in ("0", "")]
|
|
1454
|
+
device_resp_info = {
|
|
1455
|
+
"result": _to_bool(dev.get("result", False)),
|
|
1456
|
+
"rows": [{"id": _to_int(r.get("id")), "code": str(r.get("code"))} for r in dev_rows],
|
|
1457
|
+
"nonzero_codes": [{"id": _to_int(r.get("id")), "code": str(r.get("code"))} for r in nonzero],
|
|
1458
|
+
"has_errors": len(nonzero) > 0
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
# ---------------- Optional enrichment: GET /api/doors (name/group join) ----------------
|
|
1462
|
+
door_map = {} # id -> {name, group:{id,name}}
|
|
1463
|
+
if include_enrichment:
|
|
1464
|
+
doors_resp = await _http_with_retry(
|
|
1465
|
+
"GET",
|
|
1466
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
1467
|
+
headers=headers,
|
|
1468
|
+
params={"limit": "0", "order_by": "id:true"}
|
|
1469
|
+
)
|
|
1470
|
+
api_calls_meta.append({"method": "GET", "path": "/api/doors?limit=0&order_by=id:true", "status": doors_resp.status_code})
|
|
1471
|
+
if doors_resp.status_code == 200:
|
|
1472
|
+
dj = doors_resp.json() or {}
|
|
1473
|
+
rows = ((dj.get("DoorCollection") or {}).get("rows") or [])
|
|
1474
|
+
for r in rows:
|
|
1475
|
+
rid = _to_int((r.get("id")))
|
|
1476
|
+
if rid is None:
|
|
1477
|
+
continue
|
|
1478
|
+
g = r.get("door_group_id") or {}
|
|
1479
|
+
door_map[rid] = {
|
|
1480
|
+
"name": r.get("name"),
|
|
1481
|
+
"group": {"id": _to_int(g.get("id")), "name": g.get("name")}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
# ---------------- Normalize each status row ----------------
|
|
1485
|
+
normalized: List[Dict[str, Any]] = []
|
|
1486
|
+
for r in ds_rows:
|
|
1487
|
+
did = _to_int(((r.get("door_id") or {}).get("id")))
|
|
1488
|
+
if did is None:
|
|
1489
|
+
continue
|
|
1490
|
+
is_open = _to_bool(r.get("opened"))
|
|
1491
|
+
is_unlocked = _to_bool(r.get("unlocked"))
|
|
1492
|
+
has_alarm = _to_bool(r.get("alarm"))
|
|
1493
|
+
status_code = None
|
|
1494
|
+
if r.get("status") is not None:
|
|
1495
|
+
status_code = str(r.get("status"))
|
|
1496
|
+
|
|
1497
|
+
last_open_epoch = _to_int(r.get("last_open_time"))
|
|
1498
|
+
item = {
|
|
1499
|
+
"door_id": did,
|
|
1500
|
+
"is_open": is_open,
|
|
1501
|
+
"is_unlocked": is_unlocked,
|
|
1502
|
+
"has_alarm": has_alarm,
|
|
1503
|
+
"status_code": status_code, # pass-through; vendor-specific
|
|
1504
|
+
"derived_label": _derived_label(is_open, is_unlocked, has_alarm),
|
|
1505
|
+
"last_open_time_epoch": last_open_epoch,
|
|
1506
|
+
"last_open_time_iso": _epoch_to_iso(last_open_epoch)
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if include_enrichment:
|
|
1510
|
+
meta = door_map.get(did, {})
|
|
1511
|
+
item["door"] = {
|
|
1512
|
+
"name": meta.get("name"),
|
|
1513
|
+
"group": meta.get("group")
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if include_raw:
|
|
1517
|
+
item["raw"] = r
|
|
1518
|
+
|
|
1519
|
+
normalized.append(item)
|
|
1520
|
+
|
|
1521
|
+
# ---------------- Apply filters ----------------
|
|
1522
|
+
def _by_enrichment_group(item) -> bool:
|
|
1523
|
+
if not include_enrichment:
|
|
1524
|
+
return True
|
|
1525
|
+
g = ((item.get("door") or {}).get("group") or {})
|
|
1526
|
+
if group_id_filter is not None and g.get("id") != group_id_filter:
|
|
1527
|
+
return False
|
|
1528
|
+
if group_name_filter and group_name_filter not in (g.get("name") or "").lower():
|
|
1529
|
+
return False
|
|
1530
|
+
if name_contains_filter and name_contains_filter not in (item.get("door") or {}).get("name", "").lower():
|
|
1531
|
+
return False
|
|
1532
|
+
return True
|
|
1533
|
+
|
|
1534
|
+
filtered = []
|
|
1535
|
+
for it in normalized:
|
|
1536
|
+
if door_ids and it["door_id"] not in door_ids:
|
|
1537
|
+
continue
|
|
1538
|
+
if is_open_filter is not None and bool(it["is_open"]) != bool(is_open_filter):
|
|
1539
|
+
continue
|
|
1540
|
+
if is_unlocked_filter is not None and bool(it["is_unlocked"]) != bool(is_unlocked_filter):
|
|
1541
|
+
continue
|
|
1542
|
+
if has_alarm_filter is not None and bool(it["has_alarm"]) != bool(has_alarm_filter):
|
|
1543
|
+
continue
|
|
1544
|
+
if status_in and (str(it.get("status_code")) not in status_in):
|
|
1545
|
+
continue
|
|
1546
|
+
if not _by_enrichment_group(it):
|
|
1547
|
+
continue
|
|
1548
|
+
filtered.append(it)
|
|
1549
|
+
|
|
1550
|
+
# ---------------- Sort ----------------
|
|
1551
|
+
def _key(it):
|
|
1552
|
+
if sort_by == "name" and include_enrichment:
|
|
1553
|
+
return (it.get("door") or {}).get("name") or ""
|
|
1554
|
+
if sort_by == "group_name" and include_enrichment:
|
|
1555
|
+
return ((it.get("door") or {}).get("group") or {}).get("name") or ""
|
|
1556
|
+
return it.get("door_id") or 0
|
|
1557
|
+
|
|
1558
|
+
filtered.sort(key=_key, reverse=sort_desc)
|
|
1559
|
+
|
|
1560
|
+
# ---------------- Summary ----------------
|
|
1561
|
+
total = len(filtered)
|
|
1562
|
+
opened_count = sum(1 for x in filtered if x["is_open"])
|
|
1563
|
+
unlocked_count = sum(1 for x in filtered if x["is_unlocked"])
|
|
1564
|
+
alarm_count = sum(1 for x in filtered if x["has_alarm"])
|
|
1565
|
+
|
|
1566
|
+
# breakdowns
|
|
1567
|
+
from collections import Counter
|
|
1568
|
+
label_breakdown = Counter(x["derived_label"] for x in filtered)
|
|
1569
|
+
status_code_breakdown = Counter(str(x.get("status_code")) for x in filtered)
|
|
1570
|
+
|
|
1571
|
+
groups_breakdown = None
|
|
1572
|
+
if include_enrichment:
|
|
1573
|
+
groups_breakdown = Counter((((x.get("door") or {}).get("group") or {}).get("name") or "Unknown") for x in filtered)
|
|
1574
|
+
|
|
1575
|
+
result = {
|
|
1576
|
+
"message": f"Fetched status for {total} doors",
|
|
1577
|
+
"total": total,
|
|
1578
|
+
"summary": {
|
|
1579
|
+
"opened": opened_count,
|
|
1580
|
+
"unlocked": unlocked_count,
|
|
1581
|
+
"alarms": alarm_count,
|
|
1582
|
+
"derived_label_breakdown": dict(label_breakdown),
|
|
1583
|
+
"status_code_breakdown": dict(status_code_breakdown),
|
|
1584
|
+
"groups_breakdown": dict(groups_breakdown) if groups_breakdown is not None else None
|
|
1585
|
+
},
|
|
1586
|
+
"doors_status": filtered,
|
|
1587
|
+
"api_calls": api_calls_meta
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if include_device_response and device_resp_info is not None:
|
|
1591
|
+
result["device_responses"] = device_resp_info
|
|
1592
|
+
|
|
1593
|
+
return self.success_response(result)
|
|
1594
|
+
|
|
1595
|
+
except Exception as e:
|
|
1596
|
+
return await self.handle_api_error(e)
|
|
1597
|
+
|
|
1598
|
+
async def get_door_groups(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1599
|
+
"""
|
|
1600
|
+
Return all door groups (non-breaking) with optional enrichment.
|
|
1601
|
+
- Keep top-level shape: { message, total, groups }
|
|
1602
|
+
- Keep legacy per-group fields: { id, name, description, door_count }
|
|
1603
|
+
- Add optional fields when available: depth, parent, children, child_count, path, door_ids
|
|
1604
|
+
- No change to _get_door_groups(); this method reuses it as the single source of truth.
|
|
1605
|
+
"""
|
|
1606
|
+
try:
|
|
1607
|
+
self.check_auth()
|
|
1608
|
+
|
|
1609
|
+
headers = {
|
|
1610
|
+
"bs-session-id": self.get_session_id(),
|
|
1611
|
+
"Content-Type": "application/json"
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
# -------- helpers (local only) --------
|
|
1615
|
+
def _to_int(x, default=None):
|
|
1616
|
+
try:
|
|
1617
|
+
return int(str(x))
|
|
1618
|
+
except Exception:
|
|
1619
|
+
return default
|
|
1620
|
+
|
|
1621
|
+
def _sid(x):
|
|
1622
|
+
"""Normalize id to string key for internal mapping; do NOT alter output type."""
|
|
1623
|
+
if x is None:
|
|
1624
|
+
return None
|
|
1625
|
+
try:
|
|
1626
|
+
return str(int(str(x)))
|
|
1627
|
+
except Exception:
|
|
1628
|
+
return str(x)
|
|
1629
|
+
|
|
1630
|
+
# 1) Base list from v1 (kept as single source shared by other methods)
|
|
1631
|
+
v1_rows = await self._get_door_groups(headers)
|
|
1632
|
+
by_id = { _sid(g.get("id")): g for g in v1_rows if _sid(g.get("id")) }
|
|
1633
|
+
|
|
1634
|
+
# Prebuild base nodes, preserving the legacy fields
|
|
1635
|
+
nodes: Dict[str, Dict[str, Any]] = {}
|
|
1636
|
+
needs_doors_scan = False
|
|
1637
|
+
for gid, g in by_id.items():
|
|
1638
|
+
v1_door_ids = g.get("door_id_list") or []
|
|
1639
|
+
if not isinstance(v1_door_ids, list) or not v1_door_ids:
|
|
1640
|
+
needs_doors_scan = True
|
|
1641
|
+
# NOTE: Do not cast id/name/description types to avoid breaking callers
|
|
1642
|
+
nodes[gid] = {
|
|
1643
|
+
"id": g.get("id"),
|
|
1644
|
+
"name": g.get("name"),
|
|
1645
|
+
"description": g.get("description"),
|
|
1646
|
+
"door_count": len(v1_door_ids) if isinstance(v1_door_ids, list) else 0,
|
|
1647
|
+
# Optional fields populated later (non-breaking additions)
|
|
1648
|
+
"door_ids": v1_door_ids if isinstance(v1_door_ids, list) and v1_door_ids else None,
|
|
1649
|
+
"depth": None,
|
|
1650
|
+
"parent": None,
|
|
1651
|
+
"children": [],
|
|
1652
|
+
"child_count": 0,
|
|
1653
|
+
"path": None,
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
# 2) Try to enrich with hierarchy (v2 search) inline (no new helper)
|
|
1657
|
+
id2parent: Dict[str, Optional[str]] = {}
|
|
1658
|
+
id2depth: Dict[str, Optional[int]] = {}
|
|
1659
|
+
order_ids: List[str] = []
|
|
1660
|
+
try:
|
|
1661
|
+
async with httpx.AsyncClient(
|
|
1662
|
+
verify=False,
|
|
1663
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
1664
|
+
) as client:
|
|
1665
|
+
v2_resp = await client.post(
|
|
1666
|
+
f"{self.session.config.biostar_url}/api/v2/door_groups/only_permission_item/search",
|
|
1667
|
+
headers=headers,
|
|
1668
|
+
json={"order_by": "depth:false"} # root first
|
|
1669
|
+
)
|
|
1670
|
+
if v2_resp.status_code == 200:
|
|
1671
|
+
v2_json = v2_resp.json() or {}
|
|
1672
|
+
v2_rows = ((v2_json.get("DoorGroupCollection") or {}).get("rows") or []) or []
|
|
1673
|
+
for r in v2_rows:
|
|
1674
|
+
gid = _sid(r.get("id"))
|
|
1675
|
+
if not gid or gid not in nodes:
|
|
1676
|
+
continue
|
|
1677
|
+
order_ids.append(gid)
|
|
1678
|
+
id2depth[gid] = _to_int(r.get("depth"))
|
|
1679
|
+
p = r.get("parent_id") or {}
|
|
1680
|
+
id2parent[gid] = _sid(p.get("id")) if isinstance(p, dict) else None
|
|
1681
|
+
except Exception as ex:
|
|
1682
|
+
# Best-effort enrichment; never fail the tool on v2 issues
|
|
1683
|
+
logger.info("get_door_groups: v2 enrichment skipped due to error: %s", ex)
|
|
1684
|
+
|
|
1685
|
+
# 3) If door_ids missing, compute from /api/doors once
|
|
1686
|
+
if needs_doors_scan:
|
|
1687
|
+
try:
|
|
1688
|
+
async with httpx.AsyncClient(
|
|
1689
|
+
verify=False,
|
|
1690
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
1691
|
+
) as client:
|
|
1692
|
+
d_resp = await client.get(
|
|
1693
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
1694
|
+
headers=headers,
|
|
1695
|
+
params={"limit": "0", "order_by": "id:true"}
|
|
1696
|
+
)
|
|
1697
|
+
if d_resp.status_code == 200:
|
|
1698
|
+
d_json = d_resp.json() or {}
|
|
1699
|
+
d_rows = ((d_json.get("DoorCollection") or {}).get("rows") or [])
|
|
1700
|
+
for d in d_rows:
|
|
1701
|
+
gid = _sid((d.get("door_group_id") or {}).get("id"))
|
|
1702
|
+
did = d.get("id")
|
|
1703
|
+
if gid and gid in nodes and did is not None:
|
|
1704
|
+
# initialize list lazily to avoid writing empty lists
|
|
1705
|
+
if nodes[gid].get("door_ids") is None:
|
|
1706
|
+
nodes[gid]["door_ids"] = []
|
|
1707
|
+
nodes[gid]["door_ids"].append(did)
|
|
1708
|
+
# refresh door_count after scan
|
|
1709
|
+
for g in nodes.values():
|
|
1710
|
+
if isinstance(g.get("door_ids"), list):
|
|
1711
|
+
g["door_count"] = len(g["door_ids"])
|
|
1712
|
+
except Exception as ex:
|
|
1713
|
+
logger.info("get_door_groups: doors scan skipped due to error: %s", ex)
|
|
1714
|
+
|
|
1715
|
+
# 4) Link parent/children and compute path if v2 data exists
|
|
1716
|
+
if id2parent:
|
|
1717
|
+
children_index: Dict[str, List[str]] = {}
|
|
1718
|
+
for gid in nodes.keys():
|
|
1719
|
+
pid = id2parent.get(_sid(gid))
|
|
1720
|
+
if pid:
|
|
1721
|
+
children_index.setdefault(pid, []).append(_sid(gid))
|
|
1722
|
+
|
|
1723
|
+
for gid, node in nodes.items():
|
|
1724
|
+
sid = _sid(gid)
|
|
1725
|
+
# depth
|
|
1726
|
+
node["depth"] = id2depth.get(sid)
|
|
1727
|
+
# parent
|
|
1728
|
+
pid = id2parent.get(sid)
|
|
1729
|
+
if pid and pid in nodes:
|
|
1730
|
+
node["parent"] = {"id": nodes[pid]["id"], "name": nodes[pid]["name"]}
|
|
1731
|
+
# children
|
|
1732
|
+
ch = children_index.get(sid, [])
|
|
1733
|
+
node["children"] = [{"id": nodes[c]["id"], "name": nodes[c]["name"]} for c in ch if c in nodes]
|
|
1734
|
+
node["child_count"] = len(node["children"])
|
|
1735
|
+
|
|
1736
|
+
def _path(sid: str) -> str:
|
|
1737
|
+
chain = []
|
|
1738
|
+
cur = sid
|
|
1739
|
+
guard = 0
|
|
1740
|
+
while cur and cur in nodes and guard < 128:
|
|
1741
|
+
chain.append(nodes[cur]["name"])
|
|
1742
|
+
cur = id2parent.get(cur)
|
|
1743
|
+
guard += 1
|
|
1744
|
+
chain.reverse()
|
|
1745
|
+
return " / ".join([s for s in chain if isinstance(s, str) and s.strip()])
|
|
1746
|
+
|
|
1747
|
+
for gid in list(nodes.keys()):
|
|
1748
|
+
sid = _sid(gid)
|
|
1749
|
+
nodes[sid]["path"] = _path(sid)
|
|
1750
|
+
|
|
1751
|
+
# 5) Decide output order: prefer v2 order (depth:false), else insertion order
|
|
1752
|
+
if order_ids:
|
|
1753
|
+
ordered_ids = [gid for gid in order_ids if gid in nodes]
|
|
1754
|
+
else:
|
|
1755
|
+
ordered_ids = list(nodes.keys())
|
|
1756
|
+
|
|
1757
|
+
groups_out = [nodes[gid] for gid in ordered_ids]
|
|
1758
|
+
|
|
1759
|
+
return self.success_response({
|
|
1760
|
+
"message": f"Found {len(groups_out)} door groups",
|
|
1761
|
+
"total": len(groups_out),
|
|
1762
|
+
"groups": groups_out
|
|
1763
|
+
})
|
|
1764
|
+
|
|
1765
|
+
except Exception as e:
|
|
1766
|
+
return await self.handle_api_error(e)
|
|
1767
|
+
|
|
1768
|
+
|
|
1769
|
+
async def get_door_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
1770
|
+
"""
|
|
1771
|
+
Get one door group by id OR name (non-breaking):
|
|
1772
|
+
- Backwards compatible: if group_id is given, behave like before.
|
|
1773
|
+
- If group_name is given, resolve to id using robust name matching:
|
|
1774
|
+
exact -> iexact (NFKC+lower) -> icontains (NFKC+lower).
|
|
1775
|
+
- Optional enrichment:
|
|
1776
|
+
* include_hierarchy=True: depth/parent/path/children
|
|
1777
|
+
* include_doors=True: door_ids
|
|
1778
|
+
* include_doors_detail=True: full door objects (joins via /api/doors)
|
|
1779
|
+
"""
|
|
1780
|
+
try:
|
|
1781
|
+
self.check_auth()
|
|
1782
|
+
|
|
1783
|
+
headers = {
|
|
1784
|
+
"bs-session-id": self.get_session_id(),
|
|
1785
|
+
"Content-Type": "application/json"
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
# ---------- args ----------
|
|
1789
|
+
group_id_arg = args.get("group_id", None)
|
|
1790
|
+
group_name_arg = args.get("group_name", None)
|
|
1791
|
+
name_match_mode = str(args.get("name_match_mode", "auto")).lower()
|
|
1792
|
+
include_hierarchy = bool(args.get("include_hierarchy", True))
|
|
1793
|
+
include_doors = bool(args.get("include_doors", True))
|
|
1794
|
+
include_doors_detail = bool(args.get("include_doors_detail", False))
|
|
1795
|
+
include_raw = bool(args.get("include_raw", False))
|
|
1796
|
+
|
|
1797
|
+
# ---------- helpers ----------
|
|
1798
|
+
def _to_int(x, default=None):
|
|
1799
|
+
try:
|
|
1800
|
+
return int(str(x))
|
|
1801
|
+
except Exception:
|
|
1802
|
+
return default
|
|
1803
|
+
|
|
1804
|
+
def _sid(x):
|
|
1805
|
+
if x is None:
|
|
1806
|
+
return None
|
|
1807
|
+
try:
|
|
1808
|
+
return str(int(str(x)))
|
|
1809
|
+
except Exception:
|
|
1810
|
+
return str(x)
|
|
1811
|
+
|
|
1812
|
+
def _norm(s: str) -> str:
|
|
1813
|
+
if not isinstance(s, str):
|
|
1814
|
+
return ""
|
|
1815
|
+
return unicodedata.normalize("NFKC", s).strip()
|
|
1816
|
+
|
|
1817
|
+
async def _http_with_retry(method: str, url: str, **kwargs) -> httpx.Response:
|
|
1818
|
+
last_exc = None
|
|
1819
|
+
for attempt in range(3):
|
|
1820
|
+
try:
|
|
1821
|
+
async with httpx.AsyncClient(
|
|
1822
|
+
verify=False,
|
|
1823
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
1824
|
+
) as client:
|
|
1825
|
+
resp = await client.request(method, url, **kwargs)
|
|
1826
|
+
if resp.status_code >= 500:
|
|
1827
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
1828
|
+
continue
|
|
1829
|
+
return resp
|
|
1830
|
+
except Exception as e:
|
|
1831
|
+
last_exc = e
|
|
1832
|
+
await asyncio.sleep(0.6 * (attempt + 1))
|
|
1833
|
+
raise RuntimeError(f"HTTP temporary failure: {repr(last_exc)}")
|
|
1834
|
+
|
|
1835
|
+
# ---------- resolve group_id (by id or by name) ----------
|
|
1836
|
+
resolved_group_id: Optional[int] = None
|
|
1837
|
+
|
|
1838
|
+
if group_id_arg is not None:
|
|
1839
|
+
resolved_group_id = _to_int(group_id_arg)
|
|
1840
|
+
if resolved_group_id is None:
|
|
1841
|
+
return self.error_response("Invalid 'group_id'.")
|
|
1842
|
+
elif group_name_arg:
|
|
1843
|
+
# list all groups once
|
|
1844
|
+
groups = await self._get_door_groups(headers)
|
|
1845
|
+
if not groups:
|
|
1846
|
+
return self.error_response("No door groups available to resolve by name.")
|
|
1847
|
+
|
|
1848
|
+
# build normalized buckets
|
|
1849
|
+
q_raw = str(group_name_arg)
|
|
1850
|
+
q = _norm(q_raw)
|
|
1851
|
+
groups_aug = []
|
|
1852
|
+
for g in groups:
|
|
1853
|
+
nm = g.get("name")
|
|
1854
|
+
groups_aug.append({
|
|
1855
|
+
**g,
|
|
1856
|
+
"__name": nm,
|
|
1857
|
+
"__norm": _norm(nm if isinstance(nm, str) else "")
|
|
1858
|
+
})
|
|
1859
|
+
|
|
1860
|
+
exact = [g for g in groups_aug if g["__name"] == q_raw]
|
|
1861
|
+
iexact = [g for g in groups_aug if g["__norm"].lower() == q.lower()]
|
|
1862
|
+
icontains = [g for g in groups_aug if q.lower() in g["__norm"].lower()]
|
|
1863
|
+
|
|
1864
|
+
if name_match_mode == "exact":
|
|
1865
|
+
chosen = exact
|
|
1866
|
+
elif name_match_mode == "iexact":
|
|
1867
|
+
chosen = iexact
|
|
1868
|
+
elif name_match_mode == "contains":
|
|
1869
|
+
chosen = [g for g in groups_aug if q_raw in (g["__name"] or "")]
|
|
1870
|
+
elif name_match_mode == "icontains":
|
|
1871
|
+
chosen = icontains
|
|
1872
|
+
else:
|
|
1873
|
+
# auto ladder: prefer single unique match
|
|
1874
|
+
chosen = None
|
|
1875
|
+
for bucket in (exact, iexact, icontains):
|
|
1876
|
+
if len(bucket) == 1:
|
|
1877
|
+
chosen = bucket
|
|
1878
|
+
break
|
|
1879
|
+
if chosen is None:
|
|
1880
|
+
chosen = exact or iexact or icontains
|
|
1881
|
+
|
|
1882
|
+
if not chosen:
|
|
1883
|
+
# nothing matched → provide choices
|
|
1884
|
+
choices = [{"id": g.get("id"), "name": g.get("name")} for g in groups if g.get("id") is not None]
|
|
1885
|
+
return self.success_response({
|
|
1886
|
+
"status": "need_user_input",
|
|
1887
|
+
"reason": "no_match",
|
|
1888
|
+
"message": "No matching group found. Choose one and call again with 'group_id'.",
|
|
1889
|
+
"choices": choices,
|
|
1890
|
+
"next_step": "Provide 'group_id'."
|
|
1891
|
+
})
|
|
1892
|
+
|
|
1893
|
+
if len(chosen) > 1:
|
|
1894
|
+
# try to enrich choices with simple tree path
|
|
1895
|
+
choice_out = [{"id": g.get("id"), "name": g.get("name")} for g in chosen if g.get("id") is not None]
|
|
1896
|
+
if include_hierarchy:
|
|
1897
|
+
try:
|
|
1898
|
+
v2 = await _http_with_retry(
|
|
1899
|
+
"POST",
|
|
1900
|
+
f"{self.session.config.biostar_url}/api/v2/door_groups/only_permission_item/search",
|
|
1901
|
+
headers=headers,
|
|
1902
|
+
json={"order_by": "depth:false"}
|
|
1903
|
+
)
|
|
1904
|
+
if v2.status_code == 200:
|
|
1905
|
+
tree = (v2.json() or {}).get("DoorGroupCollection", {}).get("rows", []) or []
|
|
1906
|
+
by_id = { _sid(r.get("id")): r for r in tree }
|
|
1907
|
+
def _path(gid):
|
|
1908
|
+
path = []
|
|
1909
|
+
cur = by_id.get(_sid(gid))
|
|
1910
|
+
guard = 0
|
|
1911
|
+
while cur and guard < 64:
|
|
1912
|
+
path.append(cur.get("name"))
|
|
1913
|
+
pid = _sid((cur.get("parent_id") or {}).get("id"))
|
|
1914
|
+
cur = by_id.get(pid)
|
|
1915
|
+
guard += 1
|
|
1916
|
+
return " / ".join(reversed([p for p in path if isinstance(p, str)]))
|
|
1917
|
+
choice_out = [{"id": g["id"], "name": g["name"], "path": _path(g["id"])} for g in choice_out]
|
|
1918
|
+
except Exception:
|
|
1919
|
+
pass
|
|
1920
|
+
|
|
1921
|
+
return self.success_response({
|
|
1922
|
+
"status": "need_user_input",
|
|
1923
|
+
"reason": "multiple_matches",
|
|
1924
|
+
"message": "Multiple groups matched. Choose one and call again with 'group_id'.",
|
|
1925
|
+
"choices": choice_out,
|
|
1926
|
+
"next_step": "Provide 'group_id'."
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1929
|
+
resolved_group_id = _to_int(chosen[0].get("id"))
|
|
1930
|
+
|
|
1931
|
+
else:
|
|
1932
|
+
return self.error_response("Either 'group_id' or 'group_name' is required.")
|
|
1933
|
+
|
|
1934
|
+
# ---------- fetch base detail by id ----------
|
|
1935
|
+
resp = await _http_with_retry(
|
|
1936
|
+
"GET",
|
|
1937
|
+
f"{self.session.config.biostar_url}/api/door_groups/{resolved_group_id}",
|
|
1938
|
+
headers=headers
|
|
1939
|
+
)
|
|
1940
|
+
if resp.status_code != 200:
|
|
1941
|
+
return self.error_response(f"API call failed: {resp.status_code} - {resp.text}")
|
|
1942
|
+
|
|
1943
|
+
body = resp.json() or {}
|
|
1944
|
+
g = (body.get("DoorGroup") or {})
|
|
1945
|
+
|
|
1946
|
+
out = {
|
|
1947
|
+
"id": g.get("id"),
|
|
1948
|
+
"name": g.get("name"),
|
|
1949
|
+
"description": g.get("description"),
|
|
1950
|
+
"door_ids": g.get("door_id_list", []) or None # some deployments omit this
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
# ---------- enrichment: hierarchy ----------
|
|
1954
|
+
if include_hierarchy:
|
|
1955
|
+
try:
|
|
1956
|
+
v2 = await _http_with_retry(
|
|
1957
|
+
"POST",
|
|
1958
|
+
f"{self.session.config.biostar_url}/api/v2/door_groups/only_permission_item/search",
|
|
1959
|
+
headers=headers,
|
|
1960
|
+
json={"order_by": "depth:false"}
|
|
1961
|
+
)
|
|
1962
|
+
if v2.status_code == 200:
|
|
1963
|
+
rows = (v2.json() or {}).get("DoorGroupCollection", {}).get("rows", []) or []
|
|
1964
|
+
by_id = { _sid(r.get("id")): r for r in rows }
|
|
1965
|
+
sid = _sid(out["id"])
|
|
1966
|
+
cur = by_id.get(sid)
|
|
1967
|
+
# depth
|
|
1968
|
+
try:
|
|
1969
|
+
out["depth"] = _to_int((cur or {}).get("depth"))
|
|
1970
|
+
except Exception:
|
|
1971
|
+
out["depth"] = None
|
|
1972
|
+
# parent
|
|
1973
|
+
pid = _sid(((cur or {}).get("parent_id") or {}).get("id"))
|
|
1974
|
+
if pid and pid in by_id:
|
|
1975
|
+
out["parent"] = {"id": by_id[pid].get("id"), "name": by_id[pid].get("name")}
|
|
1976
|
+
# children
|
|
1977
|
+
children = [r for r in rows if _sid(((r.get("parent_id") or {}).get("id"))) == sid]
|
|
1978
|
+
out["children"] = [{"id": c.get("id"), "name": c.get("name")} for c in children]
|
|
1979
|
+
out["child_count"] = len(out["children"])
|
|
1980
|
+
# path
|
|
1981
|
+
def _path(gid):
|
|
1982
|
+
chain = []
|
|
1983
|
+
cur2 = by_id.get(_sid(gid))
|
|
1984
|
+
guard = 0
|
|
1985
|
+
while cur2 and guard < 64:
|
|
1986
|
+
chain.append(cur2.get("name"))
|
|
1987
|
+
pid2 = _sid(((cur2.get("parent_id") or {}).get("id")))
|
|
1988
|
+
cur2 = by_id.get(pid2)
|
|
1989
|
+
guard += 1
|
|
1990
|
+
return " / ".join(reversed([p for p in chain if isinstance(p, str) and p.strip()]))
|
|
1991
|
+
out["path"] = _path(out["id"])
|
|
1992
|
+
except Exception as ex:
|
|
1993
|
+
logger.info("get_door_group: hierarchy enrichment skipped: %s", ex)
|
|
1994
|
+
|
|
1995
|
+
# ---------- enrichment: doors (ids + optional details) ----------
|
|
1996
|
+
if include_doors and (not isinstance(out.get("door_ids"), list) or not out.get("door_ids")):
|
|
1997
|
+
# Build door_ids by scanning all doors (one shot)
|
|
1998
|
+
try:
|
|
1999
|
+
d_resp = await _http_with_retry(
|
|
2000
|
+
"GET",
|
|
2001
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
2002
|
+
headers=headers,
|
|
2003
|
+
params={"limit": "0", "order_by": "id:true"}
|
|
2004
|
+
)
|
|
2005
|
+
if d_resp.status_code == 200:
|
|
2006
|
+
d_json = d_resp.json() or {}
|
|
2007
|
+
d_rows = ((d_json.get("DoorCollection") or {}).get("rows") or [])
|
|
2008
|
+
gids = _sid(out["id"])
|
|
2009
|
+
door_ids = []
|
|
2010
|
+
for d in d_rows:
|
|
2011
|
+
if _sid((d.get("door_group_id") or {}).get("id")) == gids:
|
|
2012
|
+
door_ids.append(d.get("id"))
|
|
2013
|
+
out["door_ids"] = door_ids or None
|
|
2014
|
+
except Exception as ex:
|
|
2015
|
+
logger.info("get_door_group: door scan skipped: %s", ex)
|
|
2016
|
+
|
|
2017
|
+
if include_doors_detail and isinstance(out.get("door_ids"), list) and out["door_ids"]:
|
|
2018
|
+
# Join brief details from /api/doors (single GET, no N calls)
|
|
2019
|
+
try:
|
|
2020
|
+
d_resp = await _http_with_retry(
|
|
2021
|
+
"GET",
|
|
2022
|
+
f"{self.session.config.biostar_url}/api/doors",
|
|
2023
|
+
headers=headers,
|
|
2024
|
+
params={"limit": "0", "order_by": "id:true"}
|
|
2025
|
+
)
|
|
2026
|
+
if d_resp.status_code == 200:
|
|
2027
|
+
d_json = d_resp.json() or {}
|
|
2028
|
+
d_rows = ((d_json.get("DoorCollection") or {}).get("rows") or [])
|
|
2029
|
+
idset = { _to_int(x) for x in out["door_ids"] if _to_int(x) is not None }
|
|
2030
|
+
joined = []
|
|
2031
|
+
for d in d_rows:
|
|
2032
|
+
did = _to_int(d.get("id"))
|
|
2033
|
+
if did in idset:
|
|
2034
|
+
gmeta = d.get("door_group_id") or {}
|
|
2035
|
+
joined.append({
|
|
2036
|
+
"id": did,
|
|
2037
|
+
"name": d.get("name"),
|
|
2038
|
+
"description": d.get("description"),
|
|
2039
|
+
"group": {"id": _to_int(gmeta.get("id")), "name": gmeta.get("name")}
|
|
2040
|
+
})
|
|
2041
|
+
if joined:
|
|
2042
|
+
out["doors"] = joined
|
|
2043
|
+
except Exception as ex:
|
|
2044
|
+
logger.info("get_door_group: doors detail join skipped: %s", ex)
|
|
2045
|
+
|
|
2046
|
+
if include_raw:
|
|
2047
|
+
out["raw"] = g
|
|
2048
|
+
|
|
2049
|
+
# keep top-level shape backward-compatible
|
|
2050
|
+
return self.success_response({"group": out})
|
|
2051
|
+
|
|
2052
|
+
except Exception as e:
|
|
2053
|
+
return await self.handle_api_error(e)
|
|
2054
|
+
|
|
2055
|
+
async def create_door_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2056
|
+
"""Create a new door group."""
|
|
2057
|
+
try:
|
|
2058
|
+
self.check_auth()
|
|
2059
|
+
|
|
2060
|
+
headers = {
|
|
2061
|
+
"bs-session-id": self.get_session_id(),
|
|
2062
|
+
"Content-Type": "application/json"
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
# Build payload matching BioStar API format
|
|
2066
|
+
group_name = args["name"]
|
|
2067
|
+
parent_id = args.get("parent_id", 1) # Default to root group (ID: 1)
|
|
2068
|
+
depth = args.get("depth", 1) # Default depth: 1
|
|
2069
|
+
|
|
2070
|
+
# 1) Check if group already exists
|
|
2071
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
2072
|
+
existing_resp = await client.get(
|
|
2073
|
+
f"{self.session.config.biostar_url}/api/door_groups",
|
|
2074
|
+
headers=headers
|
|
2075
|
+
)
|
|
2076
|
+
|
|
2077
|
+
if existing_resp.status_code == 200:
|
|
2078
|
+
existing_data = existing_resp.json()
|
|
2079
|
+
rows = existing_data.get("DoorGroupCollection", {}).get("rows", [])
|
|
2080
|
+
for row in rows:
|
|
2081
|
+
existing_name = row.get("name", "")
|
|
2082
|
+
if existing_name.strip().lower() == group_name.strip().lower():
|
|
2083
|
+
existing_id = row.get("id")
|
|
2084
|
+
logger.info(f"Door group '{group_name}' already exists (ID: {existing_id})")
|
|
2085
|
+
return self.success_response({
|
|
2086
|
+
"message": f"Door group '{group_name}' already exists. Skipping creation.",
|
|
2087
|
+
"group_id": existing_id,
|
|
2088
|
+
"exists": True
|
|
2089
|
+
})
|
|
2090
|
+
|
|
2091
|
+
group_data = {
|
|
2092
|
+
"DoorGroup": {
|
|
2093
|
+
"parent_id": {"id": str(parent_id)},
|
|
2094
|
+
"isDoorGroups": True,
|
|
2095
|
+
"depth": depth,
|
|
2096
|
+
"sync_device_groups": [],
|
|
2097
|
+
"sync_devices": [],
|
|
2098
|
+
"inherited": True,
|
|
2099
|
+
"iconCls": "doorGroupIcon",
|
|
2100
|
+
"text": group_name,
|
|
2101
|
+
"name": group_name
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
logger.info(f"Creating door group with payload: {json.dumps(group_data, indent=2)}")
|
|
2106
|
+
|
|
2107
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
2108
|
+
response = await client.post(
|
|
2109
|
+
f"{self.session.config.biostar_url}/api/door_groups",
|
|
2110
|
+
headers=headers,
|
|
2111
|
+
json=group_data
|
|
2112
|
+
)
|
|
2113
|
+
|
|
2114
|
+
if response.status_code not in [200, 201]:
|
|
2115
|
+
logger.error(f"Door group creation failed: {response.status_code} - {response.text}")
|
|
2116
|
+
return self.error_response(f"API call failed: {response.status_code} - {response.text}")
|
|
2117
|
+
|
|
2118
|
+
result = response.json()
|
|
2119
|
+
group_id = result.get("DoorGroup", {}).get("id") or result.get("id")
|
|
2120
|
+
|
|
2121
|
+
return self.success_response({
|
|
2122
|
+
"message": f"Door group '{group_name}' created successfully",
|
|
2123
|
+
"group_id": group_id,
|
|
2124
|
+
"parent_id": parent_id,
|
|
2125
|
+
"depth": depth
|
|
2126
|
+
})
|
|
2127
|
+
|
|
2128
|
+
except Exception as e:
|
|
2129
|
+
return await self.handle_api_error(e)
|
|
2130
|
+
|
|
2131
|
+
async def update_door_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2132
|
+
"""Update an existing door group."""
|
|
2133
|
+
try:
|
|
2134
|
+
self.check_auth()
|
|
2135
|
+
|
|
2136
|
+
group_id = args["group_id"]
|
|
2137
|
+
|
|
2138
|
+
headers = {
|
|
2139
|
+
"bs-session-id": self.get_session_id(),
|
|
2140
|
+
"Content-Type": "application/json"
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
# Build update data with only name and parent_id
|
|
2144
|
+
group_data = {
|
|
2145
|
+
"DoorGroup": {}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
# Add name if provided
|
|
2149
|
+
if "name" in args:
|
|
2150
|
+
group_data["DoorGroup"]["name"] = args["name"]
|
|
2151
|
+
|
|
2152
|
+
# Add parent_id if provided
|
|
2153
|
+
if "parent_id" in args:
|
|
2154
|
+
group_data["DoorGroup"]["parent_id"] = {
|
|
2155
|
+
"id": args["parent_id"]
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
# Make sure we have something to update
|
|
2159
|
+
if not group_data["DoorGroup"]:
|
|
2160
|
+
return self.error_response("No fields to update. Provide 'name' or 'parent_id'.")
|
|
2161
|
+
|
|
2162
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
2163
|
+
# Update the group
|
|
2164
|
+
update_response = await client.put(
|
|
2165
|
+
f"{self.session.config.biostar_url}/api/door_groups/{group_id}",
|
|
2166
|
+
headers=headers,
|
|
2167
|
+
json=group_data
|
|
2168
|
+
)
|
|
2169
|
+
|
|
2170
|
+
if update_response.status_code != 200:
|
|
2171
|
+
return self.error_response(f"API call failed: {update_response.status_code} - {update_response.text}")
|
|
2172
|
+
|
|
2173
|
+
return self.success_response({
|
|
2174
|
+
"message": f"Door group {group_id} updated successfully"
|
|
2175
|
+
})
|
|
2176
|
+
|
|
2177
|
+
except Exception as e:
|
|
2178
|
+
return await self.handle_api_error(e)
|
|
2179
|
+
|
|
2180
|
+
async def delete_door_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2181
|
+
"""Delete a door group."""
|
|
2182
|
+
try:
|
|
2183
|
+
self.check_auth()
|
|
2184
|
+
|
|
2185
|
+
group_id = args["group_id"]
|
|
2186
|
+
|
|
2187
|
+
headers = {
|
|
2188
|
+
"bs-session-id": self.get_session_id(),
|
|
2189
|
+
"Content-Type": "application/json"
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
2193
|
+
response = await client.delete(
|
|
2194
|
+
f"{self.session.config.biostar_url}/api/door_groups/{group_id}",
|
|
2195
|
+
headers=headers
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
if response.status_code not in [200, 204]:
|
|
2199
|
+
return self.error_response(f"API call failed: {response.status_code} - {response.text}")
|
|
2200
|
+
|
|
2201
|
+
return self.success_response({
|
|
2202
|
+
"message": f"Door group {group_id} deleted successfully"
|
|
2203
|
+
})
|
|
2204
|
+
|
|
2205
|
+
except Exception as e:
|
|
2206
|
+
return await self.handle_api_error(e)
|
|
2207
|
+
|
|
2208
|
+
async def add_doors_to_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2209
|
+
"""
|
|
2210
|
+
Assign one or more doors to the specified (single) group.
|
|
2211
|
+
|
|
2212
|
+
Rules (single-membership):
|
|
2213
|
+
- If door already in the target group -> unchanged
|
|
2214
|
+
- If door in a different group:
|
|
2215
|
+
* force=false -> conflict
|
|
2216
|
+
* force=true -> overwrite to target group (PUT {"Door": {"door_group_id": {"id": <target>}}})
|
|
2217
|
+
"""
|
|
2218
|
+
try:
|
|
2219
|
+
self.check_auth()
|
|
2220
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
2221
|
+
|
|
2222
|
+
# Resolve group
|
|
2223
|
+
gid, gerr = await self._resolve_group_id(
|
|
2224
|
+
group_id=args.get("group_id"),
|
|
2225
|
+
group_name=args.get("group_name"),
|
|
2226
|
+
headers=headers
|
|
2227
|
+
)
|
|
2228
|
+
if gerr:
|
|
2229
|
+
groups = await self._get_door_groups(headers)
|
|
2230
|
+
return self.error_response(
|
|
2231
|
+
gerr,
|
|
2232
|
+
{"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups]}
|
|
2233
|
+
)
|
|
2234
|
+
target_gid = int(gid)
|
|
2235
|
+
|
|
2236
|
+
# Resolve doors
|
|
2237
|
+
doors, derr = await self._resolve_doors(
|
|
2238
|
+
door_ids=args.get("door_ids"),
|
|
2239
|
+
door_names=args.get("door_names"),
|
|
2240
|
+
headers=headers
|
|
2241
|
+
)
|
|
2242
|
+
if derr:
|
|
2243
|
+
return self.error_response(derr)
|
|
2244
|
+
|
|
2245
|
+
force: bool = bool(args.get("force", False))
|
|
2246
|
+
|
|
2247
|
+
updated: List[Dict[str, Any]] = []
|
|
2248
|
+
unchanged: List[Dict[str, Any]] = []
|
|
2249
|
+
conflicts: List[Dict[str, Any]] = []
|
|
2250
|
+
failures: List[Dict[str, Any]] = []
|
|
2251
|
+
|
|
2252
|
+
for d in doors:
|
|
2253
|
+
did = int(d["id"])
|
|
2254
|
+
ddetail = await self._get_door_detail(did, headers)
|
|
2255
|
+
if not ddetail:
|
|
2256
|
+
failures.append({"door_id": did, "name": d.get("name"), "reason": "door not found"})
|
|
2257
|
+
continue
|
|
2258
|
+
|
|
2259
|
+
# single-membership only
|
|
2260
|
+
dg = ddetail.get("door_group_id") or {}
|
|
2261
|
+
current_gid = None
|
|
2262
|
+
if isinstance(dg, dict) and dg.get("id") is not None:
|
|
2263
|
+
try:
|
|
2264
|
+
current_gid = int(dg.get("id"))
|
|
2265
|
+
except Exception:
|
|
2266
|
+
current_gid = None
|
|
2267
|
+
|
|
2268
|
+
if current_gid == target_gid:
|
|
2269
|
+
unchanged.append({"door_id": did, "name": d.get("name"), "reason": "already in group"})
|
|
2270
|
+
continue
|
|
2271
|
+
|
|
2272
|
+
if current_gid is not None and current_gid != target_gid and not force:
|
|
2273
|
+
conflicts.append({
|
|
2274
|
+
"door_id": did, "name": d.get("name"),
|
|
2275
|
+
"current_group_id": current_gid, "target_group_id": target_gid
|
|
2276
|
+
})
|
|
2277
|
+
continue
|
|
2278
|
+
|
|
2279
|
+
# Overwrite/Assign to target (minimal payload)
|
|
2280
|
+
payloads = [
|
|
2281
|
+
{"Door": {"door_group_id": {"id": target_gid}}}
|
|
2282
|
+
]
|
|
2283
|
+
ok, st, txt = await self._put_door_group_with_fallbacks(door_id=did, payloads=payloads, headers=headers)
|
|
2284
|
+
if ok:
|
|
2285
|
+
updated.append({"door_id": did, "name": d.get("name"), "assigned_group_id": target_gid})
|
|
2286
|
+
else:
|
|
2287
|
+
failures.append({"door_id": did, "name": d.get("name"), "status": st, "error": txt})
|
|
2288
|
+
|
|
2289
|
+
return self.success_response({
|
|
2290
|
+
"message": f"Processed {len(doors)} door(s) for assignment to group {target_gid}.",
|
|
2291
|
+
"summary": {
|
|
2292
|
+
"updated": len(updated),
|
|
2293
|
+
"unchanged": len(unchanged),
|
|
2294
|
+
"conflicts": len(conflicts),
|
|
2295
|
+
"failed": len(failures)
|
|
2296
|
+
},
|
|
2297
|
+
"updated": updated,
|
|
2298
|
+
"unchanged": unchanged,
|
|
2299
|
+
"conflicts": conflicts,
|
|
2300
|
+
"failed": failures
|
|
2301
|
+
})
|
|
2302
|
+
|
|
2303
|
+
except Exception as e:
|
|
2304
|
+
return await self.handle_api_error(e)
|
|
2305
|
+
|
|
2306
|
+
async def remove_doors_from_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2307
|
+
"""
|
|
2308
|
+
Remove one or more doors from the specified group (single-membership system).
|
|
2309
|
+
|
|
2310
|
+
Since clearing to null/empty is not allowed, if the door is currently in that group,
|
|
2311
|
+
it must be reassigned to another group based on fallback_mode:
|
|
2312
|
+
- "auto" (default): pick a root-like/default group (exclude the group being removed)
|
|
2313
|
+
- "ask" : do nothing; return candidates + needs_fallback_choice=true
|
|
2314
|
+
- "assign": reassign to provided fallback_group_id
|
|
2315
|
+
"""
|
|
2316
|
+
try:
|
|
2317
|
+
self.check_auth()
|
|
2318
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
2319
|
+
|
|
2320
|
+
# fallback parameters
|
|
2321
|
+
fallback_mode = str(args.get("fallback_mode", "auto")).lower()
|
|
2322
|
+
explicit_fallback_gid = args.get("fallback_group_id")
|
|
2323
|
+
if fallback_mode == "assign" and explicit_fallback_gid is None:
|
|
2324
|
+
return self.error_response("fallback_mode is 'assign' but fallback_group_id is missing.")
|
|
2325
|
+
|
|
2326
|
+
# Resolve group to remove from
|
|
2327
|
+
gid, gerr = await self._resolve_group_id(
|
|
2328
|
+
group_id=args.get("group_id"),
|
|
2329
|
+
group_name=args.get("group_name"),
|
|
2330
|
+
headers=headers
|
|
2331
|
+
)
|
|
2332
|
+
if gerr:
|
|
2333
|
+
groups = await self._get_door_groups(headers)
|
|
2334
|
+
return self.error_response(
|
|
2335
|
+
gerr,
|
|
2336
|
+
{"available_groups": [{"id": g.get("id"), "name": g.get("name")} for g in groups]}
|
|
2337
|
+
)
|
|
2338
|
+
target_gid = int(gid)
|
|
2339
|
+
|
|
2340
|
+
# Resolve doors
|
|
2341
|
+
doors, derr = await self._resolve_doors(
|
|
2342
|
+
door_ids=args.get("door_ids"),
|
|
2343
|
+
door_names=args.get("door_names"),
|
|
2344
|
+
headers=headers
|
|
2345
|
+
)
|
|
2346
|
+
if derr:
|
|
2347
|
+
return self.error_response(derr)
|
|
2348
|
+
|
|
2349
|
+
updated: List[Dict[str, Any]] = []
|
|
2350
|
+
unchanged: List[Dict[str, Any]] = []
|
|
2351
|
+
failures: List[Dict[str, Any]] = []
|
|
2352
|
+
|
|
2353
|
+
for d in doors:
|
|
2354
|
+
did = int(d["id"])
|
|
2355
|
+
ddetail = await self._get_door_detail(did, headers)
|
|
2356
|
+
if not ddetail:
|
|
2357
|
+
failures.append({"door_id": did, "name": d.get("name"), "reason": "door not found"})
|
|
2358
|
+
continue
|
|
2359
|
+
|
|
2360
|
+
# single-membership only
|
|
2361
|
+
dg = ddetail.get("door_group_id") or {}
|
|
2362
|
+
current_gid = None
|
|
2363
|
+
if isinstance(dg, dict) and dg.get("id") is not None:
|
|
2364
|
+
try:
|
|
2365
|
+
current_gid = int(dg.get("id"))
|
|
2366
|
+
except Exception:
|
|
2367
|
+
current_gid = None
|
|
2368
|
+
|
|
2369
|
+
if current_gid is None:
|
|
2370
|
+
unchanged.append({"door_id": did, "name": d.get("name"), "reason": "no group assigned"})
|
|
2371
|
+
continue
|
|
2372
|
+
if current_gid != target_gid:
|
|
2373
|
+
unchanged.append({"door_id": did, "name": d.get("name"), "reason": f"assigned to different group ({current_gid})"})
|
|
2374
|
+
continue
|
|
2375
|
+
|
|
2376
|
+
# current_gid == target_gid → reassign according to fallback_mode
|
|
2377
|
+
if fallback_mode == "assign":
|
|
2378
|
+
fallback_gid = int(explicit_fallback_gid)
|
|
2379
|
+
if fallback_gid == target_gid:
|
|
2380
|
+
failures.append({"door_id": did, "name": d.get("name"), "reason": "fallback_group_id equals target group"})
|
|
2381
|
+
continue
|
|
2382
|
+
elif fallback_mode == "ask":
|
|
2383
|
+
fc = await self._fallback_candidates(headers=headers, exclude_group_id=target_gid)
|
|
2384
|
+
return self.success_response({
|
|
2385
|
+
"needs_fallback_choice": True,
|
|
2386
|
+
"reason": "Removal would leave the door with no group.",
|
|
2387
|
+
"door": {"id": did, "name": d.get("name")},
|
|
2388
|
+
"group_being_removed": target_gid,
|
|
2389
|
+
"candidates": fc["candidates"],
|
|
2390
|
+
"suggested_fallback_group_id": fc["suggested_fallback_group_id"],
|
|
2391
|
+
"hint": "Call remove-doors-from-group again with fallback_mode='assign' and fallback_group_id=<id>."
|
|
2392
|
+
})
|
|
2393
|
+
else: # auto
|
|
2394
|
+
fallback_gid = await self._choose_fallback_group_id(headers=headers, exclude_group_id=target_gid)
|
|
2395
|
+
if fallback_gid is None:
|
|
2396
|
+
failures.append({"door_id": did, "name": d.get("name"), "reason": "no fallback group available"})
|
|
2397
|
+
continue
|
|
2398
|
+
|
|
2399
|
+
# Reassign to fallback group (minimal payload)
|
|
2400
|
+
payloads = [
|
|
2401
|
+
{"Door": {"door_group_id": {"id": int(fallback_gid)}}}
|
|
2402
|
+
]
|
|
2403
|
+
ok, st, txt = await self._put_door_group_with_fallbacks(door_id=did, payloads=payloads, headers=headers)
|
|
2404
|
+
if ok:
|
|
2405
|
+
updated.append({"door_id": did, "name": d.get("name"), "reassigned_group_id": int(fallback_gid)})
|
|
2406
|
+
else:
|
|
2407
|
+
failures.append({"door_id": did, "name": d.get("name"), "status": st, "error": txt})
|
|
2408
|
+
|
|
2409
|
+
return self.success_response({
|
|
2410
|
+
"message": f"Processed {len(doors)} door(s) for removal from group {target_gid}.",
|
|
2411
|
+
"summary": {
|
|
2412
|
+
"updated": len(updated),
|
|
2413
|
+
"unchanged": len(unchanged),
|
|
2414
|
+
"failed": len(failures)
|
|
2415
|
+
},
|
|
2416
|
+
"updated": updated,
|
|
2417
|
+
"unchanged": unchanged,
|
|
2418
|
+
"failed": failures
|
|
2419
|
+
})
|
|
2420
|
+
|
|
2421
|
+
except Exception as e:
|
|
2422
|
+
return await self.handle_api_error(e)
|
|
2423
|
+
|
|
2424
|
+
|
|
2425
|
+
async def _choose_fallback_group_id(
|
|
2426
|
+
self, *, headers: Dict[str, str], exclude_group_id: Optional[int] = None
|
|
2427
|
+
) -> Optional[int]:
|
|
2428
|
+
groups = await self._get_door_groups(headers)
|
|
2429
|
+
|
|
2430
|
+
ex = None
|
|
2431
|
+
if exclude_group_id is not None:
|
|
2432
|
+
try:
|
|
2433
|
+
ex = int(exclude_group_id)
|
|
2434
|
+
except Exception:
|
|
2435
|
+
ex = None
|
|
2436
|
+
|
|
2437
|
+
def _id(g):
|
|
2438
|
+
try:
|
|
2439
|
+
return int(g.get("id"))
|
|
2440
|
+
except Exception:
|
|
2441
|
+
return None
|
|
2442
|
+
|
|
2443
|
+
def _name(g):
|
|
2444
|
+
return (g.get("name") or "").strip().lower()
|
|
2445
|
+
|
|
2446
|
+
# 이름 기반 우선 후보
|
|
2447
|
+
preferred = {"all door groups", "all doors", "기본", "전체", "default"}
|
|
2448
|
+
for g in groups:
|
|
2449
|
+
gid = _id(g)
|
|
2450
|
+
if gid is None or (ex is not None and gid == ex):
|
|
2451
|
+
continue
|
|
2452
|
+
if _name(g) in preferred:
|
|
2453
|
+
return gid
|
|
2454
|
+
|
|
2455
|
+
# id == 1 시도
|
|
2456
|
+
for g in groups:
|
|
2457
|
+
gid = _id(g)
|
|
2458
|
+
if gid == 1 and (ex is None or gid != ex):
|
|
2459
|
+
return gid
|
|
2460
|
+
|
|
2461
|
+
# 남은 것 중 가장 작은 id
|
|
2462
|
+
cands = sorted([_id(g) for g in groups if _id(g) is not None and (ex is None or _id(g) != ex)])
|
|
2463
|
+
return cands[0] if cands else None
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
async def _fallback_candidates(
|
|
2467
|
+
self, *, headers: Dict[str, str], exclude_group_id: Optional[int] = None
|
|
2468
|
+
) -> Dict[str, Any]:
|
|
2469
|
+
groups = await self._get_door_groups(headers)
|
|
2470
|
+
|
|
2471
|
+
ex = None
|
|
2472
|
+
if exclude_group_id is not None:
|
|
2473
|
+
try:
|
|
2474
|
+
ex = int(exclude_group_id)
|
|
2475
|
+
except Exception:
|
|
2476
|
+
ex = None
|
|
2477
|
+
|
|
2478
|
+
cands: List[Dict[str, Any]] = []
|
|
2479
|
+
for g in groups:
|
|
2480
|
+
try:
|
|
2481
|
+
gid = int(g.get("id"))
|
|
2482
|
+
except Exception:
|
|
2483
|
+
continue
|
|
2484
|
+
if ex is not None and gid == ex:
|
|
2485
|
+
continue
|
|
2486
|
+
cands.append({"id": gid, "name": g.get("name")})
|
|
2487
|
+
|
|
2488
|
+
suggested = await self._choose_fallback_group_id(headers=headers, exclude_group_id=exclude_group_id)
|
|
2489
|
+
return {"candidates": cands, "suggested_fallback_group_id": suggested}
|
|
2490
|
+
async def update_door_description(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2491
|
+
"""
|
|
2492
|
+
Update only the 'description' of an existing door (minimal PUT payload).
|
|
2493
|
+
Spec:
|
|
2494
|
+
- PUT /api/doors/:id
|
|
2495
|
+
- Body: {"Door": {"description": "<text>"}}
|
|
2496
|
+
Behavior:
|
|
2497
|
+
- Sends ONLY the description field; no other fields are fetched or sent.
|
|
2498
|
+
- Returns success on HTTP 200 or 204.
|
|
2499
|
+
"""
|
|
2500
|
+
try:
|
|
2501
|
+
self.check_auth()
|
|
2502
|
+
|
|
2503
|
+
# --- args validation ---
|
|
2504
|
+
try:
|
|
2505
|
+
door_id = int(args["door_id"])
|
|
2506
|
+
except Exception:
|
|
2507
|
+
return self.error_response("Invalid 'door_id'. Must be an integer.")
|
|
2508
|
+
|
|
2509
|
+
# Allow empty string to clear the description explicitly.
|
|
2510
|
+
description = str(args.get("description", ""))
|
|
2511
|
+
|
|
2512
|
+
headers = {
|
|
2513
|
+
"bs-session-id": self.get_session_id(),
|
|
2514
|
+
"Content-Type": "application/json"
|
|
2515
|
+
}
|
|
2516
|
+
body = {"Door": {"description": description}}
|
|
2517
|
+
|
|
2518
|
+
async with httpx.AsyncClient(
|
|
2519
|
+
verify=False,
|
|
2520
|
+
timeout=httpx.Timeout(10.0, read=20.0)
|
|
2521
|
+
) as client:
|
|
2522
|
+
resp = await client.put(
|
|
2523
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
2524
|
+
headers=headers,
|
|
2525
|
+
json=body
|
|
2526
|
+
)
|
|
2527
|
+
|
|
2528
|
+
if resp.status_code not in (200, 204):
|
|
2529
|
+
# Surface API error details for debugging
|
|
2530
|
+
return self.error_response(
|
|
2531
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
2532
|
+
{"request_body": body}
|
|
2533
|
+
)
|
|
2534
|
+
|
|
2535
|
+
return self.success_response({
|
|
2536
|
+
"message": f"Door {door_id} description updated.",
|
|
2537
|
+
"door_id": door_id,
|
|
2538
|
+
"request_body": body
|
|
2539
|
+
})
|
|
2540
|
+
|
|
2541
|
+
except Exception as e:
|
|
2542
|
+
return await self.handle_api_error(e)
|
|
2543
|
+
|
|
2544
|
+
async def set_door_auto_mode(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2545
|
+
"""
|
|
2546
|
+
Idempotent toggle for 'auto door' (unconditional_lock) with XOR rule enforcement against open_once.
|
|
2547
|
+
- If enable=true and open_once is currently true -> reject with business-rule message.
|
|
2548
|
+
- If state is already desired -> return NO-OP.
|
|
2549
|
+
- Otherwise PUT with preserved fields and the single change applied.
|
|
2550
|
+
"""
|
|
2551
|
+
try:
|
|
2552
|
+
self.check_auth()
|
|
2553
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
2554
|
+
|
|
2555
|
+
if "enable" not in args:
|
|
2556
|
+
return self.error_response("'enable' is required (true|false).")
|
|
2557
|
+
desired_enable = bool(args["enable"])
|
|
2558
|
+
|
|
2559
|
+
# Resolve door id
|
|
2560
|
+
door_id = None
|
|
2561
|
+
if args.get("door_id") is not None:
|
|
2562
|
+
try:
|
|
2563
|
+
door_id = int(args["door_id"])
|
|
2564
|
+
except Exception:
|
|
2565
|
+
return self.error_response("Invalid 'door_id'. Must be an integer.")
|
|
2566
|
+
else:
|
|
2567
|
+
name = (args.get("door_name") or "").strip()
|
|
2568
|
+
if not name:
|
|
2569
|
+
return self.error_response("Either 'door_id' or 'door_name' must be provided.")
|
|
2570
|
+
# assume you have a resolver
|
|
2571
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[name], headers=headers)
|
|
2572
|
+
if err:
|
|
2573
|
+
return self.error_response(err)
|
|
2574
|
+
door_id = int(resolved[0]["id"])
|
|
2575
|
+
|
|
2576
|
+
# Fetch current state
|
|
2577
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
2578
|
+
cur_auto = self._to_bool(raw.get("unconditional_lock"))
|
|
2579
|
+
cur_relock = self._to_bool(raw.get("open_once"))
|
|
2580
|
+
|
|
2581
|
+
# XOR guard
|
|
2582
|
+
if desired_enable and cur_relock:
|
|
2583
|
+
return self.error_response(
|
|
2584
|
+
"Cannot enable 'auto door' because 'relock on close' is currently enabled (XOR rule)."
|
|
2585
|
+
)
|
|
2586
|
+
|
|
2587
|
+
# Idempotency
|
|
2588
|
+
if cur_auto == desired_enable:
|
|
2589
|
+
return self.success_response({
|
|
2590
|
+
"message": f"Door {door_id}: unconditional_lock already {'enabled' if cur_auto else 'disabled'}.",
|
|
2591
|
+
"door_id": door_id,
|
|
2592
|
+
"unchanged": True,
|
|
2593
|
+
"current_value": cur_auto
|
|
2594
|
+
})
|
|
2595
|
+
|
|
2596
|
+
# Build preserving payload with override
|
|
2597
|
+
overrides = {"unconditional_lock": self._bool_str(desired_enable)}
|
|
2598
|
+
body = self._build_preserving_payload(raw, overrides)
|
|
2599
|
+
|
|
2600
|
+
# PUT
|
|
2601
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
2602
|
+
resp = await client.put(
|
|
2603
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
2604
|
+
headers=headers,
|
|
2605
|
+
json=body
|
|
2606
|
+
)
|
|
2607
|
+
if resp.status_code not in (200, 204):
|
|
2608
|
+
return self.error_response(f"API call failed: {resp.status_code} - {resp.text}", {"request_body": body})
|
|
2609
|
+
|
|
2610
|
+
return self.success_response({
|
|
2611
|
+
"message": f"Door {door_id}: unconditional_lock set to {desired_enable}.",
|
|
2612
|
+
"door_id": door_id,
|
|
2613
|
+
"previous_value": cur_auto,
|
|
2614
|
+
"new_value": desired_enable,
|
|
2615
|
+
"request_body": body
|
|
2616
|
+
})
|
|
2617
|
+
|
|
2618
|
+
except Exception as e:
|
|
2619
|
+
return await self.handle_api_error(e)
|
|
2620
|
+
|
|
2621
|
+
async def set_door_relock_on_close(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2622
|
+
"""
|
|
2623
|
+
Idempotent toggle for 'relock on close' (open_once) with XOR rule enforcement against unconditional_lock.
|
|
2624
|
+
- If enable=true and unconditional_lock is currently true -> reject (XOR rule).
|
|
2625
|
+
- If state is already desired -> NO-OP.
|
|
2626
|
+
- Otherwise PUT with preserved fields and the single change applied.
|
|
2627
|
+
"""
|
|
2628
|
+
try:
|
|
2629
|
+
self.check_auth()
|
|
2630
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
2631
|
+
|
|
2632
|
+
if "enable" not in args:
|
|
2633
|
+
return self.error_response("'enable' is required (true|false).")
|
|
2634
|
+
desired_enable = bool(args["enable"])
|
|
2635
|
+
|
|
2636
|
+
# Resolve door id
|
|
2637
|
+
door_id = None
|
|
2638
|
+
if args.get("door_id") is not None:
|
|
2639
|
+
try:
|
|
2640
|
+
door_id = int(args["door_id"])
|
|
2641
|
+
except Exception:
|
|
2642
|
+
return self.error_response("Invalid 'door_id'. Must be an integer.")
|
|
2643
|
+
else:
|
|
2644
|
+
name = (args.get("door_name") or "").strip()
|
|
2645
|
+
if not name:
|
|
2646
|
+
return self.error_response("Either 'door_id' or 'door_name' must be provided.")
|
|
2647
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[name], headers=headers)
|
|
2648
|
+
if err:
|
|
2649
|
+
return self.error_response(err)
|
|
2650
|
+
door_id = int(resolved[0]["id"])
|
|
2651
|
+
|
|
2652
|
+
# Fetch current state
|
|
2653
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
2654
|
+
cur_relock = self._to_bool(raw.get("open_once"))
|
|
2655
|
+
cur_auto = self._to_bool(raw.get("unconditional_lock"))
|
|
2656
|
+
|
|
2657
|
+
# XOR guard
|
|
2658
|
+
if desired_enable and cur_auto:
|
|
2659
|
+
return self.error_response(
|
|
2660
|
+
"Cannot enable 'relock on close' because 'auto door' is currently enabled (XOR rule)."
|
|
2661
|
+
)
|
|
2662
|
+
|
|
2663
|
+
# Idempotency
|
|
2664
|
+
if cur_relock == desired_enable:
|
|
2665
|
+
return self.success_response({
|
|
2666
|
+
"message": f"Door {door_id}: open_once already {'enabled' if cur_relock else 'disabled'}.",
|
|
2667
|
+
"door_id": door_id,
|
|
2668
|
+
"unchanged": True,
|
|
2669
|
+
"current_value": cur_relock
|
|
2670
|
+
})
|
|
2671
|
+
|
|
2672
|
+
# Build preserving payload with override
|
|
2673
|
+
overrides = {"open_once": self._bool_str(desired_enable)}
|
|
2674
|
+
body = self._build_preserving_payload(raw, overrides)
|
|
2675
|
+
|
|
2676
|
+
# PUT
|
|
2677
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
2678
|
+
resp = await client.put(
|
|
2679
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
2680
|
+
headers=headers,
|
|
2681
|
+
json=body
|
|
2682
|
+
)
|
|
2683
|
+
if resp.status_code not in (200, 204):
|
|
2684
|
+
return self.error_response(f"API call failed: {resp.status_code} - {resp.text}", {"request_body": body})
|
|
2685
|
+
|
|
2686
|
+
return self.success_response({
|
|
2687
|
+
"message": f"Door {door_id}: open_once set to {desired_enable}.",
|
|
2688
|
+
"door_id": door_id,
|
|
2689
|
+
"previous_value": cur_relock,
|
|
2690
|
+
"new_value": desired_enable,
|
|
2691
|
+
"request_body": body
|
|
2692
|
+
})
|
|
2693
|
+
|
|
2694
|
+
except Exception as e:
|
|
2695
|
+
return await self.handle_api_error(e)
|
|
2696
|
+
|
|
2697
|
+
# ---- shared helpers for toggle tools ----
|
|
2698
|
+
def _bool_str(self, x: bool) -> str:
|
|
2699
|
+
return "true" if bool(x) else "false"
|
|
2700
|
+
|
|
2701
|
+
def _to_bool(self, x) -> bool:
|
|
2702
|
+
if isinstance(x, bool):
|
|
2703
|
+
return x
|
|
2704
|
+
s = str(x).strip().lower()
|
|
2705
|
+
return s in ("1", "true", "t", "yes", "y")
|
|
2706
|
+
|
|
2707
|
+
def _build_preserving_payload(self, raw: Dict[str, Any], overrides: Dict[str, Any]) -> Dict[str, Any]:
|
|
2708
|
+
"""
|
|
2709
|
+
Preserve existing writable fields from GET /api/doors/:id response and
|
|
2710
|
+
apply overrides (e.g., {'unconditional_lock': 'true'}).
|
|
2711
|
+
We intentionally slim nested blocks to API-accepted shapes.
|
|
2712
|
+
"""
|
|
2713
|
+
def _to_int(v, default=None):
|
|
2714
|
+
try:
|
|
2715
|
+
return int(v)
|
|
2716
|
+
except Exception:
|
|
2717
|
+
return default
|
|
2718
|
+
|
|
2719
|
+
d: Dict[str, Any] = {}
|
|
2720
|
+
|
|
2721
|
+
# Basics
|
|
2722
|
+
if raw.get("name") is not None:
|
|
2723
|
+
d["name"] = raw.get("name")
|
|
2724
|
+
if raw.get("description") is not None:
|
|
2725
|
+
d["description"] = raw.get("description") or ""
|
|
2726
|
+
|
|
2727
|
+
grp = raw.get("door_group_id") or {}
|
|
2728
|
+
if grp.get("id") is not None:
|
|
2729
|
+
d["door_group_id"] = {"id": _to_int(grp.get("id"))}
|
|
2730
|
+
|
|
2731
|
+
if raw.get("open_timeout") is not None:
|
|
2732
|
+
d["open_timeout"] = _to_int(raw.get("open_timeout"))
|
|
2733
|
+
if raw.get("open_duration") is not None:
|
|
2734
|
+
od = _to_int(raw.get("open_duration"))
|
|
2735
|
+
if od is not None:
|
|
2736
|
+
d["open_duration"] = str(od)
|
|
2737
|
+
|
|
2738
|
+
if raw.get("open_once") is not None:
|
|
2739
|
+
d["open_once"] = self._bool_str(self._to_bool(raw.get("open_once")))
|
|
2740
|
+
if raw.get("unconditional_lock") is not None:
|
|
2741
|
+
d["unconditional_lock"] = self._bool_str(self._to_bool(raw.get("unconditional_lock")))
|
|
2742
|
+
|
|
2743
|
+
# Devices / IO (slim forms)
|
|
2744
|
+
entry = raw.get("entry_device_id") or {}
|
|
2745
|
+
if entry.get("id") is not None:
|
|
2746
|
+
d["entry_device_id"] = {"id": _to_int(entry.get("id"))}
|
|
2747
|
+
|
|
2748
|
+
relay = raw.get("relay_output_id") or {}
|
|
2749
|
+
relay_dev = relay.get("device_id") or {}
|
|
2750
|
+
rid = _to_int(relay_dev.get("id") or entry.get("id"))
|
|
2751
|
+
rindex = _to_int(relay.get("relay_index"))
|
|
2752
|
+
if rid is not None and rindex is not None:
|
|
2753
|
+
d["relay_output_id"] = {"device_id": {"id": rid}, "relay_index": rindex}
|
|
2754
|
+
|
|
2755
|
+
exit_btn = raw.get("exit_button_input_id") or {}
|
|
2756
|
+
exit_dev = exit_btn.get("device_id") or {}
|
|
2757
|
+
xdid = _to_int(exit_dev.get("id"))
|
|
2758
|
+
xidx = _to_int(exit_btn.get("input_index"))
|
|
2759
|
+
xtype = exit_btn.get("type")
|
|
2760
|
+
if xdid is not None and xidx is not None and xtype is not None:
|
|
2761
|
+
d["exit_button_input_id"] = {
|
|
2762
|
+
"device_id": {"id": xdid},
|
|
2763
|
+
"input_index": xidx,
|
|
2764
|
+
"type": str(_to_int(xtype))
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
sensor = raw.get("sensor_input_id") or {}
|
|
2768
|
+
sdev = sensor.get("device_id") or {}
|
|
2769
|
+
sdid = _to_int(sdev.get("id"))
|
|
2770
|
+
sidx = _to_int(sensor.get("input_index"))
|
|
2771
|
+
stype = sensor.get("type")
|
|
2772
|
+
sapb = sensor.get("apb_use_door_sensor")
|
|
2773
|
+
if sdid is not None and sidx is not None and stype is not None:
|
|
2774
|
+
d["sensor_input_id"] = {
|
|
2775
|
+
"device_id": {"id": sdid},
|
|
2776
|
+
"input_index": sidx,
|
|
2777
|
+
"type": str(_to_int(stype))
|
|
2778
|
+
}
|
|
2779
|
+
if sapb is not None:
|
|
2780
|
+
d["sensor_input_id"]["apb_use_door_sensor"] = "1" if self._to_bool(sapb) else "0"
|
|
2781
|
+
|
|
2782
|
+
# apply overrides last
|
|
2783
|
+
d.update(overrides)
|
|
2784
|
+
return {"Door": d}
|
|
2785
|
+
|
|
2786
|
+
async def set_door_open_duration(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2787
|
+
"""
|
|
2788
|
+
Set door 'open_duration' (seconds) preserving all other options.
|
|
2789
|
+
Behavior:
|
|
2790
|
+
1) Resolve door by id or exact name.
|
|
2791
|
+
2) GET current detail; if already desired, return no-op.
|
|
2792
|
+
3) PUT with a body built from preserved fields + open_duration override.
|
|
2793
|
+
Notes:
|
|
2794
|
+
- API expects 'open_duration' as string; we convert int -> str for compatibility.
|
|
2795
|
+
- All other fields are preserved from the GET result via _build_preserving_payload().
|
|
2796
|
+
"""
|
|
2797
|
+
try:
|
|
2798
|
+
self.check_auth()
|
|
2799
|
+
|
|
2800
|
+
headers = {
|
|
2801
|
+
"bs-session-id": self.get_session_id(),
|
|
2802
|
+
"Content-Type": "application/json"
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
# --- parse input
|
|
2806
|
+
if "seconds" not in args:
|
|
2807
|
+
return self.error_response("'seconds' is required (integer).")
|
|
2808
|
+
try:
|
|
2809
|
+
desired_seconds = int(args["seconds"])
|
|
2810
|
+
except Exception:
|
|
2811
|
+
return self.error_response("'seconds' must be an integer.")
|
|
2812
|
+
|
|
2813
|
+
# --- enforce range: 1..900 inclusive
|
|
2814
|
+
if desired_seconds < 1 or desired_seconds > 900:
|
|
2815
|
+
return self.error_response(
|
|
2816
|
+
"'seconds' must be between 1 and 900 (inclusive).",
|
|
2817
|
+
{"received": desired_seconds, "allowed_range": [1, 900]}
|
|
2818
|
+
)
|
|
2819
|
+
|
|
2820
|
+
# --- resolve door id (id or name)
|
|
2821
|
+
door_id = None
|
|
2822
|
+
if args.get("door_id") is not None:
|
|
2823
|
+
try:
|
|
2824
|
+
door_id = int(args["door_id"])
|
|
2825
|
+
except Exception:
|
|
2826
|
+
return self.error_response("Invalid 'door_id'. Must be an integer.")
|
|
2827
|
+
else:
|
|
2828
|
+
door_name = (args.get("door_name") or "").strip()
|
|
2829
|
+
if not door_name:
|
|
2830
|
+
return self.error_response("Either 'door_id' or 'door_name' must be provided.")
|
|
2831
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[door_name], headers=headers)
|
|
2832
|
+
if err:
|
|
2833
|
+
return self.error_response(err)
|
|
2834
|
+
door_id = int(resolved[0]["id"])
|
|
2835
|
+
|
|
2836
|
+
# --- fetch current detail
|
|
2837
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
2838
|
+
cur_str = str(raw.get("open_duration") if raw.get("open_duration") is not None else "")
|
|
2839
|
+
try:
|
|
2840
|
+
cur_seconds = int(cur_str) if cur_str != "" else None
|
|
2841
|
+
except Exception:
|
|
2842
|
+
cur_seconds = None
|
|
2843
|
+
|
|
2844
|
+
# --- idempotency
|
|
2845
|
+
if cur_seconds is not None and cur_seconds == desired_seconds:
|
|
2846
|
+
return self.success_response({
|
|
2847
|
+
"message": f"Door {door_id}: open_duration already {desired_seconds} seconds. No update performed.",
|
|
2848
|
+
"door_id": door_id,
|
|
2849
|
+
"unchanged": True,
|
|
2850
|
+
"current_value": cur_seconds
|
|
2851
|
+
})
|
|
2852
|
+
|
|
2853
|
+
# --- build payload (preserve others, override open_duration)
|
|
2854
|
+
overrides = {"open_duration": str(desired_seconds)}
|
|
2855
|
+
body = self._build_preserving_payload(raw, overrides)
|
|
2856
|
+
|
|
2857
|
+
# --- PUT
|
|
2858
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
2859
|
+
resp = await client.put(
|
|
2860
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
2861
|
+
headers=headers,
|
|
2862
|
+
json=body
|
|
2863
|
+
)
|
|
2864
|
+
if resp.status_code not in (200, 204):
|
|
2865
|
+
return self.error_response(
|
|
2866
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
2867
|
+
{"request_body": body}
|
|
2868
|
+
)
|
|
2869
|
+
|
|
2870
|
+
return self.success_response({
|
|
2871
|
+
"message": f"Door {door_id}: open_duration set to {desired_seconds} seconds.",
|
|
2872
|
+
"door_id": door_id,
|
|
2873
|
+
"previous_value": cur_seconds,
|
|
2874
|
+
"new_value": desired_seconds,
|
|
2875
|
+
"request_body": body
|
|
2876
|
+
})
|
|
2877
|
+
|
|
2878
|
+
except Exception as e:
|
|
2879
|
+
return await self.handle_api_error(e)
|
|
2880
|
+
|
|
2881
|
+
async def set_door_timed_apb(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
2882
|
+
"""
|
|
2883
|
+
Update Timed Anti-Passback while preserving every other field.
|
|
2884
|
+
Business rules:
|
|
2885
|
+
- reset_time: 0..60 (inclusive)
|
|
2886
|
+
- selected_device allowed options depend on presence of entry/exit devices
|
|
2887
|
+
(0=NO_DEVICE, 1=ENTRY_ONLY, 2=EXIT_ONLY)
|
|
2888
|
+
- When enabling (selected_device!=0), force:
|
|
2889
|
+
door_anti_passback.apb_type = 0 (NONE)
|
|
2890
|
+
sensor_input_id.apb_use_door_sensor = "0" (OFF)
|
|
2891
|
+
- Timed APB apb_type defaults to 2 (HARD_APB). Other values rejected.
|
|
2892
|
+
"""
|
|
2893
|
+
try:
|
|
2894
|
+
self.check_auth()
|
|
2895
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
2896
|
+
|
|
2897
|
+
# ---- normalize selected_device ----
|
|
2898
|
+
sel_raw = args.get("selected_device", None)
|
|
2899
|
+
if sel_raw is None:
|
|
2900
|
+
return self.error_response("'selected_device' is required.")
|
|
2901
|
+
sd_map = {
|
|
2902
|
+
"0": 0, "1": 1, "2": 2,
|
|
2903
|
+
"NO_DEVICE": 0, "ENTRY_ONLY": 1, "EXIT_ONLY": 2,
|
|
2904
|
+
"none": 0, "entry": 1, "exit": 2
|
|
2905
|
+
}
|
|
2906
|
+
if isinstance(sel_raw, str):
|
|
2907
|
+
sel_key = sel_raw.strip()
|
|
2908
|
+
if sel_key not in sd_map:
|
|
2909
|
+
return self.error_response("Invalid 'selected_device'. Use 0/1/2 or NO_DEVICE/ENTRY_ONLY/EXIT_ONLY.")
|
|
2910
|
+
selected_device = sd_map[sel_key]
|
|
2911
|
+
else:
|
|
2912
|
+
try:
|
|
2913
|
+
selected_device = int(sel_raw)
|
|
2914
|
+
except Exception:
|
|
2915
|
+
return self.error_response("Invalid 'selected_device'. Must be 0, 1, or 2.")
|
|
2916
|
+
if selected_device not in (0, 1, 2):
|
|
2917
|
+
return self.error_response("Invalid 'selected_device'. Must be 0, 1, or 2.")
|
|
2918
|
+
|
|
2919
|
+
# ---- reset_time 0..60 inclusive ----
|
|
2920
|
+
if "reset_time" not in args:
|
|
2921
|
+
return self.error_response("'reset_time' is required (minutes).")
|
|
2922
|
+
try:
|
|
2923
|
+
reset_time = int(args["reset_time"])
|
|
2924
|
+
except Exception:
|
|
2925
|
+
return self.error_response("'reset_time' must be an integer (minutes).")
|
|
2926
|
+
if reset_time < 0 or reset_time > 60:
|
|
2927
|
+
return self.error_response(
|
|
2928
|
+
"'reset_time' must be between 0 and 60 (inclusive).",
|
|
2929
|
+
{"received": reset_time, "allowed_range": [0, 60]}
|
|
2930
|
+
)
|
|
2931
|
+
|
|
2932
|
+
# ---- apb_type (Timed) default 2 (HARD) ----
|
|
2933
|
+
apb_type = int(args.get("apb_type", 2))
|
|
2934
|
+
if apb_type != 2:
|
|
2935
|
+
return self.error_response("Only Timed APB type '2' (HARD_APB) is supported.", {"received": apb_type})
|
|
2936
|
+
|
|
2937
|
+
# ---- resolve door ----
|
|
2938
|
+
if args.get("door_id") is not None:
|
|
2939
|
+
try:
|
|
2940
|
+
door_id = int(args["door_id"])
|
|
2941
|
+
except Exception:
|
|
2942
|
+
return self.error_response("Invalid 'door_id'. Must be an integer.")
|
|
2943
|
+
else:
|
|
2944
|
+
name = (args.get("door_name") or "").strip()
|
|
2945
|
+
if not name:
|
|
2946
|
+
return self.error_response("Either 'door_id' or 'door_name' must be provided.")
|
|
2947
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[name], headers=headers)
|
|
2948
|
+
if err:
|
|
2949
|
+
return self.error_response(err)
|
|
2950
|
+
door_id = int(resolved[0]["id"])
|
|
2951
|
+
|
|
2952
|
+
# ---- fetch current detail ----
|
|
2953
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
2954
|
+
entry_present = bool((raw.get("entry_device_id") or {}).get("id"))
|
|
2955
|
+
exit_present = bool((raw.get("exit_device_id") or {}).get("id"))
|
|
2956
|
+
|
|
2957
|
+
# ---- selected_device vs device availability ----
|
|
2958
|
+
if not entry_present and not exit_present:
|
|
2959
|
+
allowed = [0]
|
|
2960
|
+
elif entry_present and exit_present:
|
|
2961
|
+
allowed = [0, 1, 2]
|
|
2962
|
+
elif entry_present:
|
|
2963
|
+
allowed = [0, 1]
|
|
2964
|
+
else: # exit only
|
|
2965
|
+
allowed = [0, 2]
|
|
2966
|
+
|
|
2967
|
+
if selected_device not in allowed:
|
|
2968
|
+
return self.error_response(
|
|
2969
|
+
"Selected device option not allowed for current door setup.",
|
|
2970
|
+
{
|
|
2971
|
+
"entry_device_present": entry_present,
|
|
2972
|
+
"exit_device_present": exit_present,
|
|
2973
|
+
"allowed_selected_device": allowed,
|
|
2974
|
+
"received": selected_device
|
|
2975
|
+
}
|
|
2976
|
+
)
|
|
2977
|
+
|
|
2978
|
+
# ---- build preserving body ----
|
|
2979
|
+
body = self._build_preserving_payload(raw, overrides={})
|
|
2980
|
+
d = body["Door"]
|
|
2981
|
+
|
|
2982
|
+
# ensure sensor block exists if present in raw
|
|
2983
|
+
if "sensor_input_id" in d:
|
|
2984
|
+
# When enabling Timed APB, force door sensor APB OFF
|
|
2985
|
+
if selected_device != 0:
|
|
2986
|
+
d["sensor_input_id"]["apb_use_door_sensor"] = "0"
|
|
2987
|
+
|
|
2988
|
+
# door_anti_passback NONE when enabling
|
|
2989
|
+
if selected_device != 0:
|
|
2990
|
+
d["door_anti_passback"] = {"apb_type": "0"}
|
|
2991
|
+
|
|
2992
|
+
# Timed APB block (preserve id / bypass_groups if not overridden)
|
|
2993
|
+
tapb_raw = raw.get("door_timed_anti_passback") or {}
|
|
2994
|
+
tapb: Dict[str, Any] = {
|
|
2995
|
+
"apb_type": str(apb_type),
|
|
2996
|
+
"reset_time": str(reset_time),
|
|
2997
|
+
"selected_device": str(selected_device)
|
|
2998
|
+
}
|
|
2999
|
+
if tapb_raw.get("id") is not None:
|
|
3000
|
+
tapb["id"] = str(tapb_raw.get("id"))
|
|
3001
|
+
|
|
3002
|
+
# bypass groups
|
|
3003
|
+
if "bypass_group_ids" in args and args["bypass_group_ids"] is not None:
|
|
3004
|
+
try:
|
|
3005
|
+
gids = [int(x) for x in args["bypass_group_ids"]]
|
|
3006
|
+
except Exception:
|
|
3007
|
+
return self.error_response("'bypass_group_ids' must be an array of integers.")
|
|
3008
|
+
tapb["bypass_groups"] = [{"id": g} for g in gids]
|
|
3009
|
+
elif isinstance(tapb_raw.get("bypass_groups"), list):
|
|
3010
|
+
# preserve existing if present and not overridden
|
|
3011
|
+
tapb["bypass_groups"] = [
|
|
3012
|
+
{"id": bg.get("id")} for bg in tapb_raw.get("bypass_groups") if isinstance(bg, dict) and bg.get("id") is not None
|
|
3013
|
+
]
|
|
3014
|
+
|
|
3015
|
+
d["door_timed_anti_passback"] = tapb
|
|
3016
|
+
|
|
3017
|
+
# ---- PUT ----
|
|
3018
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
3019
|
+
resp = await client.put(
|
|
3020
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
3021
|
+
headers=headers,
|
|
3022
|
+
json=body
|
|
3023
|
+
)
|
|
3024
|
+
|
|
3025
|
+
if resp.status_code not in (200, 204):
|
|
3026
|
+
return self.error_response(
|
|
3027
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
3028
|
+
{"request_body": body}
|
|
3029
|
+
)
|
|
3030
|
+
|
|
3031
|
+
return self.success_response({
|
|
3032
|
+
"message": f"Door {door_id}: Timed APB updated.",
|
|
3033
|
+
"door_id": door_id,
|
|
3034
|
+
"selected_device": selected_device,
|
|
3035
|
+
"reset_time": reset_time,
|
|
3036
|
+
"apb_type": apb_type,
|
|
3037
|
+
"request_body": body
|
|
3038
|
+
})
|
|
3039
|
+
|
|
3040
|
+
except Exception as e:
|
|
3041
|
+
return await self.handle_api_error(e)
|
|
3042
|
+
|
|
3043
|
+
async def set_door_apb(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
3044
|
+
"""
|
|
3045
|
+
Unified Timed APB tool:
|
|
3046
|
+
- mode: off | entry | exit (or 0/1/2)
|
|
3047
|
+
- reset_time: 0..60 inclusive (required when mode != off)
|
|
3048
|
+
- bypass_group_ids: optional
|
|
3049
|
+
Business rules:
|
|
3050
|
+
- Allowed modes depend on door device configuration:
|
|
3051
|
+
(no entry, no exit) => {off}
|
|
3052
|
+
(entry & exit) => {off, entry, exit}
|
|
3053
|
+
(entry only) => {off, entry}
|
|
3054
|
+
(exit only) => {off, exit}
|
|
3055
|
+
- When enabling (entry/exit):
|
|
3056
|
+
door_anti_passback.apb_type = "0" (NONE)
|
|
3057
|
+
sensor_input_id.apb_use_door_sensor = "0" (OFF)
|
|
3058
|
+
- All other fields must be preserved exactly as-is.
|
|
3059
|
+
"""
|
|
3060
|
+
try:
|
|
3061
|
+
# --- Auth check ---
|
|
3062
|
+
self.check_auth()
|
|
3063
|
+
headers = {
|
|
3064
|
+
"bs-session-id": self.get_session_id(),
|
|
3065
|
+
"Content-Type": "application/json",
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
# --- Normalize mode ---
|
|
3069
|
+
raw_mode = args.get("mode")
|
|
3070
|
+
if raw_mode is None:
|
|
3071
|
+
return self.error_response("Missing required field: mode")
|
|
3072
|
+
|
|
3073
|
+
if isinstance(raw_mode, int):
|
|
3074
|
+
mode_i = raw_mode
|
|
3075
|
+
else:
|
|
3076
|
+
s = str(raw_mode).strip().lower()
|
|
3077
|
+
if s in ("off", "0"):
|
|
3078
|
+
mode_i = 0
|
|
3079
|
+
elif s in ("entry", "1"):
|
|
3080
|
+
mode_i = 1
|
|
3081
|
+
elif s in ("exit", "2"):
|
|
3082
|
+
mode_i = 2
|
|
3083
|
+
else:
|
|
3084
|
+
return self.error_response(
|
|
3085
|
+
"Invalid mode. Use off/entry/exit or 0/1/2.",
|
|
3086
|
+
{"received": raw_mode},
|
|
3087
|
+
)
|
|
3088
|
+
|
|
3089
|
+
# --- Validate reset_time ---
|
|
3090
|
+
reset_time = args.get("reset_time", None)
|
|
3091
|
+
if mode_i != 0:
|
|
3092
|
+
if reset_time is None:
|
|
3093
|
+
return self.error_response("reset_time (0..60) is required when mode != off")
|
|
3094
|
+
try:
|
|
3095
|
+
reset_time = int(reset_time)
|
|
3096
|
+
except Exception:
|
|
3097
|
+
return self.error_response("reset_time must be an integer (minutes)")
|
|
3098
|
+
if reset_time < 0 or reset_time > 60:
|
|
3099
|
+
return self.error_response("reset_time must be within 0..60 inclusive", {"received": reset_time})
|
|
3100
|
+
else:
|
|
3101
|
+
# OFF: if provided, still validate; if omitted, we keep the current one.
|
|
3102
|
+
if reset_time is not None:
|
|
3103
|
+
try:
|
|
3104
|
+
reset_time = int(reset_time)
|
|
3105
|
+
except Exception:
|
|
3106
|
+
return self.error_response("reset_time must be an integer (minutes)")
|
|
3107
|
+
if reset_time < 0 or reset_time > 60:
|
|
3108
|
+
return self.error_response("reset_time must be within 0..60 inclusive", {"received": reset_time})
|
|
3109
|
+
|
|
3110
|
+
# --- Identify door ---
|
|
3111
|
+
if args.get("door_id") is not None:
|
|
3112
|
+
try:
|
|
3113
|
+
door_id = int(args["door_id"])
|
|
3114
|
+
except Exception:
|
|
3115
|
+
return self.error_response("door_id must be an integer")
|
|
3116
|
+
else:
|
|
3117
|
+
door_name = (args.get("door_name") or "").strip()
|
|
3118
|
+
if not door_name:
|
|
3119
|
+
return self.error_response("Either door_id or door_name is required")
|
|
3120
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[door_name], headers=headers)
|
|
3121
|
+
if err:
|
|
3122
|
+
return self.error_response(err)
|
|
3123
|
+
door_id = int(resolved[0]["id"])
|
|
3124
|
+
|
|
3125
|
+
# --- Fetch door detail ---
|
|
3126
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
3127
|
+
|
|
3128
|
+
# Determine which device sides exist
|
|
3129
|
+
entry_present = bool((raw.get("entry_device_id") or {}).get("id"))
|
|
3130
|
+
exit_present = bool((raw.get("exit_device_id") or {}).get("id"))
|
|
3131
|
+
|
|
3132
|
+
# Compute allowed modes
|
|
3133
|
+
if not entry_present and not exit_present:
|
|
3134
|
+
allowed = {0}
|
|
3135
|
+
elif entry_present and exit_present:
|
|
3136
|
+
allowed = {0, 1, 2}
|
|
3137
|
+
elif entry_present:
|
|
3138
|
+
allowed = {0, 1}
|
|
3139
|
+
else:
|
|
3140
|
+
allowed = {0, 2}
|
|
3141
|
+
|
|
3142
|
+
if mode_i not in allowed:
|
|
3143
|
+
return self.error_response(
|
|
3144
|
+
"Requested APB mode is not allowed for this door's device configuration.",
|
|
3145
|
+
{
|
|
3146
|
+
"entry_device_present": entry_present,
|
|
3147
|
+
"exit_device_present": exit_present,
|
|
3148
|
+
"allowed_modes": sorted(list(allowed)),
|
|
3149
|
+
"requested_mode": mode_i,
|
|
3150
|
+
},
|
|
3151
|
+
)
|
|
3152
|
+
|
|
3153
|
+
# --- Build preserving payload (keep all fields except we override APB) ---
|
|
3154
|
+
body = self._build_preserving_payload(raw, overrides={})
|
|
3155
|
+
door_block = body["Door"]
|
|
3156
|
+
|
|
3157
|
+
# Prepare Timed APB block
|
|
3158
|
+
tapb_raw = raw.get("door_timed_anti_passback") or {}
|
|
3159
|
+
tapb: Dict[str, Any] = {
|
|
3160
|
+
"apb_type": "2", # HARD APB only
|
|
3161
|
+
"selected_device": str(mode_i),
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
# reset_time rules
|
|
3165
|
+
if mode_i != 0:
|
|
3166
|
+
tapb["reset_time"] = str(reset_time)
|
|
3167
|
+
else:
|
|
3168
|
+
if reset_time is not None:
|
|
3169
|
+
tapb["reset_time"] = str(reset_time)
|
|
3170
|
+
elif tapb_raw.get("reset_time") is not None:
|
|
3171
|
+
tapb["reset_time"] = str(tapb_raw.get("reset_time"))
|
|
3172
|
+
|
|
3173
|
+
# preserve id if present
|
|
3174
|
+
if tapb_raw.get("id") is not None:
|
|
3175
|
+
tapb["id"] = str(tapb_raw.get("id"))
|
|
3176
|
+
|
|
3177
|
+
# bypass_groups: replace only if provided; otherwise, preserve current
|
|
3178
|
+
if args.get("bypass_group_ids") is not None:
|
|
3179
|
+
try:
|
|
3180
|
+
gids = [int(x) for x in args["bypass_group_ids"]]
|
|
3181
|
+
except Exception:
|
|
3182
|
+
return self.error_response("bypass_group_ids must be an array of integers")
|
|
3183
|
+
tapb["bypass_groups"] = [{"id": g} for g in gids]
|
|
3184
|
+
elif isinstance(tapb_raw.get("bypass_groups"), list):
|
|
3185
|
+
tapb["bypass_groups"] = [
|
|
3186
|
+
{"id": bg.get("id")}
|
|
3187
|
+
for bg in tapb_raw.get("bypass_groups")
|
|
3188
|
+
if isinstance(bg, dict) and bg.get("id") is not None
|
|
3189
|
+
]
|
|
3190
|
+
|
|
3191
|
+
door_block["door_timed_anti_passback"] = tapb
|
|
3192
|
+
|
|
3193
|
+
# When enabling APB, force classic APB and sensor APB OFF to avoid conflicts
|
|
3194
|
+
if mode_i != 0:
|
|
3195
|
+
door_block["door_anti_passback"] = {"apb_type": "0"} # NONE
|
|
3196
|
+
if "sensor_input_id" in door_block and isinstance(door_block["sensor_input_id"], dict):
|
|
3197
|
+
door_block["sensor_input_id"]["apb_use_door_sensor"] = "0"
|
|
3198
|
+
|
|
3199
|
+
# --- PUT ---
|
|
3200
|
+
async with httpx.AsyncClient(
|
|
3201
|
+
verify=False,
|
|
3202
|
+
timeout=httpx.Timeout(10.0, read=20.0),
|
|
3203
|
+
) as client:
|
|
3204
|
+
resp = await client.put(
|
|
3205
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
3206
|
+
headers=headers,
|
|
3207
|
+
json=body,
|
|
3208
|
+
)
|
|
3209
|
+
|
|
3210
|
+
if resp.status_code not in (200, 204):
|
|
3211
|
+
return self.error_response(
|
|
3212
|
+
f"API request failed: {resp.status_code} - {resp.text}",
|
|
3213
|
+
{"request_body": body},
|
|
3214
|
+
)
|
|
3215
|
+
|
|
3216
|
+
return self.success_response({
|
|
3217
|
+
"message": "Timed APB has been applied.",
|
|
3218
|
+
"door_id": door_id,
|
|
3219
|
+
"mode": {0: "off", 1: "entry", 2: "exit"}[mode_i],
|
|
3220
|
+
"reset_time": tapb.get("reset_time"),
|
|
3221
|
+
"request_body": body
|
|
3222
|
+
})
|
|
3223
|
+
|
|
3224
|
+
except Exception as e:
|
|
3225
|
+
return await self.handle_api_error(e)
|
|
3226
|
+
|
|
3227
|
+
async def _search_device_v2_by_name(
|
|
3228
|
+
self,
|
|
3229
|
+
*,
|
|
3230
|
+
name: str,
|
|
3231
|
+
headers: Dict[str, str],
|
|
3232
|
+
exclude_device_type_id: Optional[int] = 254
|
|
3233
|
+
) -> List[Dict[str, Any]]:
|
|
3234
|
+
"""
|
|
3235
|
+
Search registered devices by name using /api/v2/devices/search.
|
|
3236
|
+
Returns simplified rows: [{id, name, ip, device_type_id}]
|
|
3237
|
+
Tries exact and contains; falls back to /api/devices list if v2 is unavailable.
|
|
3238
|
+
"""
|
|
3239
|
+
def _simplify(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
3240
|
+
out, seen = [], set()
|
|
3241
|
+
for r in rows or []:
|
|
3242
|
+
try:
|
|
3243
|
+
did = int(r.get("id"))
|
|
3244
|
+
except Exception:
|
|
3245
|
+
continue
|
|
3246
|
+
if did in seen:
|
|
3247
|
+
continue
|
|
3248
|
+
seen.add(did)
|
|
3249
|
+
out.append({
|
|
3250
|
+
"id": did,
|
|
3251
|
+
"name": r.get("name"),
|
|
3252
|
+
"ip": ((r.get("lan") or {}).get("ip")),
|
|
3253
|
+
"device_type_id": ((r.get("device_type_id") or {}).get("id")),
|
|
3254
|
+
})
|
|
3255
|
+
return out
|
|
3256
|
+
|
|
3257
|
+
payload_base: Dict[str, Any] = {}
|
|
3258
|
+
if exclude_device_type_id is not None:
|
|
3259
|
+
payload_base["exclude_device_type_id"] = str(exclude_device_type_id)
|
|
3260
|
+
|
|
3261
|
+
endpoints = [
|
|
3262
|
+
{**payload_base, "name": name}, # exact
|
|
3263
|
+
{**payload_base, "name_contains": name}, # contains
|
|
3264
|
+
{**payload_base, "keyword": name}, # generic
|
|
3265
|
+
]
|
|
3266
|
+
|
|
3267
|
+
for p in endpoints:
|
|
3268
|
+
try:
|
|
3269
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
3270
|
+
r = await client.post(
|
|
3271
|
+
f"{self.session.config.biostar_url}/api/v2/devices/search",
|
|
3272
|
+
headers=headers,
|
|
3273
|
+
json=p
|
|
3274
|
+
)
|
|
3275
|
+
if r.status_code == 200:
|
|
3276
|
+
rows = (r.json().get("DeviceCollection") or {}).get("rows", []) or []
|
|
3277
|
+
if rows:
|
|
3278
|
+
return _simplify(rows)
|
|
3279
|
+
except Exception as ex:
|
|
3280
|
+
logger.info(f"/api/v2/devices/search failed for payload {p}: {ex}")
|
|
3281
|
+
|
|
3282
|
+
# Fallback to registered device list filtering
|
|
3283
|
+
regs = await self._list_registered_devices(headers=headers)
|
|
3284
|
+
norm = unicodedata.normalize("NFKC", name or "").strip().casefold()
|
|
3285
|
+
hits = []
|
|
3286
|
+
for r in regs:
|
|
3287
|
+
nm = unicodedata.normalize("NFKC", str(r.get("name") or "")).strip().casefold()
|
|
3288
|
+
if nm == norm or norm in nm:
|
|
3289
|
+
hits.append({
|
|
3290
|
+
"id": int(r.get("id")),
|
|
3291
|
+
"name": r.get("name"),
|
|
3292
|
+
"ip": ((r.get("lan") or {}).get("ip")),
|
|
3293
|
+
"device_type_id": ((r.get("device_type_id") or {}).get("id")),
|
|
3294
|
+
})
|
|
3295
|
+
return hits
|
|
3296
|
+
|
|
3297
|
+
async def _resolve_device_id_from_pair(
|
|
3298
|
+
self,
|
|
3299
|
+
*,
|
|
3300
|
+
device_id: Optional[int],
|
|
3301
|
+
device_name: Optional[str],
|
|
3302
|
+
headers: Dict[str, str],
|
|
3303
|
+
label: str
|
|
3304
|
+
) -> Tuple[Optional[int], Optional[str], Optional[List[Dict[str, Any]]]]:
|
|
3305
|
+
"""
|
|
3306
|
+
Resolve a device id from (id, name). Precedence: id > name.
|
|
3307
|
+
Returns (resolved_id, error_str, candidates_if_ambiguous).
|
|
3308
|
+
When multiple matches exist for name, returns error with candidates.
|
|
3309
|
+
"""
|
|
3310
|
+
if device_id is not None:
|
|
3311
|
+
try:
|
|
3312
|
+
return int(device_id), None, None
|
|
3313
|
+
except Exception:
|
|
3314
|
+
return None, f"{label}: invalid device_id.", None
|
|
3315
|
+
|
|
3316
|
+
if not device_name:
|
|
3317
|
+
return None, None, None # not provided => no change
|
|
3318
|
+
|
|
3319
|
+
# Allow clearing semantics for entry_device_name ('none', 'null', 'empty')
|
|
3320
|
+
norm = unicodedata.normalize("NFKC", device_name).strip().lower()
|
|
3321
|
+
if norm in ("none", "null", "empty", "clear"):
|
|
3322
|
+
return -1, None, None # sentinel for clearing
|
|
3323
|
+
|
|
3324
|
+
hits = await self._search_device_v2_by_name(name=device_name, headers=headers)
|
|
3325
|
+
if not hits:
|
|
3326
|
+
return None, f"{label}: device named '{device_name}' not found.", []
|
|
3327
|
+
if len(hits) == 1:
|
|
3328
|
+
return int(hits[0]["id"]), None, None
|
|
3329
|
+
|
|
3330
|
+
# ambiguous
|
|
3331
|
+
return None, (
|
|
3332
|
+
f"{label}: multiple devices matched '{device_name}'. Please choose one."
|
|
3333
|
+
), hits
|
|
3334
|
+
|
|
3335
|
+
async def set_door_io(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
3336
|
+
"""
|
|
3337
|
+
Update 4 IO fields together in a single PUT, preserving all other fields:
|
|
3338
|
+
- entry_device_id (supports clear)
|
|
3339
|
+
- relay_output_id
|
|
3340
|
+
- exit_button_input_id
|
|
3341
|
+
- sensor_input_id
|
|
3342
|
+
|
|
3343
|
+
Accepts both ids and names. Name inputs are resolved via /api/v2/devices/search.
|
|
3344
|
+
"""
|
|
3345
|
+
try:
|
|
3346
|
+
self.check_auth()
|
|
3347
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
3348
|
+
|
|
3349
|
+
# ---- resolve door (id or name) ----
|
|
3350
|
+
door_id: Optional[int] = None
|
|
3351
|
+
if args.get("door_id") is not None:
|
|
3352
|
+
try:
|
|
3353
|
+
door_id = int(args["door_id"])
|
|
3354
|
+
except Exception:
|
|
3355
|
+
return self.error_response("Invalid 'door_id'.")
|
|
3356
|
+
elif args.get("door_name"):
|
|
3357
|
+
doors, err = await self._resolve_doors(
|
|
3358
|
+
door_ids=None,
|
|
3359
|
+
door_names=[str(args["door_name"]).strip()],
|
|
3360
|
+
headers=headers
|
|
3361
|
+
)
|
|
3362
|
+
if err or not doors:
|
|
3363
|
+
return self.error_response(err or f"Door '{args.get('door_name')}' not found.")
|
|
3364
|
+
door_id = int(doors[0]["id"])
|
|
3365
|
+
else:
|
|
3366
|
+
return self.error_response("Either 'door_id' or 'door_name' is required.")
|
|
3367
|
+
|
|
3368
|
+
# ---- GET current door detail & base preserving body ----
|
|
3369
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
3370
|
+
if raw is None:
|
|
3371
|
+
return self.error_response(f"Door {door_id} not found.")
|
|
3372
|
+
body = self._build_preserving_payload(raw, overrides={})
|
|
3373
|
+
d = body["Door"]
|
|
3374
|
+
|
|
3375
|
+
# helper to coerce 0/1/"0"/"1"/bool -> "0"/"1"
|
|
3376
|
+
def _to_01_str(x) -> Optional[str]:
|
|
3377
|
+
if x is None:
|
|
3378
|
+
return None
|
|
3379
|
+
if isinstance(x, bool):
|
|
3380
|
+
return "1" if x else "0"
|
|
3381
|
+
s = str(x).strip().lower()
|
|
3382
|
+
if s in ("1", "true", "t", "y", "yes"): return "1"
|
|
3383
|
+
if s in ("0", "false", "f", "n", "no"): return "0"
|
|
3384
|
+
return None
|
|
3385
|
+
|
|
3386
|
+
def _to_int(x, default=None):
|
|
3387
|
+
try:
|
|
3388
|
+
return int(x)
|
|
3389
|
+
except Exception:
|
|
3390
|
+
return default
|
|
3391
|
+
|
|
3392
|
+
# ===================== 1) entry_device_id =====================
|
|
3393
|
+
# precedence: entry_device_id > entry_device_name
|
|
3394
|
+
entry_id_arg = args.get("entry_device_id")
|
|
3395
|
+
entry_name_arg = args.get("entry_device_name")
|
|
3396
|
+
resolved_entry_id, err, cand = await self._resolve_device_id_from_pair(
|
|
3397
|
+
device_id=entry_id_arg,
|
|
3398
|
+
device_name=entry_name_arg,
|
|
3399
|
+
headers=headers,
|
|
3400
|
+
label="entry_device"
|
|
3401
|
+
)
|
|
3402
|
+
if err:
|
|
3403
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3404
|
+
if resolved_entry_id is not None:
|
|
3405
|
+
if resolved_entry_id == -1: # clearing sentinel
|
|
3406
|
+
d["entry_device_id"] = {}
|
|
3407
|
+
else:
|
|
3408
|
+
d["entry_device_id"] = {"id": int(resolved_entry_id)}
|
|
3409
|
+
|
|
3410
|
+
# ===================== 2) relay_output_id =====================
|
|
3411
|
+
relay_id_arg = args.get("relay_device_id")
|
|
3412
|
+
relay_name_arg = args.get("relay_device_name")
|
|
3413
|
+
relay_index = _to_int(args.get("relay_index"), None)
|
|
3414
|
+
|
|
3415
|
+
if relay_id_arg is not None or relay_name_arg or relay_index is not None:
|
|
3416
|
+
rid, err, cand = await self._resolve_device_id_from_pair(
|
|
3417
|
+
device_id=relay_id_arg,
|
|
3418
|
+
device_name=relay_name_arg,
|
|
3419
|
+
headers=headers,
|
|
3420
|
+
label="relay_device"
|
|
3421
|
+
)
|
|
3422
|
+
if err:
|
|
3423
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3424
|
+
# require both device id and relay index to set
|
|
3425
|
+
if rid is None or relay_index is None:
|
|
3426
|
+
return self.error_response("To modify relay_output_id, both 'relay_device_(id|name)' and 'relay_index' are required.")
|
|
3427
|
+
d["relay_output_id"] = {"device_id": {"id": int(rid)}, "relay_index": int(relay_index)}
|
|
3428
|
+
|
|
3429
|
+
# ===================== 3) exit_button_input_id =====================
|
|
3430
|
+
x_dev_id = args.get("exit_button_device_id")
|
|
3431
|
+
x_dev_name = args.get("exit_button_device_name")
|
|
3432
|
+
x_idx = _to_int(args.get("exit_button_input_index"), None)
|
|
3433
|
+
x_type = _to_int(args.get("exit_button_input_type"), None)
|
|
3434
|
+
|
|
3435
|
+
if x_dev_id is not None or x_dev_name or x_idx is not None or x_type is not None:
|
|
3436
|
+
xid, err, cand = await self._resolve_device_id_from_pair(
|
|
3437
|
+
device_id=x_dev_id,
|
|
3438
|
+
device_name=x_dev_name,
|
|
3439
|
+
headers=headers,
|
|
3440
|
+
label="exit_button_device"
|
|
3441
|
+
)
|
|
3442
|
+
if err:
|
|
3443
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3444
|
+
if xid is None or x_idx is None or x_type is None:
|
|
3445
|
+
return self.error_response("To modify exit_button_input_id, provide device (id|name), input_index, and input_type.")
|
|
3446
|
+
d["exit_button_input_id"] = {
|
|
3447
|
+
"device_id": {"id": int(xid)},
|
|
3448
|
+
"input_index": int(x_idx),
|
|
3449
|
+
"type": str(int(x_type))
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
# ===================== 4) sensor_input_id =====================
|
|
3453
|
+
s_dev_id = args.get("sensor_device_id")
|
|
3454
|
+
s_dev_name = args.get("sensor_device_name")
|
|
3455
|
+
s_idx = _to_int(args.get("sensor_input_index"), None)
|
|
3456
|
+
s_type = _to_int(args.get("sensor_input_type"), None)
|
|
3457
|
+
s_apb = _to_01_str(args.get("sensor_apb_use"))
|
|
3458
|
+
|
|
3459
|
+
if s_dev_id is not None or s_dev_name or s_idx is not None or s_type is not None or s_apb is not None:
|
|
3460
|
+
sid, err, cand = await self._resolve_device_id_from_pair(
|
|
3461
|
+
device_id=s_dev_id,
|
|
3462
|
+
device_name=s_dev_name,
|
|
3463
|
+
headers=headers,
|
|
3464
|
+
label="sensor_device"
|
|
3465
|
+
)
|
|
3466
|
+
if err:
|
|
3467
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3468
|
+
if sid is None or s_idx is None or s_type is None:
|
|
3469
|
+
return self.error_response("To modify sensor_input_id, provide device (id|name), input_index, and input_type.")
|
|
3470
|
+
d["sensor_input_id"] = {
|
|
3471
|
+
"device_id": {"id": int(sid)},
|
|
3472
|
+
"input_index": int(s_idx),
|
|
3473
|
+
"type": str(int(s_type))
|
|
3474
|
+
}
|
|
3475
|
+
if s_apb is not None:
|
|
3476
|
+
d["sensor_input_id"]["apb_use_door_sensor"] = s_apb
|
|
3477
|
+
|
|
3478
|
+
# ---- PUT once with preserved + patched fields ----
|
|
3479
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
3480
|
+
resp = await client.put(
|
|
3481
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
3482
|
+
headers=headers,
|
|
3483
|
+
json=body
|
|
3484
|
+
)
|
|
3485
|
+
|
|
3486
|
+
if resp.status_code not in (200, 204):
|
|
3487
|
+
return self.error_response(
|
|
3488
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
3489
|
+
{"request_body": body}
|
|
3490
|
+
)
|
|
3491
|
+
|
|
3492
|
+
changed = [k for k in ("entry_device_id", "relay_output_id", "exit_button_input_id", "sensor_input_id") if k in d]
|
|
3493
|
+
return self.success_response({
|
|
3494
|
+
"message": f"Door {door_id} IO updated via single PUT.",
|
|
3495
|
+
"door_id": door_id,
|
|
3496
|
+
"changed_fields": changed,
|
|
3497
|
+
"request_body": body
|
|
3498
|
+
})
|
|
3499
|
+
|
|
3500
|
+
except Exception as e:
|
|
3501
|
+
return await self.handle_api_error(e)
|
|
3502
|
+
|
|
3503
|
+
async def bulk_create_doors(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
3504
|
+
"""
|
|
3505
|
+
Sequentially create multiple doors by calling create_door() for each item.
|
|
3506
|
+
|
|
3507
|
+
Behavior:
|
|
3508
|
+
- Reuses the exact guarded flow of create_door() (NO auto discovery/registration/selection).
|
|
3509
|
+
- Classifies each item into: created / preview-needed / failed.
|
|
3510
|
+
- If continue_on_error is False, stops at the first failure.
|
|
3511
|
+
|
|
3512
|
+
Input:
|
|
3513
|
+
- doors: List[create-door payload]
|
|
3514
|
+
- continue_on_error: bool (default True)
|
|
3515
|
+
|
|
3516
|
+
Returns (success):
|
|
3517
|
+
{
|
|
3518
|
+
"status": "success",
|
|
3519
|
+
"message": "Processed N door(s): X created, Y preview, Z failed.",
|
|
3520
|
+
"summary": { "total": N, "created": X, "preview": Y, "failed": Z, "continue_on_error": true|false },
|
|
3521
|
+
"created": [ { "index": i, "door_id": 123, "message": "...", "request": {...} } ],
|
|
3522
|
+
"previews": [ { "index": i, "message": "Preview", "details": {...}, "request": {...} } ],
|
|
3523
|
+
"failed": [ { "index": i, "message": "...", "details": {...}, "request": {...} } ]
|
|
3524
|
+
}
|
|
3525
|
+
"""
|
|
3526
|
+
try:
|
|
3527
|
+
self.check_auth()
|
|
3528
|
+
|
|
3529
|
+
doors: List[Dict[str, Any]] = args.get("doors") or []
|
|
3530
|
+
continue_on_error: bool = bool(args.get("continue_on_error", True))
|
|
3531
|
+
|
|
3532
|
+
if not isinstance(doors, list) or not doors:
|
|
3533
|
+
return self.error_response("`doors` must be a non-empty array of 'create-door' payloads.")
|
|
3534
|
+
|
|
3535
|
+
created: List[Dict[str, Any]] = []
|
|
3536
|
+
previews: List[Dict[str, Any]] = []
|
|
3537
|
+
failed: List[Dict[str, Any]] = []
|
|
3538
|
+
|
|
3539
|
+
for idx, item in enumerate(doors, start=1):
|
|
3540
|
+
# Call the single creation method to guarantee 100% identical behavior
|
|
3541
|
+
single = await self.create_door(item)
|
|
3542
|
+
|
|
3543
|
+
# Parse BaseHandler's TextContent payload safely
|
|
3544
|
+
raw_text = single[0].text if isinstance(single, list) and single else str(single)
|
|
3545
|
+
try:
|
|
3546
|
+
parsed = json.loads(raw_text)
|
|
3547
|
+
except Exception:
|
|
3548
|
+
try:
|
|
3549
|
+
parsed = ast.literal_eval(raw_text)
|
|
3550
|
+
except Exception:
|
|
3551
|
+
parsed = {"status": "unknown", "raw": raw_text}
|
|
3552
|
+
|
|
3553
|
+
status = str(parsed.get("status") or "").lower()
|
|
3554
|
+
|
|
3555
|
+
# Classification rules:
|
|
3556
|
+
# - created: status == "success" or door_id exists
|
|
3557
|
+
# - preview: needs_confirmation == True OR relay_options present
|
|
3558
|
+
# - failed : status == "error" (or anything else not matching above)
|
|
3559
|
+
if status == "success" or ("door_id" in parsed):
|
|
3560
|
+
created.append({
|
|
3561
|
+
"index": idx,
|
|
3562
|
+
"door_id": parsed.get("door_id"),
|
|
3563
|
+
"message": parsed.get("message"),
|
|
3564
|
+
"request": item,
|
|
3565
|
+
})
|
|
3566
|
+
elif parsed.get("needs_confirmation") or ("relay_options" in parsed):
|
|
3567
|
+
previews.append({
|
|
3568
|
+
"index": idx,
|
|
3569
|
+
"message": parsed.get("message", "Preview"),
|
|
3570
|
+
"details": {k: parsed.get(k) for k in ("group_prompt", "missing_for_creation", "relay_index", "relay_options") if k in parsed},
|
|
3571
|
+
"request": item,
|
|
3572
|
+
})
|
|
3573
|
+
else:
|
|
3574
|
+
failed.append({
|
|
3575
|
+
"index": idx,
|
|
3576
|
+
"message": parsed.get("message") or parsed.get("error") or "Unknown error",
|
|
3577
|
+
"details": {k: parsed.get(k) for k in parsed.keys() if k not in ("status", "message")},
|
|
3578
|
+
"request": item,
|
|
3579
|
+
})
|
|
3580
|
+
if not continue_on_error:
|
|
3581
|
+
break
|
|
3582
|
+
|
|
3583
|
+
summary = {
|
|
3584
|
+
"total": len(doors),
|
|
3585
|
+
"created": len(created),
|
|
3586
|
+
"preview": len(previews),
|
|
3587
|
+
"failed": len(failed),
|
|
3588
|
+
"continue_on_error": continue_on_error,
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
return self.success_response({
|
|
3592
|
+
"message": f"Processed {summary['total']} door(s): {summary['created']} created, "
|
|
3593
|
+
f"{summary['preview']} preview, {summary['failed']} failed.",
|
|
3594
|
+
"summary": summary,
|
|
3595
|
+
"created": created,
|
|
3596
|
+
"previews": previews,
|
|
3597
|
+
"failed": failed,
|
|
3598
|
+
})
|
|
3599
|
+
|
|
3600
|
+
except Exception as e:
|
|
3601
|
+
return await self.handle_api_error(e)
|
|
3602
|
+
|
|
3603
|
+
async def set_door_classic_apb(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
3604
|
+
"""
|
|
3605
|
+
Set door Classic APB (Anti-Passback) mode.
|
|
3606
|
+
- apb_type: none | soft | hard (or 0/1/2)
|
|
3607
|
+
- Preserves existing reset_time and open_when_disconnected values
|
|
3608
|
+
"""
|
|
3609
|
+
try:
|
|
3610
|
+
# --- Auth check ---
|
|
3611
|
+
self.check_auth()
|
|
3612
|
+
headers = {
|
|
3613
|
+
"bs-session-id": self.get_session_id(),
|
|
3614
|
+
"Content-Type": "application/json",
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
# --- Normalize apb_type ---
|
|
3618
|
+
raw_apb_type = args.get("apb_type")
|
|
3619
|
+
if raw_apb_type is None:
|
|
3620
|
+
return self.error_response("Missing required field: apb_type")
|
|
3621
|
+
|
|
3622
|
+
if isinstance(raw_apb_type, int):
|
|
3623
|
+
apb_type_i = raw_apb_type
|
|
3624
|
+
else:
|
|
3625
|
+
s = str(raw_apb_type).strip().lower()
|
|
3626
|
+
if s in ("none", "0"):
|
|
3627
|
+
apb_type_i = 0
|
|
3628
|
+
elif s in ("soft", "1"):
|
|
3629
|
+
apb_type_i = 1
|
|
3630
|
+
elif s in ("hard", "2"):
|
|
3631
|
+
apb_type_i = 2
|
|
3632
|
+
else:
|
|
3633
|
+
return self.error_response(
|
|
3634
|
+
"Invalid apb_type. Use none/soft/hard or 0/1/2.",
|
|
3635
|
+
{"received": raw_apb_type},
|
|
3636
|
+
)
|
|
3637
|
+
|
|
3638
|
+
# --- Identify door ---
|
|
3639
|
+
if args.get("door_id") is not None:
|
|
3640
|
+
try:
|
|
3641
|
+
door_id = int(args["door_id"])
|
|
3642
|
+
except Exception:
|
|
3643
|
+
return self.error_response("door_id must be an integer")
|
|
3644
|
+
else:
|
|
3645
|
+
door_name = (args.get("door_name") or "").strip()
|
|
3646
|
+
if not door_name:
|
|
3647
|
+
return self.error_response("Either door_id or door_name is required")
|
|
3648
|
+
resolved, err = await self._resolve_doors(door_ids=None, door_names=[door_name], headers=headers)
|
|
3649
|
+
if err:
|
|
3650
|
+
return self.error_response(err)
|
|
3651
|
+
door_id = int(resolved[0]["id"])
|
|
3652
|
+
|
|
3653
|
+
# --- Fetch door detail ---
|
|
3654
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
3655
|
+
|
|
3656
|
+
# --- Build preserving payload ---
|
|
3657
|
+
body = self._build_preserving_payload(raw, overrides={})
|
|
3658
|
+
door_block = body["Door"]
|
|
3659
|
+
|
|
3660
|
+
# Update door_anti_passback with preserved values
|
|
3661
|
+
current_apb = door_block.get("door_anti_passback", {})
|
|
3662
|
+
door_block["door_anti_passback"] = {
|
|
3663
|
+
"apb_type": str(apb_type_i),
|
|
3664
|
+
"reset_time": current_apb.get("reset_time", "1440"),
|
|
3665
|
+
"open_when_disconnected": current_apb.get("open_when_disconnected", "0")
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3668
|
+
# --- PUT ---
|
|
3669
|
+
async with httpx.AsyncClient(
|
|
3670
|
+
verify=False,
|
|
3671
|
+
timeout=httpx.Timeout(10.0, read=20.0),
|
|
3672
|
+
) as client:
|
|
3673
|
+
resp = await client.put(
|
|
3674
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
3675
|
+
headers=headers,
|
|
3676
|
+
json=body,
|
|
3677
|
+
)
|
|
3678
|
+
|
|
3679
|
+
if resp.status_code not in (200, 204):
|
|
3680
|
+
return self.error_response(
|
|
3681
|
+
f"API request failed: {resp.status_code} - {resp.text}",
|
|
3682
|
+
{"request_body": body},
|
|
3683
|
+
)
|
|
3684
|
+
|
|
3685
|
+
return self.success_response({
|
|
3686
|
+
"message": "Classic APB has been set.",
|
|
3687
|
+
"door_id": door_id,
|
|
3688
|
+
"apb_type": {0: "none", 1: "soft", 2: "hard"}[apb_type_i],
|
|
3689
|
+
"request_body": body
|
|
3690
|
+
})
|
|
3691
|
+
|
|
3692
|
+
except Exception as e:
|
|
3693
|
+
return await self.handle_api_error(e)
|
|
3694
|
+
|
|
3695
|
+
async def _search_device_v2_by_name(
|
|
3696
|
+
self,
|
|
3697
|
+
*,
|
|
3698
|
+
name: str,
|
|
3699
|
+
headers: Dict[str, str],
|
|
3700
|
+
exclude_device_type_id: Optional[int] = 254
|
|
3701
|
+
) -> List[Dict[str, Any]]:
|
|
3702
|
+
"""
|
|
3703
|
+
Search registered devices by name using /api/v2/devices/search.
|
|
3704
|
+
Returns simplified rows: [{id, name, ip, device_type_id}]
|
|
3705
|
+
Tries exact and contains; falls back to /api/devices list if v2 is unavailable.
|
|
3706
|
+
"""
|
|
3707
|
+
def _simplify(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
3708
|
+
out, seen = [], set()
|
|
3709
|
+
for r in rows or []:
|
|
3710
|
+
try:
|
|
3711
|
+
did = int(r.get("id"))
|
|
3712
|
+
except Exception:
|
|
3713
|
+
continue
|
|
3714
|
+
if did in seen:
|
|
3715
|
+
continue
|
|
3716
|
+
seen.add(did)
|
|
3717
|
+
out.append({
|
|
3718
|
+
"id": did,
|
|
3719
|
+
"name": r.get("name"),
|
|
3720
|
+
"ip": ((r.get("lan") or {}).get("ip")),
|
|
3721
|
+
"device_type_id": ((r.get("device_type_id") or {}).get("id")),
|
|
3722
|
+
})
|
|
3723
|
+
return out
|
|
3724
|
+
|
|
3725
|
+
payload_base: Dict[str, Any] = {}
|
|
3726
|
+
if exclude_device_type_id is not None:
|
|
3727
|
+
payload_base["exclude_device_type_id"] = str(exclude_device_type_id)
|
|
3728
|
+
|
|
3729
|
+
endpoints = [
|
|
3730
|
+
{**payload_base, "name": name}, # exact
|
|
3731
|
+
{**payload_base, "name_contains": name}, # contains
|
|
3732
|
+
{**payload_base, "keyword": name}, # generic
|
|
3733
|
+
]
|
|
3734
|
+
|
|
3735
|
+
for p in endpoints:
|
|
3736
|
+
try:
|
|
3737
|
+
async with httpx.AsyncClient(verify=False) as client:
|
|
3738
|
+
r = await client.post(
|
|
3739
|
+
f"{self.session.config.biostar_url}/api/v2/devices/search",
|
|
3740
|
+
headers=headers,
|
|
3741
|
+
json=p
|
|
3742
|
+
)
|
|
3743
|
+
if r.status_code == 200:
|
|
3744
|
+
rows = (r.json().get("DeviceCollection") or {}).get("rows", []) or []
|
|
3745
|
+
if rows:
|
|
3746
|
+
return _simplify(rows)
|
|
3747
|
+
except Exception as ex:
|
|
3748
|
+
logger.info(f"/api/v2/devices/search failed for payload {p}: {ex}")
|
|
3749
|
+
|
|
3750
|
+
# Fallback to registered device list filtering
|
|
3751
|
+
regs = await self._list_registered_devices(headers=headers)
|
|
3752
|
+
norm = unicodedata.normalize("NFKC", name or "").strip().casefold()
|
|
3753
|
+
hits = []
|
|
3754
|
+
for r in regs:
|
|
3755
|
+
nm = unicodedata.normalize("NFKC", str(r.get("name") or "")).strip().casefold()
|
|
3756
|
+
if nm == norm or norm in nm:
|
|
3757
|
+
hits.append({
|
|
3758
|
+
"id": int(r.get("id")),
|
|
3759
|
+
"name": r.get("name"),
|
|
3760
|
+
"ip": ((r.get("lan") or {}).get("ip")),
|
|
3761
|
+
"device_type_id": ((r.get("device_type_id") or {}).get("id")),
|
|
3762
|
+
})
|
|
3763
|
+
return hits
|
|
3764
|
+
|
|
3765
|
+
async def _resolve_device_id_from_pair(
|
|
3766
|
+
self,
|
|
3767
|
+
*,
|
|
3768
|
+
device_id: Optional[int],
|
|
3769
|
+
device_name: Optional[str],
|
|
3770
|
+
headers: Dict[str, str],
|
|
3771
|
+
label: str
|
|
3772
|
+
) -> Tuple[Optional[int], Optional[str], Optional[List[Dict[str, Any]]]]:
|
|
3773
|
+
"""
|
|
3774
|
+
Resolve a device id from (id, name). Precedence: id > name.
|
|
3775
|
+
Returns (resolved_id, error_str, candidates_if_ambiguous).
|
|
3776
|
+
When multiple matches exist for name, returns error with candidates.
|
|
3777
|
+
"""
|
|
3778
|
+
if device_id is not None:
|
|
3779
|
+
try:
|
|
3780
|
+
return int(device_id), None, None
|
|
3781
|
+
except Exception:
|
|
3782
|
+
return None, f"{label}: invalid device_id.", None
|
|
3783
|
+
|
|
3784
|
+
if not device_name:
|
|
3785
|
+
return None, None, None # not provided => no change
|
|
3786
|
+
|
|
3787
|
+
# Allow clearing semantics for entry_device_name ('none', 'null', 'empty')
|
|
3788
|
+
norm = unicodedata.normalize("NFKC", device_name).strip().lower()
|
|
3789
|
+
if norm in ("none", "null", "empty", "clear"):
|
|
3790
|
+
return -1, None, None # sentinel for clearing
|
|
3791
|
+
|
|
3792
|
+
hits = await self._search_device_v2_by_name(name=device_name, headers=headers)
|
|
3793
|
+
if not hits:
|
|
3794
|
+
return None, f"{label}: device named '{device_name}' not found.", []
|
|
3795
|
+
if len(hits) == 1:
|
|
3796
|
+
return int(hits[0]["id"]), None, None
|
|
3797
|
+
|
|
3798
|
+
# ambiguous
|
|
3799
|
+
return None, (
|
|
3800
|
+
f"{label}: multiple devices matched '{device_name}'. Please choose one."
|
|
3801
|
+
), hits
|
|
3802
|
+
|
|
3803
|
+
async def set_door_io(self, args: Dict[str, Any]) -> Sequence[TextContent]:
|
|
3804
|
+
"""
|
|
3805
|
+
Update 4 IO fields together in a single PUT, preserving all other fields:
|
|
3806
|
+
- entry_device_id (supports clear)
|
|
3807
|
+
- relay_output_id
|
|
3808
|
+
- exit_button_input_id
|
|
3809
|
+
- sensor_input_id
|
|
3810
|
+
|
|
3811
|
+
Accepts both ids and names. Name inputs are resolved via /api/v2/devices/search.
|
|
3812
|
+
"""
|
|
3813
|
+
try:
|
|
3814
|
+
self.check_auth()
|
|
3815
|
+
headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
|
|
3816
|
+
|
|
3817
|
+
# ---- resolve door (id or name) ----
|
|
3818
|
+
door_id: Optional[int] = None
|
|
3819
|
+
if args.get("door_id") is not None:
|
|
3820
|
+
try:
|
|
3821
|
+
door_id = int(args["door_id"])
|
|
3822
|
+
except Exception:
|
|
3823
|
+
return self.error_response("Invalid 'door_id'.")
|
|
3824
|
+
elif args.get("door_name"):
|
|
3825
|
+
doors, err = await self._resolve_doors(
|
|
3826
|
+
door_ids=None,
|
|
3827
|
+
door_names=[str(args["door_name"]).strip()],
|
|
3828
|
+
headers=headers
|
|
3829
|
+
)
|
|
3830
|
+
if err or not doors:
|
|
3831
|
+
return self.error_response(err or f"Door '{args.get('door_name')}' not found.")
|
|
3832
|
+
door_id = int(doors[0]["id"])
|
|
3833
|
+
else:
|
|
3834
|
+
return self.error_response("Either 'door_id' or 'door_name' is required.")
|
|
3835
|
+
|
|
3836
|
+
# ---- GET current door detail & base preserving body ----
|
|
3837
|
+
raw = await self._get_door_detail(door_id, headers=headers)
|
|
3838
|
+
if raw is None:
|
|
3839
|
+
return self.error_response(f"Door {door_id} not found.")
|
|
3840
|
+
body = self._build_preserving_payload(raw, overrides={})
|
|
3841
|
+
d = body["Door"]
|
|
3842
|
+
|
|
3843
|
+
# helper to coerce 0/1/"0"/"1"/bool -> "0"/"1"
|
|
3844
|
+
def _to_01_str(x) -> Optional[str]:
|
|
3845
|
+
if x is None:
|
|
3846
|
+
return None
|
|
3847
|
+
if isinstance(x, bool):
|
|
3848
|
+
return "1" if x else "0"
|
|
3849
|
+
s = str(x).strip().lower()
|
|
3850
|
+
if s in ("1", "true", "t", "y", "yes"): return "1"
|
|
3851
|
+
if s in ("0", "false", "f", "n", "no"): return "0"
|
|
3852
|
+
return None
|
|
3853
|
+
|
|
3854
|
+
def _to_int(x, default=None):
|
|
3855
|
+
try:
|
|
3856
|
+
return int(x)
|
|
3857
|
+
except Exception:
|
|
3858
|
+
return default
|
|
3859
|
+
|
|
3860
|
+
# ===================== 1) entry_device_id =====================
|
|
3861
|
+
# precedence: entry_device_id > entry_device_name
|
|
3862
|
+
entry_id_arg = args.get("entry_device_id")
|
|
3863
|
+
entry_name_arg = args.get("entry_device_name")
|
|
3864
|
+
resolved_entry_id, err, cand = await self._resolve_device_id_from_pair(
|
|
3865
|
+
device_id=entry_id_arg,
|
|
3866
|
+
device_name=entry_name_arg,
|
|
3867
|
+
headers=headers,
|
|
3868
|
+
label="entry_device"
|
|
3869
|
+
)
|
|
3870
|
+
if err:
|
|
3871
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3872
|
+
if resolved_entry_id is not None:
|
|
3873
|
+
if resolved_entry_id == -1: # clearing sentinel
|
|
3874
|
+
d["entry_device_id"] = {}
|
|
3875
|
+
else:
|
|
3876
|
+
d["entry_device_id"] = {"id": int(resolved_entry_id)}
|
|
3877
|
+
|
|
3878
|
+
# ===================== 2) relay_output_id =====================
|
|
3879
|
+
relay_id_arg = args.get("relay_device_id")
|
|
3880
|
+
relay_name_arg = args.get("relay_device_name")
|
|
3881
|
+
relay_index = _to_int(args.get("relay_index"), None)
|
|
3882
|
+
|
|
3883
|
+
if relay_id_arg is not None or relay_name_arg or relay_index is not None:
|
|
3884
|
+
rid, err, cand = await self._resolve_device_id_from_pair(
|
|
3885
|
+
device_id=relay_id_arg,
|
|
3886
|
+
device_name=relay_name_arg,
|
|
3887
|
+
headers=headers,
|
|
3888
|
+
label="relay_device"
|
|
3889
|
+
)
|
|
3890
|
+
if err:
|
|
3891
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3892
|
+
# require both device id and relay index to set
|
|
3893
|
+
if rid is None or relay_index is None:
|
|
3894
|
+
return self.error_response("To modify relay_output_id, both 'relay_device_(id|name)' and 'relay_index' are required.")
|
|
3895
|
+
d["relay_output_id"] = {"device_id": {"id": int(rid)}, "relay_index": int(relay_index)}
|
|
3896
|
+
|
|
3897
|
+
# ===================== 3) exit_button_input_id =====================
|
|
3898
|
+
x_dev_id = args.get("exit_button_device_id")
|
|
3899
|
+
x_dev_name = args.get("exit_button_device_name")
|
|
3900
|
+
x_idx = _to_int(args.get("exit_button_input_index"), None)
|
|
3901
|
+
x_type = _to_int(args.get("exit_button_input_type"), None)
|
|
3902
|
+
|
|
3903
|
+
if x_dev_id is not None or x_dev_name or x_idx is not None or x_type is not None:
|
|
3904
|
+
xid, err, cand = await self._resolve_device_id_from_pair(
|
|
3905
|
+
device_id=x_dev_id,
|
|
3906
|
+
device_name=x_dev_name,
|
|
3907
|
+
headers=headers,
|
|
3908
|
+
label="exit_button_device"
|
|
3909
|
+
)
|
|
3910
|
+
if err:
|
|
3911
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3912
|
+
if xid is None or x_idx is None or x_type is None:
|
|
3913
|
+
return self.error_response("To modify exit_button_input_id, provide device (id|name), input_index, and input_type.")
|
|
3914
|
+
d["exit_button_input_id"] = {
|
|
3915
|
+
"device_id": {"id": int(xid)},
|
|
3916
|
+
"input_index": int(x_idx),
|
|
3917
|
+
"type": str(int(x_type))
|
|
3918
|
+
}
|
|
3919
|
+
|
|
3920
|
+
# ===================== 4) sensor_input_id =====================
|
|
3921
|
+
s_dev_id = args.get("sensor_device_id")
|
|
3922
|
+
s_dev_name = args.get("sensor_device_name")
|
|
3923
|
+
s_idx = _to_int(args.get("sensor_input_index"), None)
|
|
3924
|
+
s_type = _to_int(args.get("sensor_input_type"), None)
|
|
3925
|
+
s_apb = _to_01_str(args.get("sensor_apb_use"))
|
|
3926
|
+
|
|
3927
|
+
if s_dev_id is not None or s_dev_name or s_idx is not None or s_type is not None or s_apb is not None:
|
|
3928
|
+
sid, err, cand = await self._resolve_device_id_from_pair(
|
|
3929
|
+
device_id=s_dev_id,
|
|
3930
|
+
device_name=s_dev_name,
|
|
3931
|
+
headers=headers,
|
|
3932
|
+
label="sensor_device"
|
|
3933
|
+
)
|
|
3934
|
+
if err:
|
|
3935
|
+
return self.error_response(err, {"needs_selection": True, "candidates": cand or []})
|
|
3936
|
+
if sid is None or s_idx is None or s_type is None:
|
|
3937
|
+
return self.error_response("To modify sensor_input_id, provide device (id|name), input_index, and input_type.")
|
|
3938
|
+
d["sensor_input_id"] = {
|
|
3939
|
+
"device_id": {"id": int(sid)},
|
|
3940
|
+
"input_index": int(s_idx),
|
|
3941
|
+
"type": str(int(s_type))
|
|
3942
|
+
}
|
|
3943
|
+
if s_apb is not None:
|
|
3944
|
+
d["sensor_input_id"]["apb_use_door_sensor"] = s_apb
|
|
3945
|
+
|
|
3946
|
+
# ---- PUT once with preserved + patched fields ----
|
|
3947
|
+
async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
|
|
3948
|
+
resp = await client.put(
|
|
3949
|
+
f"{self.session.config.biostar_url}/api/doors/{door_id}",
|
|
3950
|
+
headers=headers,
|
|
3951
|
+
json=body
|
|
3952
|
+
)
|
|
3953
|
+
|
|
3954
|
+
if resp.status_code not in (200, 204):
|
|
3955
|
+
return self.error_response(
|
|
3956
|
+
f"API call failed: {resp.status_code} - {resp.text}",
|
|
3957
|
+
{"request_body": body}
|
|
3958
|
+
)
|
|
3959
|
+
|
|
3960
|
+
changed = [k for k in ("entry_device_id", "relay_output_id", "exit_button_input_id", "sensor_input_id") if k in d]
|
|
3961
|
+
return self.success_response({
|
|
3962
|
+
"message": f"Door {door_id} IO updated via single PUT.",
|
|
3963
|
+
"door_id": door_id,
|
|
3964
|
+
"changed_fields": changed,
|
|
3965
|
+
"request_body": body
|
|
3966
|
+
})
|
|
3967
|
+
|
|
3968
|
+
except Exception as e:
|
|
3969
|
+
return await self.handle_api_error(e)
|