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.
Files changed (61) hide show
  1. biostar_x_mcp_server/__init__.py +25 -0
  2. biostar_x_mcp_server/__main__.py +15 -0
  3. biostar_x_mcp_server/config.py +87 -0
  4. biostar_x_mcp_server/handlers/__init__.py +35 -0
  5. biostar_x_mcp_server/handlers/access_handler.py +2162 -0
  6. biostar_x_mcp_server/handlers/audit_handler.py +489 -0
  7. biostar_x_mcp_server/handlers/auth_handler.py +216 -0
  8. biostar_x_mcp_server/handlers/base_handler.py +228 -0
  9. biostar_x_mcp_server/handlers/card_handler.py +746 -0
  10. biostar_x_mcp_server/handlers/device_handler.py +4344 -0
  11. biostar_x_mcp_server/handlers/door_handler.py +3969 -0
  12. biostar_x_mcp_server/handlers/event_handler.py +1331 -0
  13. biostar_x_mcp_server/handlers/file_handler.py +212 -0
  14. biostar_x_mcp_server/handlers/help_web_handler.py +379 -0
  15. biostar_x_mcp_server/handlers/log_handler.py +1051 -0
  16. biostar_x_mcp_server/handlers/navigation_handler.py +109 -0
  17. biostar_x_mcp_server/handlers/occupancy_handler.py +541 -0
  18. biostar_x_mcp_server/handlers/user_handler.py +3568 -0
  19. biostar_x_mcp_server/schemas/__init__.py +21 -0
  20. biostar_x_mcp_server/schemas/access.py +158 -0
  21. biostar_x_mcp_server/schemas/audit.py +73 -0
  22. biostar_x_mcp_server/schemas/auth.py +24 -0
  23. biostar_x_mcp_server/schemas/cards.py +128 -0
  24. biostar_x_mcp_server/schemas/devices.py +496 -0
  25. biostar_x_mcp_server/schemas/doors.py +306 -0
  26. biostar_x_mcp_server/schemas/events.py +104 -0
  27. biostar_x_mcp_server/schemas/files.py +7 -0
  28. biostar_x_mcp_server/schemas/help.py +29 -0
  29. biostar_x_mcp_server/schemas/logs.py +33 -0
  30. biostar_x_mcp_server/schemas/occupancy.py +19 -0
  31. biostar_x_mcp_server/schemas/tool_response.py +29 -0
  32. biostar_x_mcp_server/schemas/users.py +166 -0
  33. biostar_x_mcp_server/server.py +335 -0
  34. biostar_x_mcp_server/session.py +221 -0
  35. biostar_x_mcp_server/tool_manager.py +172 -0
  36. biostar_x_mcp_server/tools/__init__.py +45 -0
  37. biostar_x_mcp_server/tools/access.py +510 -0
  38. biostar_x_mcp_server/tools/audit.py +227 -0
  39. biostar_x_mcp_server/tools/auth.py +59 -0
  40. biostar_x_mcp_server/tools/cards.py +269 -0
  41. biostar_x_mcp_server/tools/categories.py +197 -0
  42. biostar_x_mcp_server/tools/devices.py +1552 -0
  43. biostar_x_mcp_server/tools/doors.py +865 -0
  44. biostar_x_mcp_server/tools/events.py +305 -0
  45. biostar_x_mcp_server/tools/files.py +28 -0
  46. biostar_x_mcp_server/tools/help.py +80 -0
  47. biostar_x_mcp_server/tools/logs.py +123 -0
  48. biostar_x_mcp_server/tools/navigation.py +89 -0
  49. biostar_x_mcp_server/tools/occupancy.py +91 -0
  50. biostar_x_mcp_server/tools/users.py +1113 -0
  51. biostar_x_mcp_server/utils/__init__.py +31 -0
  52. biostar_x_mcp_server/utils/category_mapper.py +206 -0
  53. biostar_x_mcp_server/utils/decorators.py +101 -0
  54. biostar_x_mcp_server/utils/language_detector.py +51 -0
  55. biostar_x_mcp_server/utils/search.py +42 -0
  56. biostar_x_mcp_server/utils/timezone.py +122 -0
  57. suprema_biostar_mcp-1.0.1.dist-info/METADATA +163 -0
  58. suprema_biostar_mcp-1.0.1.dist-info/RECORD +61 -0
  59. suprema_biostar_mcp-1.0.1.dist-info/WHEEL +4 -0
  60. suprema_biostar_mcp-1.0.1.dist-info/entry_points.txt +2 -0
  61. 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)