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,4344 @@
1
+ import logging
2
+ from typing import Sequence, Dict, Any, List, Optional, Tuple
3
+ from mcp.types import TextContent
4
+ import httpx
5
+ from .base_handler import BaseHandler
6
+ import re
7
+ import asyncio
8
+ import json
9
+ from collections.abc import Iterable
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ TZ_MAP: Dict[str, int] = {
14
+ "(UTC-12:00)": -43200, "Eniwetok": -43200, "Kwajalein": -43200,
15
+ "(UTC-11:00)": -39600, "Midway": -39600, "Samoa": -39600,
16
+ "(UTC-10:00)": -36000, "Hawaii": -36000, "HST": -36000,
17
+ "(UTC-9:30)": -34200, "Marquesas": -34200, "French Polynesia": -34200,
18
+ "(UTC-9:00)": -32400, "Alaska": -32400, "AKST": -32400,
19
+ "(UTC-8:00)": -28800, "Pacific Standard Time": -28800, "PST": -28800,
20
+ "(UTC-7:00)": -25200, "Mountain Standard Time": -25200, "MST": -25200,
21
+ "(UTC-6:00)": -21600, "Central Standard Time": -21600, "CST": -21600, "Mexico City": -21600,
22
+ "(UTC-5:00)": -18000, "Eastern Standard Time": -18000, "EST": -18000, "Bogota": -18000, "Lima": -18000,
23
+ "(UTC-4:30)": -16200, "Venezuela": -16200,
24
+ "(UTC-4:00)": -14400, "Atlantic Standard Time": -14400, "AST": -14400, "Caracas": -14400, "La Paz": -14400,
25
+ "(UTC-3:30)": -12600, "Newfoundland": -12600,
26
+ "(UTC-3:00)": -10800, "Brazil": -10800, "Buenos Aires": -10800, "Georgetown": -10800,
27
+ "(UTC-2:00)": -7200, "Mid-Atlantic": -7200,
28
+ "(UTC-1:00)": -3600, "Azores": -3600, "Cape Verde": -3600,
29
+ "(UTC±0:00)": 0, "UTC": 0, "GMT": 0,
30
+
31
+ # East (positive)
32
+ "(UTC+1:00)": 3600, "Western Europe Time": 3600, "WET": 3600, "Lisbon": 3600, "Casablanca": 3600,
33
+ "(UTC+2:00)": 7200, "Kaliningrad": 7200, "South Africa": 7200, "SAST": 7200, "EET": 7200,
34
+ "(UTC+3:00)": 10800, "Baghdad": 10800, "Riyadh": 10800, "Moscow": 10800,
35
+ "(UTC+3:30)": 12600, "Tehran": 12600, "IRST": 12600,
36
+ "(UTC+4:00)": 14400, "Abu Dhabi": 14400, "Muscat": 14400, "Baku": 14400, "Tbilisi": 14400,
37
+ "(UTC+4:30)": 16200, "Kabul": 16200,
38
+ "(UTC+5:00)": 18000, "Yekaterinburg": 18000, "Islamabad": 18000, "Karachi": 18000, "Tashkent": 18000,
39
+ "(UTC+5:30)": 19800, "India": 19800, "New Delhi": 19800, "Colombo": 19800, "IST": 19800,
40
+ "(UTC+5:45)": 20700, "Kathmandu": 20700,
41
+ "(UTC+6:00)": 21600, "Almaty": 21600, "Dhaka": 21600,
42
+ "(UTC+6:30)": 23400, "Myanmar": 23400, "Cocos": 23400,
43
+ "(UTC+7:00)": 25200, "Bangkok": 25200, "Hanoi": 25200, "Jakarta": 25200,
44
+ "(UTC+8:00)": 28800, "Beijing": 28800, "Perth": 28800, "Singapore": 28800, "Hong Kong": 28800,
45
+ "China Standard Time": 28800,
46
+ "(UTC+8:30)": 30600, "Pyongyang": 30600,
47
+ "(UTC+8:45)": 31500, "Eucla": 31500,
48
+ "(UTC+9:00)": 32400, "Seoul": 32400, "Tokyo": 32400, "KST": 32400, "JST": 32400,
49
+ "(UTC+9:30)": 34200, "Adelaide": 34200, "Darwin": 34200,
50
+ "(UTC+10:00)": 36000, "Eastern Australia": 36000, "Guam": 36000, "Vladivostok": 36000,
51
+ "(UTC+10:30)": 37800, "Lord Howe": 37800,
52
+ "(UTC+11:00)": 39600, "Magadan": 39600, "Solomon": 39600, "New Caledonia": 39600,
53
+ "(UTC+12:00)": 43200, "Auckland": 43200, "Wellington": 43200, "Fiji": 43200, "Kamchatka": 43200,
54
+ "(UTC+12:45)": 45900, "Chatham": 45900,
55
+ "(UTC+13:00)": 46800, "Samoa (West)": 46800, "Tonga": 46800,
56
+ "(UTC+14:00)": 50400, "Kiritimati": 50400,
57
+ }
58
+
59
+ ALIASES: Dict[str, int] = {
60
+ # Common English aliases / cities
61
+ "tehran": 12600, "hawaii": -36000, "vancouver": -28800, "los angeles": -28800, "seoul": 32400,
62
+ "tokyo": 32400, "singapore": 28800, "hong kong": 28800, "bangkok": 25200, "guam": 36000,
63
+ "alaska": -32400, "american samoa": -39600, "midway": -39600, "kiritimati": 50400,
64
+ }
65
+
66
+ UTC_OFFSET_RE = re.compile(r"^(?P<sign>[+-])(?P<h>\d{1,2})(?::(?P<m>\d{2}))?$")
67
+
68
+
69
+ def _normalize_label(s: str) -> str:
70
+ """Normalize label for matching irrespective of spaces, parens, commas, hyphens, case."""
71
+ return (
72
+ s.strip().lower()
73
+ .replace("(", "").replace(")", "")
74
+ .replace(" ", "").replace(",", "").replace("-", "")
75
+ )
76
+
77
+
78
+ def _parse_utc_offset_str(s: str) -> Optional[int]:
79
+ """Parse '+09:00', '-08', '+3:30' to seconds."""
80
+ m = UTC_OFFSET_RE.match(s.strip())
81
+ if not m:
82
+ return None
83
+ h = int(m.group("h"))
84
+ mnt = int(m.group("m") or 0)
85
+ seconds = h * 3600 + mnt * 60
86
+ return -seconds if m.group("sign") == "-" else seconds
87
+
88
+
89
+ def _lookup_tz_offset(args: Dict[str, Any]) -> Tuple[Optional[int], str]:
90
+ """
91
+ Resolve offset in seconds from one of: offset_seconds, utc_offset, tz_label.
92
+ tz_label can be any English label like '(UTC+9:00) Seoul, Tokyo' or 'Pacific Standard Time', or a city/abbr.
93
+ """
94
+ if "offset_seconds" in args and isinstance(args["offset_seconds"], int):
95
+ return args["offset_seconds"], "resolved by offset_seconds"
96
+
97
+ if "utc_offset" in args and isinstance(args["utc_offset"], str):
98
+ sec = _parse_utc_offset_str(args["utc_offset"])
99
+ if sec is not None:
100
+ return sec, f"parsed from utc_offset='{args['utc_offset']}'"
101
+
102
+ if "tz_label" in args and isinstance(args["tz_label"], str):
103
+ raw = args["tz_label"].strip()
104
+
105
+ # 1) Exact key
106
+ if raw in TZ_MAP:
107
+ return TZ_MAP[raw], f"matched tz_label exact='{raw}'"
108
+
109
+ # 2) If label starts with '(UTC±hh[:mm])', parse the offset directly → language-agnostic
110
+ if raw.startswith("(") and "UTC" in raw:
111
+ inside = raw[1: raw.find(")")] # e.g. 'UTC+9:00'
112
+ off = inside.replace("UTC", "")
113
+ sec = _parse_utc_offset_str(off)
114
+ if sec is not None:
115
+ return sec, f"parsed from label parentheses='{inside}'"
116
+
117
+ # 3) Normalized lookup against our English keys
118
+ key = _normalize_label(raw)
119
+ for k, v in TZ_MAP.items():
120
+ if _normalize_label(k) == key:
121
+ return v, f"matched tz_label normalized='{raw}'"
122
+
123
+ # 4) English aliases (cities/abbrs)
124
+ al = raw.lower()
125
+ if al in ALIASES:
126
+ return ALIASES[al], f"matched alias='{raw}'"
127
+
128
+ return None, "unable to resolve timezone (provide offset_seconds, or utc_offset like '+09:00', or an English tz_label)"
129
+
130
+
131
+ class DeviceHandler(BaseHandler):
132
+ """Handle device-related operations."""
133
+
134
+ async def list_devices(self, args: Dict[str, Any]) -> Sequence[TextContent]:
135
+ """Get list of devices."""
136
+ try:
137
+ self.check_auth()
138
+
139
+ headers = {
140
+ "bs-session-id": self.get_session_id(),
141
+ "Content-Type": "application/json"
142
+ }
143
+
144
+ async with httpx.AsyncClient(verify=False) as client:
145
+ response = await client.get(
146
+ f"{self.session.config.biostar_url}/api/devices",
147
+ headers=headers
148
+ )
149
+
150
+ if response.status_code != 200:
151
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
152
+
153
+ data = response.json()
154
+ devices = data.get("DeviceCollection", {}).get("rows", [])
155
+
156
+ # Filter by group if specified
157
+ if args.get("group_id"):
158
+ devices = [d for d in devices if args["group_id"] in d.get("device_group_id", {}).get("id_list", [])]
159
+
160
+ # Filter by device type if specified
161
+ if args.get("device_type"):
162
+ devices = [d for d in devices if d.get("device_type", {}).get("name") == args["device_type"]]
163
+
164
+ return self.success_response({
165
+ "message": f"Found {len(devices)} devices",
166
+ "total": len(devices),
167
+ "devices": [self.format_device_info(device) for device in devices]
168
+ })
169
+
170
+ except Exception as e:
171
+ return await self.handle_api_error(e)
172
+
173
+ async def _fetch_all_doors(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
174
+ """GET /api/doors and return rows (best-effort on error)."""
175
+ async with httpx.AsyncClient(verify=False) as client:
176
+ resp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
177
+ if resp.status_code != 200:
178
+ return []
179
+ return (resp.json() or {}).get("DoorCollection", {}).get("rows", []) or []
180
+
181
+ # ---------- [MODIFY] get_device to use the resolver ----------
182
+ async def get_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
183
+ """Get specific device details.
184
+ Now supports resolving by device_name/device_search_text/door_name/q (+ edge/prefer_entry).
185
+ """
186
+ try:
187
+ self.check_auth()
188
+
189
+ headers = {
190
+ "bs-session-id": self.get_session_id(),
191
+ "Content-Type": "application/json"
192
+ }
193
+
194
+ # Use resolver (device_id straight-through; else name → door fallback)
195
+ resolved_id, early = await self._resolve_device_id_from_args(args, headers)
196
+ if resolved_id is None:
197
+ return early # Either needs_selection or error already formatted
198
+
199
+ device_id = resolved_id
200
+
201
+ async with httpx.AsyncClient(verify=False) as client:
202
+ response = await client.get(
203
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
204
+ headers=headers
205
+ )
206
+
207
+ if response.status_code != 200:
208
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
209
+
210
+ data = response.json()
211
+ device = data.get("Device", {})
212
+
213
+ return self.success_response({
214
+ "device": self.format_device_info(device)
215
+ })
216
+
217
+ except Exception as e:
218
+ return await self.handle_api_error(e)
219
+
220
+ async def add_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
221
+ """Add a new device to the system."""
222
+ try:
223
+ self.check_auth()
224
+
225
+ headers = {
226
+ "bs-session-id": self.get_session_id(),
227
+ "Content-Type": "application/json"
228
+ }
229
+
230
+ # Convert IP address to array format
231
+ ip_parts = args["ip_address"].split(".")
232
+ ip_array = [int(part) for part in ip_parts]
233
+
234
+ # Format IP for sorting (pad each octet to 3 digits with leading zeros)
235
+ ip_for_sorting = "".join([f"{int(part):05d}" for part in ip_parts])
236
+
237
+ device_data = {
238
+ "Device": {
239
+ "id": args["device_id"],
240
+ "name": args["name"],
241
+ "device_group_id": {
242
+ "id": args["device_group_id"],
243
+ "name": "All Devices"
244
+ },
245
+ "lan": {
246
+ "enable_dhcp": "false",
247
+ "ip": args["ip_address"],
248
+ "device_port": str(args.get("port", 51211))
249
+ },
250
+ "rs485": {
251
+ "mode": "1",
252
+ "channels": [{"index": "0", "mode": "1"}]
253
+ },
254
+ "support_ssl": str(args.get("use_ssl", True)).lower(),
255
+ "is_exist_root_cert": "false",
256
+ "system": {
257
+ "use_alphanumeric": "false"
258
+ },
259
+ "capacity": {
260
+ "support_alphanumeric": "true",
261
+ "support_ssl": str(args.get("use_ssl", True)).lower(),
262
+ "is_exist_root_cert": "false",
263
+ "fw_upgrade_required": "false"
264
+ },
265
+ "server_connected": {
266
+ "status": "disconnected"
267
+ },
268
+ "isDisabled": False,
269
+ "ip": ip_array,
270
+ "ipForSorting": ip_for_sorting,
271
+ "secureModeStatus": "Connectable.",
272
+ "status": "OK"
273
+ }
274
+ }
275
+
276
+ # Add device_type_id if provided
277
+ if "device_type_id" in args:
278
+ device_data["Device"]["device_type_id"] = {
279
+ "id": str(args["device_type_id"]),
280
+ "name": args.get("device_type_name", "")
281
+ }
282
+
283
+ async with httpx.AsyncClient(verify=False) as client:
284
+ response = await client.post(
285
+ f"{self.session.config.biostar_url}/api/devices",
286
+ headers=headers,
287
+ json=device_data
288
+ )
289
+
290
+ if response.status_code not in [200, 201]:
291
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
292
+
293
+ logger.info(
294
+ f"##################### ADD DEVICE RESPONSE: {response} #########################################################")
295
+
296
+ return self.success_response({
297
+ "message": f"Device '{args['name']}' added successfully",
298
+ "device_id": args["device_id"]
299
+ })
300
+
301
+ except Exception as e:
302
+ return await self.handle_api_error(e)
303
+
304
+ async def bulk_add_devices(self, args: Dict[str, Any]) -> Sequence[TextContent]:
305
+ """
306
+ Bulk add devices by sequentially calling the existing add_device() for each item.
307
+
308
+ Input:
309
+ - devices: [ { device_id, name, ip_address, device_group_id, (device_type_id), (device_type_name), (port), (use_ssl) }, ... ]
310
+ - continue_on_error: bool (default True)
311
+
312
+ Behavior:
313
+ - For each element, call self.add_device(item) to reuse the exact same logic.
314
+ - Aggregate per-item results (success/error) and return a summary.
315
+ """
316
+ try:
317
+ self.check_auth()
318
+ rows = args.get("devices")
319
+ if not isinstance(rows, (list, tuple)) or not rows:
320
+ return self.error_response(
321
+ "Provide 'devices' as a non-empty array of objects (same schema as 'add-device').")
322
+
323
+ continue_on_error = bool(args.get("continue_on_error", True))
324
+
325
+ import ast
326
+ results: List[Dict[str, Any]] = []
327
+ success_cnt = 0
328
+
329
+ for idx, item in enumerate(rows):
330
+ if not isinstance(item, dict):
331
+ results.append({
332
+ "index": idx,
333
+ "status": "error",
334
+ "error": "Item must be an object"
335
+ })
336
+ if not continue_on_error:
337
+ break
338
+ continue
339
+
340
+ try:
341
+ # ⬇ '완전히 동일한 로직' 보장을 위해 기존 단일 메서드를 그대로 호출
342
+ single_seq = await self.add_device(item)
343
+
344
+ # add_device()는 success_response/error_response 형태의 TextContent를 1개 반환함
345
+ parsed = None
346
+ if isinstance(single_seq, (list, tuple)) and single_seq:
347
+ t = getattr(single_seq[0], "text", None)
348
+ if isinstance(t, str) and t.strip():
349
+ try:
350
+ parsed = ast.literal_eval(t) # dict로 파싱 시도
351
+ except Exception:
352
+ parsed = {"raw": t}
353
+ else:
354
+ parsed = {"raw": single_seq}
355
+
356
+ ok = isinstance(parsed, dict) and str(parsed.get("status")) == "success"
357
+ if ok:
358
+ success_cnt += 1
359
+ results.append({
360
+ "index": idx,
361
+ "status": "success",
362
+ "device_id": parsed.get("device_id"),
363
+ "message": parsed.get("message")
364
+ })
365
+ else:
366
+ results.append({
367
+ "index": idx,
368
+ "status": "error",
369
+ "response": parsed
370
+ })
371
+ if not continue_on_error:
372
+ break
373
+
374
+ except Exception as e:
375
+ results.append({
376
+ "index": idx,
377
+ "status": "error",
378
+ "error": f"{type(e).__name__}: {e}"
379
+ })
380
+ if not continue_on_error:
381
+ break
382
+
383
+ return self.success_response({
384
+ "message": f"Bulk add attempted for {len(results)} item(s).",
385
+ "success": success_cnt,
386
+ "failed": len(results) - success_cnt,
387
+ "results": results
388
+ })
389
+
390
+ except Exception as e:
391
+ return await self.handle_api_error(e)
392
+
393
+ async def update_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
394
+ """Update device configuration."""
395
+ try:
396
+ self.check_auth()
397
+
398
+ device_id = args["device_id"]
399
+
400
+ headers = {
401
+ "bs-session-id": self.get_session_id(),
402
+ "Content-Type": "application/json"
403
+ }
404
+
405
+ # First get existing device data
406
+ async with httpx.AsyncClient(verify=False) as client:
407
+ get_response = await client.get(
408
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
409
+ headers=headers
410
+ )
411
+
412
+ if get_response.status_code != 200:
413
+ return self.error_response(f"Failed to get device: {get_response.status_code}")
414
+
415
+ device_data = get_response.json()
416
+
417
+ # Update only provided fields
418
+ if "name" in args:
419
+ device_data["Device"]["name"] = args["name"]
420
+ if "description" in args:
421
+ device_data["Device"]["description"] = args["description"]
422
+ if "time_zone" in args:
423
+ device_data["Device"]["time_zone"] = args["time_zone"]
424
+ if "volume" in args:
425
+ device_data["Device"]["volume"] = args["volume"]
426
+
427
+ # Update the device
428
+ update_response = await client.put(
429
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
430
+ headers=headers,
431
+ json=device_data
432
+ )
433
+
434
+ if update_response.status_code != 200:
435
+ return self.error_response(f"API call failed: {update_response.status_code} - {update_response.text}")
436
+
437
+ return self.success_response({
438
+ "message": f"Device {device_id} updated successfully"
439
+ })
440
+
441
+ except Exception as e:
442
+ return await self.handle_api_error(e)
443
+
444
+ async def reboot_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
445
+ """
446
+ Restart device(s) via POST /api/devices/reset.
447
+
448
+ STRICT SELECTION RULES:
449
+ - device_names: exact (case-sensitive) match ONLY. No autocorrect, no fuzzy matching.
450
+ * 0 match -> NO ACTION. Return full list and needs_selection=true.
451
+ * >1 match -> NO ACTION. Return candidates and needs_selection=true.
452
+ * 1 match -> use that id.
453
+ - device_ids: validate existence. If any unknown id is included -> NO ACTION (return available devices).
454
+ - device_search_text: optional substring browse. If provided without an exact selection,
455
+ return candidates ONLY (no action).
456
+ - If both device_ids and device_names are provided, the resolved id sets must be IDENTICAL.
457
+ - If multiple devices will be restarted, confirm_multi=true is REQUIRED.
458
+
459
+ SUCCESS CRITERIA (double-check):
460
+ - HTTP 200
461
+ - AND (Response.code == "0" if present)
462
+ - AND every DeviceResponse.rows[*].code == "0"
463
+ """
464
+ try:
465
+ self.check_auth()
466
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
467
+
468
+ # ---- Parse inputs ----
469
+ raw_ids = args.get("device_ids")
470
+ legacy_id = args.get("device_id")
471
+ raw_names = args.get("device_names")
472
+ search_text = (args.get("device_search_text") or "").strip()
473
+ confirm_multi = bool(args.get("confirm_multi", False))
474
+
475
+ # Normalize ids (support scalar)
476
+ ids_from_args = []
477
+ if raw_ids is not None and not isinstance(raw_ids, (list, tuple, set)):
478
+ raw_ids = [raw_ids]
479
+ if isinstance(raw_ids, (list, tuple, set)):
480
+ for x in raw_ids:
481
+ try:
482
+ v = int(str(x).strip())
483
+ if v not in ids_from_args:
484
+ ids_from_args.append(v)
485
+ except Exception:
486
+ continue
487
+ if legacy_id is not None:
488
+ try:
489
+ v = int(str(legacy_id).strip())
490
+ if v not in ids_from_args:
491
+ ids_from_args.append(v)
492
+ except Exception:
493
+ pass
494
+
495
+ # Normalize names (support scalar)
496
+ names_from_args = []
497
+ if raw_names is not None and not isinstance(raw_names, (list, tuple, set)):
498
+ raw_names = [raw_names]
499
+ if isinstance(raw_names, (list, tuple, set)):
500
+ names_from_args = [str(n) for n in raw_names]
501
+
502
+ # Browse-only path: no exact selection, but search text provided
503
+ if not ids_from_args and not names_from_args and search_text:
504
+ all_rows = await self._fetch_all_devices(headers)
505
+ q = search_text.lower()
506
+ candidates = [
507
+ self.format_device_info(d) for d in all_rows
508
+ if q in str(d.get("name") or "").lower()
509
+ ]
510
+ return self.success_response({
511
+ "message": f"No exact selection given. Found {len(candidates)} candidates for '{search_text}'. Please choose exact device_names or device_ids.",
512
+ "status": "needs_selection",
513
+ "needs_selection": True,
514
+ "candidates": candidates
515
+ })
516
+
517
+ # No targets at all
518
+ if not ids_from_args and not names_from_args:
519
+ return self.error_response(
520
+ "No targets provided. Use 'device_names' (exact) or 'device_ids', or use 'device_search_text' to browse candidates."
521
+ )
522
+
523
+ # Load all devices once for validation
524
+ all_rows = await self._fetch_all_devices(headers)
525
+
526
+ # Build indexes
527
+ known_id_set = set()
528
+ by_exact_name = {}
529
+ for d in all_rows:
530
+ did = d.get("id")
531
+ try:
532
+ known_id_set.add(int(did))
533
+ except Exception:
534
+ pass
535
+ nm = str(d.get("name") or "")
536
+ by_exact_name.setdefault(nm, []).append(d)
537
+
538
+ # Resolve names strictly (case-sensitive equals)
539
+ resolved_from_names = []
540
+ not_found_names = []
541
+ ambiguous_names = []
542
+ for nm in names_from_args:
543
+ exact = by_exact_name.get(nm, [])
544
+ if len(exact) == 0:
545
+ not_found_names.append(nm)
546
+ elif len(exact) > 1:
547
+ ambiguous_names.append({
548
+ "name": nm,
549
+ "candidates": [self.format_device_info(r) for r in exact]
550
+ })
551
+ else:
552
+ try:
553
+ resolved_from_names.append(int(exact[0].get("id")))
554
+ except Exception:
555
+ not_found_names.append(nm)
556
+
557
+ # Hard-stop if names are unresolved or ambiguous
558
+ if not_found_names or ambiguous_names:
559
+ return self.error_response(
560
+ "Strict device name match failed; explicit selection required.",
561
+ {
562
+ "status": "needs_selection",
563
+ "unresolved_names": not_found_names,
564
+ "ambiguous": ambiguous_names,
565
+ "available_devices": [self.format_device_info(r) for r in all_rows]
566
+ }
567
+ )
568
+
569
+ # Validate ids existence
570
+ unknown_ids = [i for i in ids_from_args if i not in known_id_set]
571
+ if unknown_ids:
572
+ return self.error_response(
573
+ "Unknown device id(s). No action performed.",
574
+ {
575
+ "status": "unknown_ids",
576
+ "unknown_ids": unknown_ids,
577
+ "available_devices": [self.format_device_info(r) for r in all_rows]
578
+ }
579
+ )
580
+
581
+ # If both ids and names provided, sets must match exactly
582
+ if ids_from_args and resolved_from_names:
583
+ if set(ids_from_args) != set(resolved_from_names):
584
+ return self.error_response(
585
+ "device_ids and device_names do not resolve to the same set. No action performed.",
586
+ {
587
+ "status": "mismatched_selection",
588
+ "ids_from_device_ids": sorted(set(ids_from_args)),
589
+ "ids_from_device_names": sorted(set(resolved_from_names))
590
+ }
591
+ )
592
+
593
+ # Final target list
594
+ final_ids = sorted(list(set(ids_from_args or resolved_from_names)))
595
+
596
+ # Multi-target safety
597
+ if len(final_ids) > 1 and not confirm_multi:
598
+ return self.error_response(
599
+ "Multiple devices requested but confirm_multi=false.",
600
+ {"requested_device_ids": final_ids}
601
+ )
602
+
603
+ # Build payload and call API
604
+ payload = {
605
+ "DeviceCollection": {
606
+ "rows": [{"id": did} for did in final_ids],
607
+ "total": len(final_ids)
608
+ }
609
+ }
610
+ async with httpx.AsyncClient(verify=False) as client:
611
+ resp = await client.post(
612
+ f"{self.session.config.biostar_url}/api/devices/reset",
613
+ headers=headers,
614
+ json=payload
615
+ )
616
+
617
+ if resp.status_code != 200:
618
+ return self.error_response(
619
+ f"API call failed: {resp.status_code} - {resp.text}",
620
+ {"request_body": payload}
621
+ )
622
+
623
+ # Parse and evaluate success
624
+ try:
625
+ body = resp.json() or {}
626
+ except Exception:
627
+ body = {}
628
+
629
+ device_resp = body.get("DeviceResponse") or {}
630
+ rows = device_resp.get("rows") or []
631
+ response_block = body.get("Response") or {}
632
+ overall_code = str(response_block.get("code")) if response_block.get("code") is not None else None
633
+
634
+ success_ids = []
635
+ failures = []
636
+ for r in rows:
637
+ rid = r.get("id")
638
+ code = r.get("code")
639
+ try:
640
+ rid_int = int(str(rid))
641
+ except Exception:
642
+ rid_int = None
643
+ if str(code) == "0" and rid_int is not None:
644
+ success_ids.append(rid_int)
645
+ else:
646
+ failures.append({"id": rid, "code": code})
647
+
648
+ overall_ok = (overall_code in (None, "0")) and (len(failures) == 0)
649
+
650
+ summary = {
651
+ "requested": final_ids,
652
+ "success_ids": sorted(success_ids),
653
+ "failed": failures,
654
+ "device_response": device_resp,
655
+ "response_block": response_block,
656
+ "request_body": payload
657
+ }
658
+
659
+ if overall_ok:
660
+ return self.success_response({"message": f"Restart executed: {len(success_ids)} success", **summary})
661
+ else:
662
+ if response_block.get("message"):
663
+ summary["response_message"] = response_block.get("message")
664
+ return self.error_response("Restart completed with errors.", summary)
665
+
666
+ except Exception as e:
667
+ return await self.handle_api_error(e)
668
+
669
+ async def get_device_status(self, args: Dict[str, Any]) -> Sequence[TextContent]:
670
+ """Get device status and health."""
671
+ try:
672
+ self.check_auth()
673
+
674
+ device_id = args["device_id"]
675
+
676
+ headers = {
677
+ "bs-session-id": self.get_session_id(),
678
+ "Content-Type": "application/json"
679
+ }
680
+
681
+ async with httpx.AsyncClient(verify=False) as client:
682
+ response = await client.get(
683
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/status",
684
+ headers=headers
685
+ )
686
+
687
+ if response.status_code != 200:
688
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
689
+
690
+ data = response.json()
691
+ status = data.get("DeviceStatus", {})
692
+
693
+ return self.success_response({
694
+ "device_id": device_id,
695
+ "status": {
696
+ "is_online": status.get("is_online", False),
697
+ "is_locked": status.get("is_locked", False),
698
+ "last_seen": status.get("last_seen"),
699
+ "cpu_usage": status.get("cpu_usage"),
700
+ "memory_usage": status.get("memory_usage"),
701
+ "storage_usage": status.get("storage_usage"),
702
+ "temperature": status.get("temperature")
703
+ }
704
+ })
705
+
706
+ except Exception as e:
707
+ return await self.handle_api_error(e)
708
+
709
+ async def scan_devices(self, args: Dict[str, Any]) -> Sequence[TextContent]:
710
+ """Scan network for devices."""
711
+ try:
712
+ self.check_auth()
713
+
714
+ headers = {
715
+ "bs-session-id": self.get_session_id(),
716
+ "Content-Type": "application/json"
717
+ }
718
+
719
+ scan_data = {
720
+ "network_range": args.get("network_range", ""),
721
+ "port": args.get("port", 51211)
722
+ }
723
+
724
+ async with httpx.AsyncClient(verify=False) as client:
725
+ response = await client.post(
726
+ f"{self.session.config.biostar_url}/api/devices/scan",
727
+ headers=headers,
728
+ json=scan_data
729
+ )
730
+
731
+ if response.status_code != 200:
732
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
733
+
734
+ data = response.json()
735
+ discovered_devices = data.get("discovered_devices", [])
736
+
737
+ return self.success_response({
738
+ "message": f"Found {len(discovered_devices)} devices on network",
739
+ "devices": discovered_devices
740
+ })
741
+
742
+ except Exception as e:
743
+ return await self.handle_api_error(e)
744
+
745
+ async def sync_device_time(self, args: Dict[str, Any]) -> Sequence[TextContent]:
746
+ """Sync device time with server."""
747
+ try:
748
+ self.check_auth()
749
+
750
+ device_id = args["device_id"]
751
+
752
+ headers = {
753
+ "bs-session-id": self.get_session_id(),
754
+ "Content-Type": "application/json"
755
+ }
756
+
757
+ async with httpx.AsyncClient(verify=False) as client:
758
+ response = await client.post(
759
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/sync_time",
760
+ headers=headers
761
+ )
762
+
763
+ if response.status_code != 200:
764
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
765
+
766
+ return self.success_response({
767
+ "message": f"Device {device_id} time synchronized successfully"
768
+ })
769
+
770
+ except Exception as e:
771
+ return await self.handle_api_error(e)
772
+
773
+ async def clear_device_log(self, args: Dict[str, Any]) -> Sequence[TextContent]:
774
+ """Clear device logs."""
775
+ try:
776
+ self.check_auth()
777
+
778
+ device_id = args["device_id"]
779
+ log_type = args.get("log_type", "all")
780
+
781
+ headers = {
782
+ "bs-session-id": self.get_session_id(),
783
+ "Content-Type": "application/json"
784
+ }
785
+
786
+ clear_data = {
787
+ "log_type": log_type
788
+ }
789
+
790
+ async with httpx.AsyncClient(verify=False) as client:
791
+ response = await client.post(
792
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/clear_log",
793
+ headers=headers,
794
+ json=clear_data
795
+ )
796
+
797
+ if response.status_code != 200:
798
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
799
+
800
+ return self.success_response({
801
+ "message": f"Device {device_id} logs cleared successfully (type: {log_type})"
802
+ })
803
+
804
+ except Exception as e:
805
+ return await self.handle_api_error(e)
806
+
807
+ async def lock_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
808
+ """Lock a device."""
809
+ try:
810
+ self.check_auth()
811
+
812
+ device_id = args["device_id"]
813
+
814
+ headers = {
815
+ "bs-session-id": self.get_session_id(),
816
+ "Content-Type": "application/json"
817
+ }
818
+
819
+ async with httpx.AsyncClient(verify=False) as client:
820
+ response = await client.post(
821
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/lock",
822
+ headers=headers
823
+ )
824
+
825
+ if response.status_code != 200:
826
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
827
+
828
+ return self.success_response({
829
+ "message": f"Device {device_id} locked successfully"
830
+ })
831
+
832
+ except Exception as e:
833
+ return await self.handle_api_error(e)
834
+
835
+ async def unlock_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
836
+ """Unlock a device."""
837
+ try:
838
+ self.check_auth()
839
+
840
+ device_id = args["device_id"]
841
+
842
+ headers = {
843
+ "bs-session-id": self.get_session_id(),
844
+ "Content-Type": "application/json"
845
+ }
846
+
847
+ async with httpx.AsyncClient(verify=False) as client:
848
+ response = await client.post(
849
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/unlock",
850
+ headers=headers
851
+ )
852
+
853
+ if response.status_code != 200:
854
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
855
+
856
+ return self.success_response({
857
+ "message": f"Device {device_id} unlocked successfully"
858
+ })
859
+
860
+ except Exception as e:
861
+ return await self.handle_api_error(e)
862
+
863
+ async def disconnect_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
864
+ """Disconnect a device from the BioStar 2 server."""
865
+ try:
866
+ self.check_auth()
867
+
868
+ device_id = args["device_id"]
869
+
870
+ headers = {
871
+ "bs-session-id": self.session.session_id,
872
+ "Content-Type": "application/json"
873
+ }
874
+
875
+ async with httpx.AsyncClient(verify=False) as client:
876
+ response = await client.post(
877
+ f"{self.session.config.biostar_url}/api/devices/{device_id}/disconnect",
878
+ headers=headers
879
+ )
880
+
881
+ if response.status_code != 200:
882
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
883
+
884
+ return self.success_response({
885
+ "message": f"Device {device_id} disconnected successfully"
886
+ })
887
+
888
+ except Exception as e:
889
+ return await self.handle_api_error(e)
890
+
891
+ async def remove_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
892
+ """Remove a device from the BioStar 2 system."""
893
+ try:
894
+ self.check_auth()
895
+
896
+ device_id = args["device_id"]
897
+
898
+ headers = {
899
+ "bs-session-id": self.session.session_id,
900
+ "Content-Type": "application/json"
901
+ }
902
+
903
+ async with httpx.AsyncClient(verify=False) as client:
904
+ response = await client.delete(
905
+ f"{self.session.config.biostar_url}/api/devices?id={device_id}",
906
+ headers=headers
907
+ )
908
+
909
+ if response.status_code not in [200, 204]:
910
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
911
+
912
+ return self.success_response({
913
+ "message": f"Device {device_id} removed successfully"
914
+ })
915
+
916
+ except Exception as e:
917
+ return await self.handle_api_error(e)
918
+
919
+ async def udp_search(self, args: Dict[str, Any]) -> Sequence[TextContent]:
920
+ """Search for devices using UDP broadcast."""
921
+ try:
922
+ self.check_auth()
923
+
924
+ timeout = args.get("timeout", 5)
925
+
926
+ headers = {
927
+ "bs-session-id": self.get_session_id(),
928
+ "Content-Type": "application/json"
929
+ }
930
+
931
+ search_data = {
932
+ "timeout": timeout,
933
+ "with_rs485": True
934
+ }
935
+
936
+ async with httpx.AsyncClient(verify=False) as client:
937
+ response = await client.post(
938
+ f"{self.session.config.biostar_url}/api/devices/udp_search",
939
+ headers=headers,
940
+ json=search_data,
941
+ timeout=timeout + 5 # Give extra time for the API call
942
+ )
943
+
944
+ if response.status_code != 200:
945
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
946
+
947
+ data = response.json()
948
+ discovered_devices = data.get("DeviceCollection", {}).get("rows", [])
949
+
950
+ # 연결되지 않은 장치만 필터링 (server_connected.status가 "disconnected"인 장치)
951
+ disconnected_devices = [
952
+ device for device in discovered_devices
953
+ if device.get("server_connected", {}).get("status") == "disconnected"
954
+ ]
955
+
956
+ logger.info(
957
+ f"UDP search found {len(discovered_devices)} total devices, {len(disconnected_devices)} disconnected devices")
958
+
959
+ return self.success_response({
960
+ "message": f"UDP search found {len(disconnected_devices)} disconnected devices (out of {len(discovered_devices)} total)",
961
+ "total_discovered": len(discovered_devices),
962
+ "total_disconnected": len(disconnected_devices),
963
+ "devices": [
964
+ {
965
+ "device_id": d.get("id"),
966
+ "device_type": d.get("device_type_id", {}).get("name", "Unknown"),
967
+ "device_type_id": d.get("device_type_id", {}).get("id"),
968
+ "ip_address": d.get("lan", {}).get("ip"),
969
+ "port": d.get("lan", {}).get("device_port"),
970
+ "dhcp_enabled": d.get("lan", {}).get("enable_dhcp") == "true",
971
+ "support_ssl": d.get("support_ssl") == "true",
972
+ "server_connected": d.get("server_connected", {}).get("status"),
973
+ "server_ip": d.get("server_connected", {}).get("ip_addr"),
974
+ "rs485_mode": d.get("rs485", {}).get("mode"),
975
+ "use_alphanumeric": d.get("system", {}).get("use_alphanumeric") == "true"
976
+ }
977
+ for d in disconnected_devices
978
+ ]
979
+ })
980
+
981
+ except Exception as e:
982
+ return await self.handle_api_error(e)
983
+
984
+ async def tcp_search(self, args: Dict[str, Any]) -> Sequence[TextContent]:
985
+ """
986
+ Search for a specific device using TCP connection.
987
+ This is the preferred method when you know the device IP address.
988
+ Use TCP search BEFORE UDP search for more reliable connection.
989
+ """
990
+ try:
991
+ self.check_auth()
992
+
993
+ ip_address = args["ip_address"]
994
+ port = args.get("port", 51211)
995
+ timeout = args.get("timeout", 10)
996
+ retry_count = args.get("retry_count", 2)
997
+
998
+ headers = {
999
+ "bs-session-id": self.get_session_id(),
1000
+ "Content-Type": "application/json"
1001
+ }
1002
+
1003
+ # TCP Search payload format as specified by user
1004
+ search_data = {
1005
+ "Device": {
1006
+ "lan": {
1007
+ "ip": ip_address,
1008
+ "device_port": str(port)
1009
+ }
1010
+ },
1011
+ "FollowerServer": {
1012
+ "id": "1"
1013
+ }
1014
+ }
1015
+
1016
+ last_error = None
1017
+ for attempt in range(retry_count + 1):
1018
+ try:
1019
+ logger.info(f"TCP Search attempt {attempt + 1}/{retry_count + 1} for {ip_address}:{port}")
1020
+
1021
+ async with httpx.AsyncClient(verify=False) as client:
1022
+ response = await client.post(
1023
+ f"{self.session.config.biostar_url}/api/devices/tcp_search",
1024
+ headers=headers,
1025
+ json=search_data,
1026
+ timeout=timeout
1027
+ )
1028
+
1029
+ if response.status_code in [200, 201]:
1030
+ logger.info(f" TCP Search successful for {ip_address}:{port}")
1031
+
1032
+ # Parse response to get device info
1033
+ data = response.json()
1034
+ logger.info(f" TCP Search Response: {json.dumps(data, indent=2)}")
1035
+
1036
+ # Extract device from DeviceCollection.rows[0]
1037
+ device_collection = data.get("DeviceCollection", {})
1038
+ rows = device_collection.get("rows", [])
1039
+ if not rows:
1040
+ return self.error_response({
1041
+ "message": f"No device found at {ip_address}:{port}",
1042
+ "method": "TCP Search"
1043
+ })
1044
+
1045
+ device_info = rows[0]
1046
+ device_id = device_info.get("id")
1047
+ device_type_id = device_info.get("device_type_id", {}).get("id")
1048
+
1049
+ # Device name from TCP search (use as-is, don't modify!)
1050
+ device_name = device_info.get("name")
1051
+ if not device_name:
1052
+ # If no name in response, use device ID
1053
+ device_name = f"Device {device_id}"
1054
+
1055
+ return self.success_response({
1056
+ "message": f"Successfully connected to device at {ip_address}:{port}",
1057
+ "method": "TCP Search",
1058
+ "device": {
1059
+ "device_id": device_id,
1060
+ "name": device_name,
1061
+ "device_type_id": device_type_id,
1062
+ "ip_address": ip_address,
1063
+ "port": port,
1064
+ "full_info": device_info
1065
+ }
1066
+ })
1067
+
1068
+ last_error = f"HTTP {response.status_code}: {response.text}"
1069
+
1070
+ except Exception as e:
1071
+ last_error = str(e)
1072
+ logger.warning(f" TCP Search attempt {attempt + 1} failed: {e}")
1073
+
1074
+ # Wait before retry (except on last attempt)
1075
+ if attempt < retry_count:
1076
+ await asyncio.sleep(2)
1077
+
1078
+ # All attempts failed
1079
+ logger.error(f" TCP Search failed after {retry_count + 1} attempts for {ip_address}:{port}")
1080
+ return self.error_response({
1081
+ "message": f"Failed to connect to device at {ip_address}:{port} after {retry_count + 1} attempts",
1082
+ "method": "TCP Search",
1083
+ "last_error": last_error,
1084
+ "suggestion": "Try using udp-search instead, or verify the IP address and port are correct"
1085
+ })
1086
+
1087
+ except Exception as e:
1088
+ return await self.handle_api_error(e)
1089
+
1090
+ # Device Group methods
1091
+ async def get_device_groups(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1092
+ """
1093
+ List device groups via POST /api/v2/device_groups/search.
1094
+ - Input:
1095
+ • group_search_text (optional): case-insensitive substring filter on name
1096
+ - Output per group:
1097
+ • id, name, depth, parent_id, is_root, is_top_level
1098
+ - Notes:
1099
+ • This endpoint does NOT return device counts. Do not infer counts here.
1100
+ • Root group: id==1 or depth==0
1101
+ • Top-level group: parent_id==1
1102
+ """
1103
+ try:
1104
+ self.check_auth()
1105
+
1106
+ headers = {
1107
+ "bs-session-id": self.get_session_id(),
1108
+ "Content-Type": "application/json"
1109
+ }
1110
+
1111
+ payload = {"order_by": "depth:false"}
1112
+
1113
+ async with httpx.AsyncClient(verify=False) as client:
1114
+ resp = await client.post(
1115
+ f"{self.session.config.biostar_url}/api/v2/device_groups/search",
1116
+ headers=headers,
1117
+ json=payload
1118
+ )
1119
+
1120
+ if resp.status_code != 200:
1121
+ return self.error_response(f"API call failed: {resp.status_code} - {resp.text}")
1122
+
1123
+ body = resp.json() or {}
1124
+ rows = (body.get("DeviceGroupCollection") or {}).get("rows", []) or []
1125
+
1126
+ group_search_text = (args.get("group_search_text") or "").strip().lower()
1127
+
1128
+ out = []
1129
+ for g in rows:
1130
+ gid = self._to_int_safe(g.get("id"))
1131
+ depth = self._to_int_safe(g.get("depth"))
1132
+ pid = self._to_int_safe((g.get("parent_id") or {}).get("id"))
1133
+
1134
+ item = {
1135
+ "id": gid,
1136
+ "name": g.get("name"),
1137
+ "depth": depth,
1138
+ "parent_id": pid,
1139
+ "is_root": (gid == 1 or depth == 0),
1140
+ "is_top_level": (pid == 1)
1141
+ }
1142
+ out.append(item)
1143
+
1144
+ if group_search_text:
1145
+ out = [r for r in out if group_search_text in str(r.get("name") or "").lower()]
1146
+
1147
+ return self.success_response({
1148
+ "message": f"Found {len(out)} device groups (v2 search)",
1149
+ "total": len(out),
1150
+ "groups": out
1151
+ })
1152
+
1153
+ except Exception as e:
1154
+ return await self.handle_api_error(e)
1155
+
1156
+ async def get_device_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1157
+ """
1158
+ Fetch a device group by STRICT selection:
1159
+ - Accept one of:
1160
+ • group_id (preferred)
1161
+ • group_name (EXACT, case-sensitive; no fuzzy)
1162
+ • group_search_text (substring browse; returns candidates ONLY)
1163
+ - Branch:
1164
+ • 0 match -> NO ACTION. Return all available groups (compact) and needs_selection=true.
1165
+ • 1 match -> GET /api/device_groups/{id} and return detailed shape (devices + children + is_top_level).
1166
+ • >1 match -> NO ACTION. Return candidates (id/name/depth/parent) and needs_selection=true.
1167
+ """
1168
+ try:
1169
+ self.check_auth()
1170
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
1171
+
1172
+ group_id = args.get("group_id")
1173
+ group_name = args.get("group_name")
1174
+ group_search_text = (args.get("group_search_text") or "").strip()
1175
+
1176
+ # --- Case 1: direct by id ---
1177
+ if group_id is not None:
1178
+ gid = int(group_id)
1179
+ detail = await self._fetch_group_detail(gid, headers)
1180
+ if detail is None:
1181
+ return self.error_response(f"Device group id {gid} not found.")
1182
+ return self.success_response(self._format_group_detail(detail))
1183
+
1184
+ # Load all groups (for name/substring resolution)
1185
+ all_groups = await self._fetch_all_device_groups(headers)
1186
+
1187
+ # --- Case 2: browse by substring only (no exact selection) ---
1188
+ if not group_name and group_search_text:
1189
+ q = group_search_text.lower()
1190
+ candidates = [
1191
+ self._compact_group_row(g) for g in all_groups
1192
+ if q in str(g.get("name") or "").lower()
1193
+ ]
1194
+ return self.success_response({
1195
+ "message": f"Found {len(candidates)} candidate group(s) for '{group_search_text}'. Please choose one.",
1196
+ "status": "needs_selection",
1197
+ "needs_selection": True,
1198
+ "candidates": candidates
1199
+ })
1200
+
1201
+ # --- Case 3: strict exact name (case-sensitive) ---
1202
+ if group_name:
1203
+ exact = [g for g in all_groups if str(g.get("name", "")) == group_name]
1204
+
1205
+ if len(exact) == 0:
1206
+ return self.error_response(
1207
+ "No device group matched exactly. Please choose one from the list.",
1208
+ {
1209
+ "status": "group_not_found",
1210
+ "needs_selection": True,
1211
+ "available_groups": [self._compact_group_row(g) for g in all_groups]
1212
+ }
1213
+ )
1214
+
1215
+ if len(exact) > 1:
1216
+ return self.error_response(
1217
+ "Multiple device groups share the same exact name. Please choose one by id.",
1218
+ {
1219
+ "status": "ambiguous_group",
1220
+ "needs_selection": True,
1221
+ "candidates": [self._compact_group_row(g) for g in exact]
1222
+ }
1223
+ )
1224
+
1225
+ gid = int(exact[0].get("id"))
1226
+ detail = await self._fetch_group_detail(gid, headers)
1227
+ if detail is None:
1228
+ return self.error_response(f"Device group id {gid} not found (detail).")
1229
+ return self.success_response(self._format_group_detail(detail))
1230
+
1231
+ # --- No selection input at all ---
1232
+ return self.error_response(
1233
+ "Provide one of: group_id, group_name (exact), or group_search_text (browse)."
1234
+ )
1235
+
1236
+ except Exception as e:
1237
+ return await self.handle_api_error(e)
1238
+
1239
+ # ------------------------ helpers for device group detail ------------------------
1240
+
1241
+ async def _fetch_all_device_groups(self, headers: Dict[str, str]) -> List[Dict[str, Any]]:
1242
+ """GET /api/device_groups → return rows."""
1243
+ try:
1244
+ async with httpx.AsyncClient(verify=False) as client:
1245
+ r = await client.get(f"{self.session.config.biostar_url}/api/device_groups", headers=headers)
1246
+ if r.status_code != 200:
1247
+ return []
1248
+ data = r.json() or {}
1249
+ return (data.get("DeviceGroupCollection") or {}).get("rows", []) or []
1250
+ except Exception:
1251
+ return []
1252
+
1253
+ async def _fetch_group_detail(self, gid: int, headers: Dict[str, str]) -> Optional[Dict[str, Any]]:
1254
+ """GET /api/device_groups/{id} → return DeviceGroup dict or None."""
1255
+ try:
1256
+ async with httpx.AsyncClient(verify=False) as client:
1257
+ r = await client.get(f"{self.session.config.biostar_url}/api/device_groups/{gid}", headers=headers)
1258
+ if r.status_code != 200:
1259
+ return None
1260
+ body = r.json() or {}
1261
+ return body.get("DeviceGroup") or None
1262
+ except Exception:
1263
+ return None
1264
+
1265
+ def _compact_group_row(self, g: Dict[str, Any]) -> Dict[str, Any]:
1266
+ """Compact shape for selection lists."""
1267
+ pid = (g.get("parent_id") or {}).get("id")
1268
+ return {
1269
+ "id": self._to_int_safe(g.get("id")),
1270
+ "name": g.get("name"),
1271
+ "depth": self._to_int_safe(g.get("depth")),
1272
+ "parent_id": self._to_int_safe(pid),
1273
+ }
1274
+
1275
+ def _format_group_detail(self, dg: Dict[str, Any]) -> Dict[str, Any]:
1276
+ """Normalize DeviceGroup detail into a friendly, explicit structure."""
1277
+ gid = self._to_int_safe(dg.get("id"))
1278
+ name = dg.get("name")
1279
+ desc = dg.get("description")
1280
+ depth = self._to_int_safe(dg.get("depth"))
1281
+ parent = dg.get("parent_id") or {}
1282
+ parent_id = self._to_int_safe(parent.get("id"))
1283
+ parent_name = parent.get("name")
1284
+ is_top_level = (parent_id == 1)
1285
+
1286
+ # children groups
1287
+ children = []
1288
+ for cg in (dg.get("device_groups") or []):
1289
+ children.append({
1290
+ "id": self._to_int_safe(cg.get("id")),
1291
+ "name": cg.get("name"),
1292
+ "description": cg.get("description"),
1293
+ "depth": self._to_int_safe(cg.get("depth"))
1294
+ })
1295
+
1296
+ # devices in group (flatten common fields)
1297
+ devs = []
1298
+ for d in (dg.get("devices") or []):
1299
+ devs.append({
1300
+ "id": self._to_int_safe(d.get("id")),
1301
+ "name": d.get("name"),
1302
+ "model_id": self._to_int_safe((d.get("device_type_id") or {}).get("id")),
1303
+ "model_name": (d.get("device_type_id") or {}).get("name"),
1304
+ "status": d.get("status"),
1305
+ "firmware": (d.get("version") or {}).get("firmware"),
1306
+ "product_name": (d.get("version") or {}).get("product_name"),
1307
+ "ip": (d.get("lan") or {}).get("ip"),
1308
+ "connection_mode": (d.get("lan") or {}).get("connection_mode"),
1309
+ "rs485_mode": (d.get("rs485") or {}).get("mode"),
1310
+ })
1311
+
1312
+ return {
1313
+ "message": f"Device group '{name}' (id={gid}) detail",
1314
+ "group": {
1315
+ "id": gid,
1316
+ "name": name,
1317
+ "description": desc,
1318
+ "depth": depth,
1319
+ "parent": {"id": parent_id, "name": parent_name},
1320
+ "is_top_level": is_top_level,
1321
+ "counts": {"devices": len(devs), "children": len(children)},
1322
+ "devices": devs,
1323
+ "children": children,
1324
+ }
1325
+ }
1326
+
1327
+ def _to_int_safe(self, v: Any) -> Optional[int]:
1328
+ """Best-effort int coercion; None on failure."""
1329
+ try:
1330
+ return int(str(v))
1331
+ except Exception:
1332
+ return None
1333
+
1334
+ async def create_device_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1335
+ """Create a new device group."""
1336
+ try:
1337
+ self.check_auth()
1338
+
1339
+ headers = {
1340
+ "bs-session-id": self.get_session_id(),
1341
+ "Content-Type": "application/json"
1342
+ }
1343
+
1344
+ group_data = {
1345
+ "DeviceGroup": {
1346
+ "name": args["name"],
1347
+ "description": args.get("description", ""),
1348
+ "parent_id": {
1349
+ "id": args.get("parent_id", 1)
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ # Only add device_id_list if devices are provided
1355
+ if args.get("device_ids"):
1356
+ group_data["DeviceGroup"]["device_id_list"] = args["device_ids"]
1357
+
1358
+ async with httpx.AsyncClient(verify=False) as client:
1359
+ response = await client.post(
1360
+ f"{self.session.config.biostar_url}/api/device_groups",
1361
+ headers=headers,
1362
+ json=group_data
1363
+ )
1364
+
1365
+ if response.status_code not in [200, 201]:
1366
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
1367
+
1368
+ return self.success_response({
1369
+ "message": f"Device group '{args['name']}' created successfully",
1370
+ "group_id": response.json().get("id")
1371
+ })
1372
+
1373
+ except Exception as e:
1374
+ return await self.handle_api_error(e)
1375
+
1376
+ async def update_device_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1377
+ """Update an existing device group."""
1378
+ try:
1379
+ self.check_auth()
1380
+
1381
+ group_id = args["group_id"]
1382
+
1383
+ headers = {
1384
+ "bs-session-id": self.get_session_id(),
1385
+ "Content-Type": "application/json"
1386
+ }
1387
+
1388
+ # First get existing group data
1389
+ async with httpx.AsyncClient(verify=False) as client:
1390
+ get_response = await client.get(
1391
+ f"{self.session.config.biostar_url}/api/device_groups/{group_id}",
1392
+ headers=headers
1393
+ )
1394
+
1395
+ if get_response.status_code != 200:
1396
+ return self.error_response(f"Failed to get device group: {get_response.status_code}")
1397
+
1398
+ group_data = get_response.json()
1399
+
1400
+ # Update only provided fields
1401
+ if "name" in args:
1402
+ group_data["DeviceGroup"]["name"] = args["name"]
1403
+ if "description" in args:
1404
+ group_data["DeviceGroup"]["description"] = args["description"]
1405
+ if "device_ids" in args:
1406
+ group_data["DeviceGroup"]["device_id_list"] = args["device_ids"]
1407
+
1408
+ # Update the group
1409
+ update_response = await client.put(
1410
+ f"{self.session.config.biostar_url}/api/device_groups/{group_id}",
1411
+ headers=headers,
1412
+ json=group_data
1413
+ )
1414
+
1415
+ if update_response.status_code != 200:
1416
+ return self.error_response(f"API call failed: {update_response.status_code} - {update_response.text}")
1417
+
1418
+ return self.success_response({
1419
+ "message": f"Device group {group_id} updated successfully"
1420
+ })
1421
+
1422
+ except Exception as e:
1423
+ return await self.handle_api_error(e)
1424
+
1425
+ async def delete_device_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1426
+ """Delete a device group."""
1427
+ try:
1428
+ self.check_auth()
1429
+
1430
+ group_id = args["group_id"]
1431
+
1432
+ headers = {
1433
+ "bs-session-id": self.get_session_id(),
1434
+ "Content-Type": "application/json"
1435
+ }
1436
+
1437
+ async with httpx.AsyncClient(verify=False) as client:
1438
+ response = await client.delete(
1439
+ f"{self.session.config.biostar_url}/api/device_groups/{group_id}",
1440
+ headers=headers
1441
+ )
1442
+
1443
+ if response.status_code not in [200, 204]:
1444
+ return self.error_response(f"API call failed: {response.status_code} - {response.text}")
1445
+
1446
+ return self.success_response({
1447
+ "message": f"Device group {group_id} deleted successfully"
1448
+ })
1449
+
1450
+ except Exception as e:
1451
+ return await self.handle_api_error(e)
1452
+
1453
+ def format_device_info(self, device: Dict[str, Any]) -> Dict[str, Any]:
1454
+ """Format device information for response."""
1455
+ return {
1456
+ "id": device.get("id"),
1457
+ "name": device.get("name"),
1458
+ "device_type": device.get("device_type", {}).get("name") if isinstance(device.get("device_type"),
1459
+ dict) else device.get("device_type"),
1460
+ "ip_address": device.get("ip"),
1461
+ "port": device.get("port"),
1462
+ "status": device.get("status"),
1463
+ "is_online": device.get("is_online", False),
1464
+ "firmware_version": device.get("firmware_version"),
1465
+ "last_activity": device.get("last_activity")
1466
+ }
1467
+
1468
+ async def move_device_to_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1469
+ """
1470
+ Move a device into a target device group.
1471
+ - Resolves device by id or by name substring.
1472
+ - Resolves group by id or by name substring using POST /api/v2/device_groups/search.
1473
+ - Applies PUT /api/devices/{device_id} with {"Device":{"device_group_id":{"id": <gid>}}}.
1474
+ - If ambiguous or not found, returns candidates and asks for selection.
1475
+ """
1476
+ try:
1477
+ self.check_auth()
1478
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
1479
+
1480
+ # ---------- 1) Resolve device ----------
1481
+ device_id = args.get("device_id")
1482
+ device_query = (args.get("device_name") or args.get("device_search_text") or "").strip()
1483
+
1484
+ if not device_id:
1485
+ devices_all = await self._fetch_all_devices(headers)
1486
+ if device_query:
1487
+ matches = self._filter_devices_by_query(devices_all, device_query)
1488
+ else:
1489
+ matches = []
1490
+
1491
+ if len(matches) == 0:
1492
+ # No device matched → return all devices and stop
1493
+ return self.success_response({
1494
+ "message": "No device matched the query. Here is the full device list. Please pick one.",
1495
+ "status": "device_not_found",
1496
+ "needs_selection": True,
1497
+ "devices": [self.format_device_info(d) for d in devices_all]
1498
+ })
1499
+ if len(matches) > 1:
1500
+ # Ambiguous → return candidates
1501
+ return self.success_response({
1502
+ "message": "Multiple devices matched. Please pick one device.",
1503
+ "status": "ambiguous_device",
1504
+ "needs_selection": True,
1505
+ "candidates": [self.format_device_info(d) for d in matches]
1506
+ })
1507
+ device_id = matches[0].get("id")
1508
+
1509
+ # ---------- 2) Resolve group ----------
1510
+ target_group_id = args.get("target_group_id")
1511
+ target_group_name = (args.get("target_group_name") or args.get("group_search_text") or "").strip()
1512
+
1513
+ if not target_group_id:
1514
+ groups_all = await self._search_all_device_groups_v2(headers)
1515
+ if target_group_name:
1516
+ g_matches = self._filter_groups_by_name(groups_all, target_group_name)
1517
+ else:
1518
+ g_matches = []
1519
+
1520
+ if len(g_matches) == 0:
1521
+ # No group matched → return all groups and stop
1522
+ return self.success_response({
1523
+ "message": "No device group matched the query. Here is the full group list. Please pick one.",
1524
+ "status": "group_not_found",
1525
+ "needs_selection": True,
1526
+ "groups": [{"id": g.get("id"), "name": g.get("name"), "depth": g.get("depth")} for g in
1527
+ groups_all]
1528
+ })
1529
+ if len(g_matches) > 1:
1530
+ # Ambiguous → return candidates
1531
+ return self.success_response({
1532
+ "message": "Multiple device groups matched. Please pick one group.",
1533
+ "status": "ambiguous_group",
1534
+ "needs_selection": True,
1535
+ "candidates": [{"id": g.get("id"), "name": g.get("name"), "depth": g.get("depth")} for g in
1536
+ g_matches]
1537
+ })
1538
+ target_group_id = g_matches[0].get("id")
1539
+
1540
+ # ---------- 3) PUT update ----------
1541
+ payload = {"Device": {"device_group_id": {"id": int(target_group_id)}}}
1542
+ async with httpx.AsyncClient(verify=False) as client:
1543
+ put_resp = await client.put(
1544
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
1545
+ headers=headers,
1546
+ json=payload
1547
+ )
1548
+ if put_resp.status_code != 200:
1549
+ return self.error_response(
1550
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
1551
+ {"request_body": payload, "device_id": int(device_id)}
1552
+ )
1553
+
1554
+ return self.success_response({
1555
+ "message": f"Device {int(device_id)} moved to group {int(target_group_id)} successfully",
1556
+ "device_id": int(device_id),
1557
+ "target_group_id": int(target_group_id),
1558
+ "request_body": payload
1559
+ })
1560
+
1561
+ except Exception as e:
1562
+ return await self.handle_api_error(e)
1563
+
1564
+ async def remove_device_from_group(self, args: Dict[str, Any]) -> Sequence[TextContent]:
1565
+ """
1566
+ Move a device to the root group (id=1).
1567
+ - Resolves device by id or by name substring (same 3-case logic).
1568
+ - Applies PUT /api/devices/{device_id} with {"Device":{"device_group_id":{"id":1}}}.
1569
+ """
1570
+ try:
1571
+ self.check_auth()
1572
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
1573
+
1574
+ # Resolve device
1575
+ device_id = args.get("device_id")
1576
+ device_query = (args.get("device_name") or args.get("device_search_text") or "").strip()
1577
+
1578
+ if not device_id:
1579
+ devices_all = await self._fetch_all_devices(headers)
1580
+ matches = self._filter_devices_by_query(devices_all, device_query) if device_query else []
1581
+
1582
+ if len(matches) == 0:
1583
+ return self.success_response({
1584
+ "message": "No device matched the query. Here is the full device list. Please pick one.",
1585
+ "status": "device_not_found",
1586
+ "needs_selection": True,
1587
+ "devices": [self.format_device_info(d) for d in devices_all]
1588
+ })
1589
+ if len(matches) > 1:
1590
+ return self.success_response({
1591
+ "message": "Multiple devices matched. Please pick one device.",
1592
+ "status": "ambiguous_device",
1593
+ "needs_selection": True,
1594
+ "candidates": [self.format_device_info(d) for d in matches]
1595
+ })
1596
+ device_id = matches[0].get("id")
1597
+
1598
+ # PUT to root group (id=1)
1599
+ payload = {"Device": {"device_group_id": {"id": 1}}}
1600
+ async with httpx.AsyncClient(verify=False) as client:
1601
+ put_resp = await client.put(
1602
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
1603
+ headers=headers,
1604
+ json=payload
1605
+ )
1606
+ if put_resp.status_code != 200:
1607
+ return self.error_response(
1608
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
1609
+ {"request_body": payload, "device_id": int(device_id)}
1610
+ )
1611
+
1612
+ return self.success_response({
1613
+ "message": f"Device {int(device_id)} moved to root group (id=1) successfully",
1614
+ "device_id": int(device_id),
1615
+ "target_group_id": 1,
1616
+ "request_body": payload
1617
+ })
1618
+
1619
+ except Exception as e:
1620
+ return await self.handle_api_error(e)
1621
+
1622
+ # ------------------------ helpers (private) ------------------------
1623
+ async def _fetch_all_devices(self, headers: Dict[str, str]) -> list:
1624
+ """GET /api/devices and return rows list."""
1625
+ try:
1626
+ async with httpx.AsyncClient(verify=False) as client:
1627
+ r = await client.get(f"{self.session.config.biostar_url}/api/devices", headers=headers)
1628
+ if r.status_code != 200:
1629
+ return []
1630
+ data = r.json() or {}
1631
+ return data.get("DeviceCollection", {}).get("rows", []) or []
1632
+ except Exception:
1633
+ return []
1634
+
1635
+ def _filter_devices_by_query(self, rows: list, query: str) -> list:
1636
+ """Case-insensitive substring match on name or exact id match if numeric."""
1637
+ q = query.strip().lower()
1638
+ numeric_id = None
1639
+ try:
1640
+ numeric_id = int(query)
1641
+ except Exception:
1642
+ pass
1643
+ out = []
1644
+ for d in rows:
1645
+ name = str(d.get("name") or "").lower()
1646
+ did = d.get("id")
1647
+ if (q and q in name) or (numeric_id is not None and did == numeric_id):
1648
+ out.append(d)
1649
+ return out
1650
+
1651
+ async def _search_all_device_groups_v2(self, headers: Dict[str, str]) -> list:
1652
+ """POST /api/v2/device_groups/search with {"order_by":"depth:false"}."""
1653
+ try:
1654
+ payload = {"order_by": "depth:false"}
1655
+ async with httpx.AsyncClient(verify=False) as client:
1656
+ r = await client.post(
1657
+ f"{self.session.config.biostar_url}/api/v2/device_groups/search",
1658
+ headers=headers,
1659
+ json=payload
1660
+ )
1661
+ if r.status_code != 200:
1662
+ return []
1663
+ data = r.json() or {}
1664
+ return data.get("DeviceGroupCollection", {}).get("rows", []) or []
1665
+ except Exception:
1666
+ return []
1667
+
1668
+ def _filter_groups_by_name(self, rows: list, name_substring: str) -> list:
1669
+ """Case-insensitive substring match on group name."""
1670
+ q = name_substring.strip().lower()
1671
+ return [g for g in rows if q in str(g.get("name") or "").lower()]
1672
+
1673
+ async def _resolve_device_id_from_args(self, args: Dict[str, Any], headers: Dict[str, str]) -> tuple[
1674
+ Optional[int], Optional[Sequence]]:
1675
+ """
1676
+ Resolve device id from args.
1677
+ Priority:
1678
+ 1) device_id
1679
+ 2) device_name (exact or best-match search if your implementation supports)
1680
+ 3) door_id -> entry_device_id
1681
+ 4) door_name -> entry_device_id
1682
+ This function assumes existing endpoints/utilities in your codebase.
1683
+ """
1684
+ # If caller already passes device_id
1685
+ if args.get("device_id") is not None:
1686
+ try:
1687
+ return int(args["device_id"]), None
1688
+ except Exception:
1689
+ return None, self.error_response("Invalid device_id")
1690
+
1691
+ # NOTE: Keep your original search order/logic. Below are safe fallbacks.
1692
+ # 2) Resolve by device_name (exact)
1693
+ device_name = str(args.get("device_name") or "").strip()
1694
+ if device_name:
1695
+ try:
1696
+ async with httpx.AsyncClient(verify=False) as client:
1697
+ resp = await client.get(f"{self.session.config.biostar_url}/api/devices", headers=headers)
1698
+ if resp.status_code == 200:
1699
+ data = resp.json() or {}
1700
+ rows = (data.get("DeviceCollection") or {}).get("rows") or data.get("devices") or []
1701
+ for d in rows:
1702
+ if str(d.get("name") or "").strip() == device_name:
1703
+ did = d.get("id")
1704
+ if did is not None:
1705
+ return int(did), None
1706
+ except Exception:
1707
+ pass # fall through
1708
+
1709
+ # 3) Resolve via door_id -> entry_device_id
1710
+ if args.get("door_id") is not None:
1711
+ try:
1712
+ door_id = int(args["door_id"])
1713
+ async with httpx.AsyncClient(verify=False) as client:
1714
+ dresp = await client.get(f"{self.session.config.biostar_url}/api/doors/{door_id}", headers=headers)
1715
+ if dresp.status_code == 200:
1716
+ dobj = dresp.json() or {}
1717
+ door = (dobj.get("Door") or {})
1718
+ entry = (door.get("entry_device_id") or {}).get("id")
1719
+ if entry is not None:
1720
+ return int(entry), None
1721
+ except Exception:
1722
+ pass # fall through
1723
+
1724
+ # 4) Resolve via door_name -> entry_device_id
1725
+ door_name = str(args.get("door_name") or "").strip()
1726
+ if door_name:
1727
+ try:
1728
+ async with httpx.AsyncClient(verify=False) as client:
1729
+ dresp = await client.get(f"{self.session.config.biostar_url}/api/doors", headers=headers)
1730
+ if dresp.status_code == 200:
1731
+ data = dresp.json() or {}
1732
+ rows = (data.get("DoorCollection") or {}).get("rows") or data.get("doors") or []
1733
+ for d in rows:
1734
+ if str(d.get("name") or "").strip() == door_name:
1735
+ entry = (d.get("entry_device_id") or {}).get("id")
1736
+ if entry is not None:
1737
+ return int(entry), None
1738
+ except Exception:
1739
+ pass # fall through
1740
+
1741
+ # Nothing resolved
1742
+ return None, self.error_response("Device not resolved from args")
1743
+
1744
+ def _online_status_from_device(self, device_obj: Dict[str, Any]) -> bool:
1745
+ """
1746
+ Determine online status consistently:
1747
+ - Prefer 'status' == "1" as online.
1748
+ - Fallback to 'is_online' boolean.
1749
+ """
1750
+ status = str(device_obj.get("status", "")).strip()
1751
+ if status == "1":
1752
+ return True
1753
+ io = device_obj.get("is_online")
1754
+ if isinstance(io, bool):
1755
+ return io
1756
+ return False
1757
+
1758
+ def _to_int(self, v) -> Optional[int]:
1759
+ """Safe convert to int or return None."""
1760
+ try:
1761
+ return int(str(v).strip())
1762
+ except Exception:
1763
+ return None
1764
+
1765
+ # -----------------------------
1766
+ # AUTH MODE UPDATE (with conflict & order policy)
1767
+ # -----------------------------
1768
+ async def update_device_auth_mode(self, args: Dict[str, Any]) -> Sequence:
1769
+ """
1770
+ Update authentication operation modes for a device.
1771
+
1772
+ Flow:
1773
+ 1) Resolve target device by device_id or name/door fallback.
1774
+ 2) GET /api/devices/{id} to read current 'authentication.operation_modes'.
1775
+ 3) Decide new set using 'action' (add/remove/set), 'conflict_policy' (overwrite/abort/merge).
1776
+ 4) PUT minimal payload: {"Device":{"authentication":{"operation_modes":[...]}}}
1777
+
1778
+ Business rules:
1779
+ - 'add' that changes strictness may be considered a conflict.
1780
+ - Default on conflict: overwrite (i.e., behave like 'set').
1781
+ - If conflict_policy='abort', we return an error instead.
1782
+ - schedule_id defaults to 1 and is applied to all rows.
1783
+
1784
+ Inputs:
1785
+ - mode_codes: [int] direct extended mode codes (11..49) take precedence
1786
+ - auth_specs: [str] human specs (e.g., "card+face+fingerprint") mapped to a single code
1787
+ Supports ordered forms like "card -> face -> fingerprint".
1788
+ - action: "set" | "add" | "remove" (default "set")
1789
+ - conflict_policy: "overwrite" | "abort" | "merge" (default "overwrite")
1790
+ - ordered_sequence_policy: "collapse" | "abort" (default "collapse")
1791
+ - schedule_id: int (default 1)
1792
+ """
1793
+ try:
1794
+ self.check_auth()
1795
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
1796
+
1797
+ # 1) Resolve
1798
+ resolved_id, early = await self._resolve_device_id_from_args(args, headers)
1799
+ if resolved_id is None:
1800
+ return early
1801
+ device_id = int(resolved_id)
1802
+
1803
+ # 2) Fetch current config
1804
+ async with httpx.AsyncClient(verify=False) as client:
1805
+ get_resp = await client.get(f"{self.session.config.biostar_url}/api/devices/{device_id}",
1806
+ headers=headers)
1807
+ if get_resp.status_code != 200:
1808
+ return self.error_response(f"Failed to get device: {get_resp.status_code} - {get_resp.text}")
1809
+
1810
+ body = get_resp.json() or {}
1811
+ device = (body.get("Device") or {})
1812
+ auth = device.get("authentication") or {}
1813
+ current_ops = list(auth.get("operation_modes") or [])
1814
+
1815
+ # Track online, but do not block updates (only include in response)
1816
+ is_online = self._online_status_from_device(device)
1817
+
1818
+ # Extract current numeric codes
1819
+ current_codes: set[int] = set()
1820
+ for row in current_ops:
1821
+ code = self._to_int(row.get("mode"))
1822
+ if code is not None:
1823
+ current_codes.add(code)
1824
+
1825
+ # 3) Resolve target codes
1826
+ ordered_detected = False
1827
+ target_codes: List[int] = []
1828
+
1829
+ # Prefer auth_specs if given (allows natural language + order syntax)
1830
+ specs = args.get("auth_specs") or []
1831
+ if isinstance(specs, (list, tuple)) and specs:
1832
+ for spec in specs:
1833
+ code, was_ordered = self._map_auth_spec_to_code(spec)
1834
+ ordered_detected = ordered_detected or bool(was_ordered)
1835
+ if code is None:
1836
+ return self.error_response(
1837
+ "Unknown auth spec. Provide supported 'auth_specs' (e.g., 'card+face+fingerprint') or 'mode_codes'.",
1838
+ {"unknown_spec": spec}
1839
+ )
1840
+ if 11 <= int(code) <= 49:
1841
+ target_codes.append(int(code))
1842
+
1843
+ # If no specs or empty, accept raw mode codes
1844
+ if not target_codes:
1845
+ raw_codes = args.get("mode_codes")
1846
+ if isinstance(raw_codes, (list, tuple)):
1847
+ for x in raw_codes:
1848
+ code = self._to_int(x)
1849
+ if code is not None and 11 <= code <= 49:
1850
+ target_codes.append(code)
1851
+
1852
+ if not target_codes:
1853
+ return self.error_response("No valid mode codes resolved from 'auth_specs' or 'mode_codes' (11..49).")
1854
+
1855
+ action = (args.get("action") or "set").strip().lower()
1856
+ conflict_policy = (args.get("conflict_policy") or "overwrite").strip().lower()
1857
+ ordered_policy = (args.get("ordered_sequence_policy") or "collapse").strip().lower()
1858
+ schedule_id = self._to_int(args.get("schedule_id")) or 1
1859
+ target_set = set(target_codes)
1860
+
1861
+ # If ordered syntax was detected, either collapse (default) or abort
1862
+ if ordered_detected and ordered_policy == "abort":
1863
+ return self.error_response(
1864
+ "Ordered steps are not supported by device. Aborted per 'ordered_sequence_policy=abort'.",
1865
+ {"received_specs": specs}
1866
+ )
1867
+
1868
+ # Conflict detection (simple guard; project-specific refinement possible)
1869
+ def has_conflict_add(cur: set[int], tgt: set[int]) -> bool:
1870
+ return len(tgt - cur) > 0
1871
+
1872
+ # Compute next codes
1873
+ if action == "set":
1874
+ new_codes = set(target_set)
1875
+ elif action == "remove":
1876
+ new_codes = set(current_codes) - target_set
1877
+ elif action == "add":
1878
+ if has_conflict_add(current_codes, target_set):
1879
+ if conflict_policy == "abort":
1880
+ return self.error_response(
1881
+ "Add operation conflicts with business rule. Use action='set' or conflict_policy='overwrite'.",
1882
+ {"current": sorted(current_codes), "target": sorted(target_set)}
1883
+ )
1884
+ elif conflict_policy in ("overwrite", "set"):
1885
+ new_codes = set(target_set) # overwrite like 'set'
1886
+ elif conflict_policy == "merge":
1887
+ new_codes = set(current_codes) | target_set
1888
+ else:
1889
+ new_codes = set(target_set) # default overwrite
1890
+ else:
1891
+ new_codes = set(current_codes) | target_set
1892
+ else:
1893
+ return self.error_response("Invalid action. Use 'set', 'add', or 'remove'.")
1894
+
1895
+ # Short-circuit if nothing to change
1896
+ if new_codes == current_codes:
1897
+ return self.success_response({
1898
+ "message": "No changes to authentication.operation_modes.",
1899
+ "device_id": device_id,
1900
+ "online": is_online,
1901
+ "unchanged_modes": sorted(current_codes),
1902
+ "ordered_input_collapsed": bool(ordered_detected and ordered_policy == "collapse")
1903
+ })
1904
+
1905
+ # Build minimal payload (keep other fields intact server-side)
1906
+ def _row(m: int) -> Dict[str, Any]:
1907
+ return {"mode": int(m), "schedule_id": {"id": str(schedule_id)}}
1908
+
1909
+ final_ops = [_row(m) for m in sorted(new_codes)]
1910
+ payload = {"Device": {"authentication": {"operation_modes": final_ops}}}
1911
+
1912
+ # 4) PUT
1913
+ async with httpx.AsyncClient(verify=False) as client:
1914
+ put_resp = await client.put(
1915
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
1916
+ headers=headers,
1917
+ json=payload
1918
+ )
1919
+
1920
+ if put_resp.status_code != 200:
1921
+ return self.error_response(
1922
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
1923
+ {"request_body": payload, "device_id": device_id}
1924
+ )
1925
+
1926
+ return self.success_response({
1927
+ "message": f"Updated authentication.operation_modes via action='{action}', conflict_policy='{conflict_policy}'",
1928
+ "device_id": device_id,
1929
+ "online": is_online,
1930
+ "before_codes": sorted(current_codes),
1931
+ "after_codes": sorted(new_codes),
1932
+ "ordered_input_collapsed": bool(ordered_detected and ordered_policy == "collapse"),
1933
+ "request_body": payload
1934
+ })
1935
+
1936
+ except Exception as e:
1937
+ return await self.handle_api_error(e)
1938
+
1939
+ def _snap_face_max(self, max_cm: Any) -> Optional[int]:
1940
+ """
1941
+ Snap requested face detection max distance to supported steps:
1942
+ 30,40,50,60,70,80,90,100,110,120,130 or 255 (for >130).
1943
+ """
1944
+ if max_cm is None:
1945
+ return None
1946
+ # Special hint keys
1947
+ if isinstance(max_cm, str) and max_cm.strip().lower() in ("over_130", "over130", ">130", "gt130"):
1948
+ return 255
1949
+ val = self._to_int(max_cm)
1950
+ if val is None:
1951
+ return None
1952
+ if val <= 30:
1953
+ return 30
1954
+ if val >= 131:
1955
+ return 255
1956
+ steps = [30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130]
1957
+ for s in steps:
1958
+ if val <= s:
1959
+ return s
1960
+ return 130
1961
+
1962
+ # -----------------------------
1963
+ # FACE DISTANCE UPDATE (leave UNCHANGED as requested)
1964
+ # -----------------------------
1965
+ async def update_device_face_distance(self, args: Dict[str, Any]) -> Sequence:
1966
+ """
1967
+ Update face detection distance window ONLY.
1968
+ (UNCHANGED per user's request)
1969
+ """
1970
+ try:
1971
+ self.check_auth()
1972
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
1973
+
1974
+ # 1) Resolve device
1975
+ resolved_id, early = await self._resolve_device_id_from_args(args, headers)
1976
+ if resolved_id is None:
1977
+ return early
1978
+ device_id = int(resolved_id)
1979
+
1980
+ # Parse target distance
1981
+ max_cm = self._snap_face_max(args.get("max_cm"))
1982
+ if max_cm is None:
1983
+ # Support legacy argument name
1984
+ max_cm = self._snap_face_max(args.get("target_max_cm"))
1985
+ if max_cm is None:
1986
+ return self.error_response("Provide 'max_cm' (30..130) or 'over_130' to use 255.")
1987
+
1988
+ # 2) Fetch current device
1989
+ async with httpx.AsyncClient(verify=False) as client:
1990
+ get_resp = await client.get(f"{self.session.config.biostar_url}/api/devices/{device_id}",
1991
+ headers=headers)
1992
+ if get_resp.status_code != 200:
1993
+ return self.error_response(f"Failed to get device: {get_resp.status_code} - {get_resp.text}")
1994
+
1995
+ body = get_resp.json() or {}
1996
+ device = (body.get("Device") or {})
1997
+ is_online = self._online_status_from_device(device)
1998
+
1999
+ # Prefer cloning existing 'face' block to keep all fields intact
2000
+ face = {}
2001
+ if isinstance(device.get("face"), dict):
2002
+ face = dict(device["face"]) # shallow clone
2003
+
2004
+ # Apply only the two fields (strings as per API examples)
2005
+ face["detection_distance_min"] = "30"
2006
+ face["detection_distance_max"] = str(max_cm)
2007
+
2008
+ payload = {"Device": {"face": face}}
2009
+
2010
+ # 3) PUT minimal update
2011
+ async with httpx.AsyncClient(verify=False) as client:
2012
+ put_resp = await client.put(
2013
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
2014
+ headers=headers,
2015
+ json=payload
2016
+ )
2017
+
2018
+ if put_resp.status_code != 200:
2019
+ return self.error_response(
2020
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
2021
+ {"request_body": payload, "device_id": device_id}
2022
+ )
2023
+
2024
+ return self.success_response({
2025
+ "message": "Updated face detection distance.",
2026
+ "device_id": device_id,
2027
+ "online": is_online,
2028
+ "applied": {
2029
+ "detection_distance_min": 30,
2030
+ "detection_distance_max": max_cm
2031
+ },
2032
+ "request_body": payload
2033
+ })
2034
+
2035
+ except Exception as e:
2036
+ return await self.handle_api_error(e)
2037
+
2038
+ # -----------------------------
2039
+ # Normalization + Mapping (ASCII-only), order-aware
2040
+ # -----------------------------
2041
+ def _normalize_and_canonicalize_auth_spec(self, spec: str) -> tuple[Optional[str], bool]:
2042
+ """
2043
+ Normalize a human auth spec into a canonical ASCII form and detect if it carried an order.
2044
+ Returns (canonical_string, ordered_detected).
2045
+ Canonical form uses:
2046
+ - '+' for AND between steps
2047
+ - '/' for OR within a step
2048
+ - tokens: face, fingerprint, card, id, pin
2049
+ Examples:
2050
+ "card -> (face or fingerprint) -> pin" -> "card+face/fingerprint+pin", ordered=True
2051
+ "card++face+fingerprint" -> "card+face+fingerprint", ordered=True
2052
+ "fingerprint + face / pin" -> "face/pin+fingerprint", ordered=False
2053
+ """
2054
+ if not spec:
2055
+ return None, False
2056
+
2057
+ s_in = str(spec)
2058
+ s = s_in.strip().lower()
2059
+
2060
+ # Detect order connectors
2061
+ ordered_connectors = ["->", "→", "⇒", ">>", "≫", "then", "next"]
2062
+ ordered_detected = any(tok in s for tok in ordered_connectors) or ("++" in s)
2063
+
2064
+ # Unify connectors
2065
+ s = (s.replace("+", "+")
2066
+ .replace("/", "/")
2067
+ .replace("→", "->")
2068
+ .replace("⇒", "->")
2069
+ .replace("≫", ">>"))
2070
+
2071
+ s = s.replace(" or ", "/")
2072
+ s = s.replace("||", "/").replace("|", "/")
2073
+ s = s.replace(" then ", "->").replace(" next ", "->")
2074
+
2075
+ # Collapse ordered connectors to '+'
2076
+ for tok in ["->", ">>"]:
2077
+ s = s.replace(tok, "+")
2078
+ s = s.replace("++", "+")
2079
+ s = s.replace(" ", "")
2080
+
2081
+ # Numeric-only short-circuit
2082
+ if s.isdigit():
2083
+ return s, ordered_detected
2084
+
2085
+ # Synonyms -> canonical tokens
2086
+ synonyms = {
2087
+ "facial": "face",
2088
+ "faceid": "face",
2089
+ "finger": "fingerprint",
2090
+ "fp": "fingerprint",
2091
+ "badge": "card",
2092
+ "rfid": "card",
2093
+ "uid": "id",
2094
+ "userid": "id",
2095
+ "passcode": "pin",
2096
+ "password": "pin",
2097
+ }
2098
+ for k, v in synonyms.items():
2099
+ s = s.replace(k, v)
2100
+
2101
+ # Remove parentheses (we keep OR within a step)
2102
+ s = s.replace("(", "").replace(")", "")
2103
+
2104
+ TOKENS = {"face", "fingerprint", "card", "id", "pin"}
2105
+ AND_ORDER = ["card", "id", "face", "fingerprint", "pin"]
2106
+ OR_ORDER = ["face", "fingerprint", "pin"]
2107
+
2108
+ # Split into AND steps
2109
+ raw_and_terms = [t for t in s.split("+") if t]
2110
+ if not raw_and_terms:
2111
+ return None, ordered_detected
2112
+
2113
+ def normalize_or_group(term: str) -> str:
2114
+ parts = [p for p in term.split("/") if p]
2115
+ if not parts:
2116
+ return ""
2117
+ if not all(p in TOKENS for p in parts):
2118
+ return ""
2119
+
2120
+ # Canonical OR order
2121
+ def or_key(x: str) -> int:
2122
+ return OR_ORDER.index(x) if x in OR_ORDER else 99
2123
+
2124
+ parts_sorted = sorted(parts, key=or_key)
2125
+ return "/".join(parts_sorted)
2126
+
2127
+ # Normalize each AND term (each may be an OR group)
2128
+ norm_terms = []
2129
+ for term in raw_and_terms:
2130
+ norm = normalize_or_group(term)
2131
+ if not norm:
2132
+ return None, ordered_detected
2133
+ norm_terms.append(norm)
2134
+
2135
+ # Canonical AND ordering by head token
2136
+ def head_token(t: str) -> str:
2137
+ return t.split("/")[0]
2138
+
2139
+ def and_key(t: str) -> int:
2140
+ ht = head_token(t)
2141
+ return AND_ORDER.index(ht) if ht in AND_ORDER else 99
2142
+
2143
+ norm_terms_sorted = sorted(norm_terms, key=and_key)
2144
+ canonical = "+".join(norm_terms_sorted)
2145
+ return canonical, ordered_detected
2146
+
2147
+ def _map_auth_spec_to_code(self, spec: str) -> tuple[Optional[int], bool]:
2148
+ """
2149
+ Map a (possibly ordered) human-readable auth spec to the extended mode code (11..49).
2150
+ Returns (code, ordered_detected).
2151
+ Canonicalization ensures that permutations like "card+fingerprint+face" map to the same
2152
+ code as "card+face+fingerprint" (29).
2153
+ """
2154
+ canonical, ordered = self._normalize_and_canonicalize_auth_spec(spec)
2155
+ if not canonical:
2156
+ return None, ordered
2157
+
2158
+ # If canonical is numeric string (e.g., "29"), accept directly if valid
2159
+ if canonical.isdigit():
2160
+ val = int(canonical)
2161
+ return (val if 11 <= val <= 49 else None), ordered
2162
+
2163
+ # Canonical dictionary: one key per semantic combination
2164
+ MAP = {
2165
+ # Face family
2166
+ "face": 11,
2167
+ "face+fingerprint": 12,
2168
+ "face+pin": 13,
2169
+ "face+fingerprint/pin": 14,
2170
+ "face+fingerprint+pin": 15,
2171
+
2172
+ # Fingerprint family
2173
+ "fingerprint": 16,
2174
+ "fingerprint+face": 17, # normalized order collapses to face+fingerprint
2175
+ "fingerprint+pin": 18,
2176
+ "fingerprint+face/pin": 19,
2177
+ "fingerprint+face+pin": 20, # fingerprint+face+pin -> face+fingerprint+pin
2178
+
2179
+ # Card family (use a single canonical triple-AND -> 29)
2180
+ "card": 21,
2181
+ "card+face": 22,
2182
+ "card+fingerprint": 23,
2183
+ "card+pin": 24,
2184
+ "card+face/fingerprint": 25,
2185
+ "card+face/pin": 26,
2186
+ "card+fingerprint/pin": 27,
2187
+ "card+face/fingerprint/pin": 28,
2188
+ "card+face+fingerprint": 29,
2189
+ "card+face+pin": 30,
2190
+ "card+fingerprint+pin": 32,
2191
+ "card+face/fingerprint+pin": 33,
2192
+ "card+face+fingerprint/pin": 34,
2193
+
2194
+ # ID family
2195
+ "id+face": 36,
2196
+ "id+fingerprint": 37,
2197
+ "id+pin": 38,
2198
+ "id+face/fingerprint": 39,
2199
+ "id+face/pin": 40,
2200
+ "id+fingerprint/pin": 41,
2201
+ "id+face/fingerprint/pin": 42,
2202
+ "id+face+fingerprint": 43,
2203
+ "id+face+pin": 44,
2204
+ "id+fingerprint+pin": 46,
2205
+ "id+face/fingerprint+pin": 47,
2206
+ "id+face+fingerprint/pin": 48,
2207
+ }
2208
+
2209
+ # Direct hit
2210
+ if canonical in MAP:
2211
+ # Enforce canonical triple-AND mapping: any "card+face+fingerprint" order -> 29
2212
+ if canonical == "card+face+fingerprint":
2213
+ return 29, ordered
2214
+ if canonical == "id+face+fingerprint":
2215
+ return 43, ordered
2216
+ if canonical == "card+face+fingerprint/pin":
2217
+ return 34, ordered
2218
+ if canonical == "id+face+fingerprint/pin":
2219
+ return 48, ordered
2220
+ return MAP[canonical], ordered
2221
+
2222
+ # Common alias fallback (after canonicalization this is rarely needed)
2223
+ aliases = {
2224
+ "face+card": "card+face",
2225
+ "fingerprint+card": "card+fingerprint",
2226
+ "pin+card": "card+pin",
2227
+ "face+id": "id+face",
2228
+ "fingerprint+id": "id+fingerprint",
2229
+ "pin+id": "id+pin",
2230
+ }
2231
+ if canonical in aliases and aliases[canonical] in MAP:
2232
+ return MAP[aliases[canonical]], ordered
2233
+
2234
+ # Not mappable
2235
+ return None, ordered
2236
+
2237
+ async def update_device_date_type(self, args: Dict[str, Any]) -> Sequence[TextContent]:
2238
+ """
2239
+ Update only display.date_type for a resolved device.
2240
+ - Resolution: device_id straight-through, else device/door name via internal resolver.
2241
+ - Accepts 'date_type' (0|1|2) or 'date_format' (YYYY/MM/DD | MM/DD/YYYY | DD/MM/YYYY).
2242
+ - Sends a minimal PUT payload: {"Device":{"display":{"date_type":"<code>"}}}.
2243
+ - Leaves all other fields untouched (server will only apply provided fields).
2244
+ """
2245
+ try:
2246
+ self.check_auth()
2247
+
2248
+ headers = {
2249
+ "bs-session-id": self.get_session_id(),
2250
+ "Content-Type": "application/json"
2251
+ }
2252
+
2253
+ # ---- Resolve target device id using the project's resolver ----
2254
+ # (Supports: device_id, device_name, device_search_text, door_name, q + edge/prefer_entry)
2255
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
2256
+ if device_id is None:
2257
+ return early # either needs_selection or error already formatted
2258
+
2259
+ # ---- Determine desired date_type code (string "0"/"1"/"2") ----
2260
+ mapping = {
2261
+ "YYYY/MM/DD": "0",
2262
+ "MM/DD/YYYY": "1",
2263
+ "DD/MM/YYYY": "2",
2264
+ }
2265
+
2266
+ dt_raw = args.get("date_type")
2267
+ df = args.get("date_format")
2268
+
2269
+ if df is not None:
2270
+ df_str = str(df).strip().upper()
2271
+ if df_str not in mapping:
2272
+ return self.error_response(
2273
+ "Invalid date_format. Use one of: YYYY/MM/DD, MM/DD/YYYY, DD/MM/YYYY."
2274
+ )
2275
+ code = mapping[df_str]
2276
+ else:
2277
+ # dt_raw is required when date_format is not provided
2278
+ dt_str = str(dt_raw).strip()
2279
+ if dt_str not in ("0", "1", "2"):
2280
+ # Allow integers 0/1/2 as well
2281
+ try:
2282
+ if int(dt_str) not in (0, 1, 2):
2283
+ raise ValueError()
2284
+ dt_str = str(int(dt_str))
2285
+ except Exception:
2286
+ return self.error_response("Invalid date_type. Use 0|1|2.")
2287
+ code = dt_str
2288
+
2289
+ # ---- Build minimal payload and PUT ----
2290
+ payload = {"Device": {"display": {"date_type": code}}}
2291
+
2292
+ async with httpx.AsyncClient(verify=False) as client:
2293
+ put_resp = await client.put(
2294
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
2295
+ headers=headers,
2296
+ json=payload
2297
+ )
2298
+
2299
+ if put_resp.status_code != 200:
2300
+ return self.error_response(
2301
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
2302
+ {"request_body": payload, "device_id": int(device_id)}
2303
+ )
2304
+
2305
+ # Optionally parse body for confirmation, but success on HTTP 200 is consistent with other handlers
2306
+ return self.success_response({
2307
+ "message": "display.date_type updated successfully",
2308
+ "device_id": int(device_id),
2309
+ "applied_date_type": code,
2310
+ "applied_date_format": {"0": "YYYY/MM/DD", "1": "MM/DD/YYYY", "2": "DD/MM/YYYY"}[code],
2311
+ "request_body": payload
2312
+ })
2313
+
2314
+ except Exception as e:
2315
+ return await self.handle_api_error(e)
2316
+
2317
+ async def update_device_timezone(self, args: Dict[str, Any]) -> Dict[str, Any]:
2318
+ """
2319
+ Steps:
2320
+ 1) Resolve target device (id/name or via door).
2321
+ 2) Resolve timezone offset seconds from args.
2322
+ 3) GET /api/devices/{id} to fetch current Device payload.
2323
+ 4) Merge Device.system.timezone only; PUT back the full payload.
2324
+ """
2325
+ self.check_auth()
2326
+
2327
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
2328
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
2329
+ if device_id is None:
2330
+ return early
2331
+
2332
+ # 2) Resolve timezone
2333
+ offset, note = _lookup_tz_offset(args)
2334
+ if offset is None:
2335
+ return self.error_response(
2336
+ f"Failed to resolve timezone. Hint: provide offset_seconds (int), "
2337
+ f"or utc_offset like '+09:00', or tz_label such as '(UTC+9:00) Seoul/Tokyo' / 'Seoul' / 'PST'."
2338
+ )
2339
+
2340
+ # 3) Fetch current device
2341
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
2342
+ async with httpx.AsyncClient(verify=False) as client:
2343
+ get_resp = await client.get(f"{self.session.config.biostar_url}/api/devices/{device_id}", headers=headers)
2344
+ if get_resp.status_code != 200:
2345
+ return self.error_response(f"GET device failed: {get_resp.status_code} - {get_resp.text}")
2346
+
2347
+ dev_payload = get_resp.json()
2348
+ if "Device" not in dev_payload:
2349
+ return self.error_response("Malformed device payload from server (missing 'Device').")
2350
+
2351
+ # 4) Merge only timezone
2352
+ dev_payload["Device"].setdefault("system", {})
2353
+ before = dev_payload["Device"]["system"].get("timezone", None)
2354
+ dev_payload["Device"]["system"]["timezone"] = offset
2355
+
2356
+ if args.get("dry_run"):
2357
+ return {
2358
+ "message": "Dry-run: computed timezone only.",
2359
+ "device_id": device_id,
2360
+ "resolved_offset_seconds": offset,
2361
+ "resolution_note": note,
2362
+ "before": before,
2363
+ "after": offset
2364
+ }
2365
+
2366
+ # 5) PUT back (full merged payload)
2367
+ async with httpx.AsyncClient(verify=False) as client:
2368
+ put_resp = await client.put(
2369
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
2370
+ headers=headers,
2371
+ json=dev_payload
2372
+ )
2373
+ if put_resp.status_code != 200:
2374
+ return self.error_response(f"PUT device failed: {put_resp.status_code} - {put_resp.text}")
2375
+
2376
+ return {
2377
+ "message": "Device timezone updated.",
2378
+ "device_id": device_id,
2379
+ "resolved_offset_seconds": offset,
2380
+ "resolution_note": note,
2381
+ "before": before,
2382
+ "after": offset
2383
+ }
2384
+
2385
+ async def factory_reset_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
2386
+ """
2387
+ Factory-reset a device with preflight door linkage check.
2388
+
2389
+ Behavior:
2390
+ - Resolve target device id via the existing resolver (id/name/door-based).
2391
+ - Preflight: GET /api/doors and check if this device appears as entry/exit.
2392
+ If linked, return a failure (do NOT call factory_reset API).
2393
+ - If not linked:
2394
+ POST /api/devices/factory_reset with:
2395
+ {
2396
+ "DeviceCollection":{"total":1,"rows":[{"id":"<id>"}]},
2397
+ "isResetWithoutNetwork": <bool>
2398
+ }
2399
+ - Supports dry_run: when true, do not call POST; just report the outcome.
2400
+ """
2401
+ try:
2402
+ self.check_auth()
2403
+
2404
+ headers = {
2405
+ "bs-session-id": self.get_session_id(),
2406
+ "Content-Type": "application/json"
2407
+ }
2408
+
2409
+ # 1) Resolve device id using existing resolver
2410
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
2411
+ if device_id is None:
2412
+ return early # already formatted error/needs_selection
2413
+
2414
+ # 2) Preflight: check door linkage
2415
+ # We fetch doors (client-side filter) to see if device_id is used.
2416
+ linked_doors = []
2417
+ async with httpx.AsyncClient(verify=False) as client:
2418
+ # Note: If your project exposes a v2 search or filters by device,
2419
+ # you can replace this with more selective queries.
2420
+ doors_resp = await client.get(
2421
+ f"{self.session.config.biostar_url}/api/doors",
2422
+ headers=headers
2423
+ )
2424
+
2425
+ if doors_resp.status_code != 200:
2426
+ return self.error_response(
2427
+ f"Failed to fetch doors: {doors_resp.status_code} - {doors_resp.text}"
2428
+ )
2429
+
2430
+ try:
2431
+ doors_json = doors_resp.json()
2432
+ except Exception:
2433
+ return self.error_response("Failed to parse doors response JSON")
2434
+
2435
+ rows = (doors_json or {}).get("DoorCollection", {}).get("rows", [])
2436
+ dev_id_str = str(int(device_id))
2437
+ for d in rows or []:
2438
+ # Each door may have {"entry_device_id":{"id":"..."},"exit_device_id":{"id":"..."}}
2439
+ entry_id = str(((d or {}).get("entry_device_id") or {}).get("id") or "")
2440
+ exit_id = str(((d or {}).get("exit_device_id") or {}).get("id") or "")
2441
+ if entry_id == dev_id_str or exit_id == dev_id_str:
2442
+ linked_doors.append({
2443
+ "door_id": str((d or {}).get("id") or ""),
2444
+ "door_name": (d or {}).get("name") or "",
2445
+ "edge": "entry" if entry_id == dev_id_str else "exit"
2446
+ })
2447
+
2448
+ if linked_doors:
2449
+ # Spec requirement: if device is linked to a door, respond failure.
2450
+ return self.error_response(
2451
+ "Factory reset blocked: device is linked to one or more doors.",
2452
+ {"device_id": int(device_id), "linked_doors": linked_doors}
2453
+ )
2454
+
2455
+ # 3) Resolve options
2456
+ dry_run = bool(args.get("dry_run") or False)
2457
+ is_reset_wo_net = bool(args.get("is_reset_without_network") or False)
2458
+
2459
+ # 4) Build request body
2460
+ payload = {
2461
+ "DeviceCollection": {
2462
+ "total": 1,
2463
+ "rows": [{"id": dev_id_str}]
2464
+ },
2465
+ "isResetWithoutNetwork": is_reset_wo_net
2466
+ }
2467
+
2468
+ if dry_run:
2469
+ return self.success_response({
2470
+ "message": "[dry_run] factory reset would be invoked",
2471
+ "device_id": int(device_id),
2472
+ "payload": payload
2473
+ })
2474
+
2475
+ # 5) POST factory_reset
2476
+ async with httpx.AsyncClient(verify=False) as client:
2477
+ reset_resp = await client.post(
2478
+ f"{self.session.config.biostar_url}/api/devices/factory_reset",
2479
+ headers=headers,
2480
+ json=payload
2481
+ )
2482
+
2483
+ if reset_resp.status_code != 200:
2484
+ return self.error_response(
2485
+ f"Factory reset failed: {reset_resp.status_code} - {reset_resp.text}",
2486
+ {"request_body": payload, "device_id": int(device_id)}
2487
+ )
2488
+
2489
+ # Try to parse a body if useful, but 200 OK is success by contract
2490
+ body = None
2491
+ try:
2492
+ body = reset_resp.json()
2493
+ except Exception:
2494
+ body = {"raw": reset_resp.text}
2495
+
2496
+ return self.success_response({
2497
+ "message": "Factory reset completed successfully",
2498
+ "device_id": int(device_id),
2499
+ "isResetWithoutNetwork": is_reset_wo_net,
2500
+ "response": body
2501
+ })
2502
+
2503
+ except Exception as e:
2504
+ return await self.handle_api_error(e)
2505
+
2506
+ async def update_device_auth_and_display(self, args: Dict[str, Any]) -> Sequence[TextContent]:
2507
+ """
2508
+ Update five fields only (minimal payload PUT /api/devices/:id):
2509
+ - authentication.auth_timeout (3–20)
2510
+ - authentication.enable_server_matching
2511
+ - authentication.enable_full_access
2512
+ - display.display_userid_option
2513
+ - display.display_username_option
2514
+
2515
+ Resolves device by id or name inside this method (no external helpers).
2516
+ Coerces types to API's expected string booleans/numerics.
2517
+ """
2518
+ # ---------- -1) Auth check ----------
2519
+ try:
2520
+ if hasattr(self, "check_auth"):
2521
+ self.check_auth()
2522
+ except Exception as e:
2523
+ return [TextContent(type="text", text=str({
2524
+ "status": "error",
2525
+ "error": f"Authentication required: {type(e).__name__}: {e}"
2526
+ }))]
2527
+
2528
+ # ---------- 0) Basic validations ----------
2529
+ device_id_raw = args.get("device_id")
2530
+ device_name = (args.get("device_name") or "").strip()
2531
+ if device_id_raw is None and not device_name:
2532
+ return [
2533
+ TextContent(type="text", text=str({"status": "error", "error": "Missing device_id or device_name"}))]
2534
+
2535
+ # ---------- 1) Resolve device id ----------
2536
+ device_id: Optional[int] = None
2537
+ if device_id_raw is not None:
2538
+ try:
2539
+ device_id = int(device_id_raw)
2540
+ except Exception:
2541
+ return [TextContent(type="text", text=str({"status": "error", "error": "Invalid device_id"}))]
2542
+ else:
2543
+ try:
2544
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
2545
+ if hasattr(self, "_auth_headers"):
2546
+ try:
2547
+ ah = self._auth_headers() or {}
2548
+ if isinstance(ah, dict):
2549
+ headers.update(ah)
2550
+ except Exception:
2551
+ pass
2552
+ else:
2553
+ if hasattr(self, "get_session_id"):
2554
+ sid = self.get_session_id()
2555
+ if sid:
2556
+ headers["bs-session-id"] = sid
2557
+ if hasattr(self, "headers") and isinstance(self.headers, dict):
2558
+ headers.update(self.headers)
2559
+
2560
+ payload_search = {"name_contains": device_name, "limit": 5}
2561
+ async with httpx.AsyncClient(base_url=self.session.config.biostar_url, verify=False,
2562
+ timeout=30.0) as client:
2563
+ r = await client.post("/api/v2/devices/search", json=payload_search, headers=headers)
2564
+ r.raise_for_status()
2565
+ j = r.json()
2566
+
2567
+ rows = j.get("DeviceCollection", {}).get("rows", []) if isinstance(j, dict) else []
2568
+ exact = [row for row in rows if str(row.get("name", "")).strip() == device_name]
2569
+ take = exact[0] if exact else (rows[0] if rows else None)
2570
+ if not take or "id" not in take:
2571
+ return [TextContent(type="text", text=str(
2572
+ {"status": "error", "error": "Device not found by name", "name": device_name}))]
2573
+ device_id = int(take["id"])
2574
+ except Exception as e:
2575
+ logger.exception("device search failed")
2576
+ return [TextContent(type="text", text=str(
2577
+ {"status": "error", "error": "Device search failed", "details": f"{type(e).__name__}: {e}"}))]
2578
+
2579
+ # ---------- 2) Build minimal payload ----------
2580
+ as_bool_str = lambda b: "true" if bool(b) else "false"
2581
+
2582
+ def normalize_display_opt(v) -> Optional[str]:
2583
+ if v is None:
2584
+ return None
2585
+ if isinstance(v, str):
2586
+ m = {"all": "0", "first": "1", "none": "2"}
2587
+ if v.lower() in m:
2588
+ return m[v.lower()]
2589
+ if v in {"0", "1", "2"}:
2590
+ return v
2591
+ return None
2592
+ if isinstance(v, int) and v in (0, 1, 2):
2593
+ return str(v)
2594
+ return None
2595
+
2596
+ def normalize_auth_timeout(v) -> Optional[str]:
2597
+ """Accept only 3..20; return None if invalid (so we can raise a clear error)."""
2598
+ if v is None:
2599
+ return None
2600
+ try:
2601
+ iv = int(v)
2602
+ except Exception:
2603
+ return None
2604
+ if iv < 3 or iv > 20:
2605
+ return None
2606
+ return str(iv)
2607
+
2608
+ auth_timeout = args.get("auth_timeout")
2609
+ norm_auth_timeout = normalize_auth_timeout(auth_timeout)
2610
+ if auth_timeout is not None and norm_auth_timeout is None:
2611
+ return [TextContent(type="text", text=str({
2612
+ "status": "error",
2613
+ "error": "auth_timeout must be between 3 and 20 seconds"
2614
+ }))]
2615
+
2616
+ enable_server_matching = args.get("enable_server_matching")
2617
+ enable_full_access = args.get("enable_full_access")
2618
+ display_userid_option = normalize_display_opt(args.get("display_userid_option"))
2619
+ display_username_option = normalize_display_opt(args.get("display_username_option"))
2620
+
2621
+ authentication: Dict[str, Any] = {}
2622
+ display: Dict[str, Any] = {}
2623
+
2624
+ if norm_auth_timeout is not None:
2625
+ authentication["auth_timeout"] = norm_auth_timeout
2626
+ if enable_server_matching is not None:
2627
+ authentication["enable_server_matching"] = as_bool_str(enable_server_matching)
2628
+ if enable_full_access is not None:
2629
+ authentication["enable_full_access"] = as_bool_str(enable_full_access)
2630
+
2631
+ if display_userid_option is not None:
2632
+ display["display_userid_option"] = display_userid_option
2633
+ if display_username_option is not None:
2634
+ display["display_username_option"] = display_username_option
2635
+
2636
+ device_payload: Dict[str, Any] = {}
2637
+ if authentication:
2638
+ device_payload["authentication"] = authentication
2639
+ if display:
2640
+ device_payload["display"] = display
2641
+
2642
+ if not device_payload:
2643
+ return [TextContent(type="text", text=str({"status": "error", "error": "No updatable fields provided"}))]
2644
+
2645
+ final_payload = {"Device": device_payload}
2646
+
2647
+ # ---------- 3) Dry-run ----------
2648
+ if args.get("dry_run") is True:
2649
+ return [TextContent(type="text", text=str({
2650
+ "status": "dry-run", "device_id": device_id, "payload": final_payload
2651
+ }))]
2652
+
2653
+ # ---------- 4) PUT /api/devices/:id ----------
2654
+ try:
2655
+ headers: Dict[str, str] = {"Content-Type": "application/json"}
2656
+ if hasattr(self, "_auth_headers"):
2657
+ try:
2658
+ ah = self._auth_headers() or {}
2659
+ if isinstance(ah, dict):
2660
+ headers.update(ah)
2661
+ except Exception:
2662
+ pass
2663
+ else:
2664
+ if hasattr(self, "get_session_id"):
2665
+ sid = self.get_session_id()
2666
+ if sid:
2667
+ headers["bs-session-id"] = sid
2668
+ if hasattr(self, "headers") and isinstance(self.headers, dict):
2669
+ headers.update(self.headers)
2670
+
2671
+ async with httpx.AsyncClient(base_url=self.session.config.biostar_url, verify=False,
2672
+ timeout=30.0) as client:
2673
+ resp = await client.put(f"/api/devices/{device_id}", json=final_payload, headers=headers)
2674
+ if resp.status_code >= 400:
2675
+ try:
2676
+ j = resp.json()
2677
+ except Exception:
2678
+ j = {"raw_text": resp.text}
2679
+ return [TextContent(type="text", text=str({
2680
+ "status": "error", "http_status": resp.status_code,
2681
+ "request_payload": final_payload, "response": j
2682
+ }))]
2683
+
2684
+ try:
2685
+ body: Any = resp.json()
2686
+ except Exception:
2687
+ body = {"raw_text": resp.text or ""}
2688
+
2689
+ return [TextContent(type="text", text=str({
2690
+ "status": "success", "device_id": device_id,
2691
+ "request_payload": final_payload, "response": body
2692
+ }))]
2693
+
2694
+ except Exception as e:
2695
+ logger.exception("PUT /api/devices/:id failed")
2696
+ return [TextContent(type="text", text=str({
2697
+ "status": "error", "error": f"{type(e).__name__}: {e}",
2698
+ "device_id": device_id, "request_payload": final_payload
2699
+ }))]
2700
+
2701
+ async def update_device_network(self, args: Dict[str, Any]) -> Sequence[TextContent]:
2702
+ """
2703
+ Update device network (TCP/IP) settings only, mapped from the UI:
2704
+ - DHCP toggle (enable_dhcp)
2705
+ - IP address, subnet mask, gateway, DNS server (used only when DHCP=false)
2706
+ - Device port
2707
+
2708
+ Behavior:
2709
+ - Fetch current device -> merge "Device.lan" only -> PUT back minimal payload.
2710
+ - When dhcp_enabled=true: static fields (ip/subnet/gateway/dns) are ignored.
2711
+ - When dhcp_enabled=false: ip_address and subnet_mask are required; gateway/dns optional.
2712
+ - String booleans ("true"/"false") are used to match API conventions.
2713
+
2714
+ Input:
2715
+ - Resolve target with the existing resolver:
2716
+ device_id | device_name | device_search_text | door_id | door_name (+ edge)
2717
+ - Fields:
2718
+ dhcp_enabled: bool (required)
2719
+ ip_address: str (IPv4) # used only if dhcp_enabled=false
2720
+ subnet_mask: str (IPv4) # used only if dhcp_enabled=false
2721
+ gateway: str (IPv4, optional) # used only if dhcp_enabled=false
2722
+ dns_server: str (IPv4, optional)# used only if dhcp_enabled=false
2723
+ device_port: int (optional)
2724
+
2725
+ Returns: success_response/error_response with applied fields echo.
2726
+ """
2727
+ try:
2728
+ self.check_auth()
2729
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
2730
+
2731
+ # ---- 1) Resolve target device id ----
2732
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
2733
+ if device_id is None:
2734
+ return early
2735
+
2736
+ # ---- 2) Validate/normalize inputs ----
2737
+ def as_bool_str(b: bool) -> str:
2738
+ return "true" if bool(b) else "false"
2739
+
2740
+ def is_ipv4(s: str) -> bool:
2741
+ try:
2742
+ parts = [int(p) for p in str(s).split(".")]
2743
+ return len(parts) == 4 and all(0 <= x <= 255 for x in parts)
2744
+ except Exception:
2745
+ return False
2746
+
2747
+ if "dhcp_enabled" not in args:
2748
+ return self.error_response("Missing required field: dhcp_enabled")
2749
+
2750
+ dhcp_enabled = bool(args.get("dhcp_enabled"))
2751
+
2752
+ ip_address = args.get("ip_address")
2753
+ subnet_mask = args.get("subnet_mask")
2754
+ gateway = args.get("gateway")
2755
+ dns_server = args.get("dns_server")
2756
+ device_port = args.get("device_port")
2757
+
2758
+ if not dhcp_enabled:
2759
+ # When static addressing, ip and mask are mandatory
2760
+ if not ip_address or not subnet_mask:
2761
+ return self.error_response("When dhcp_enabled=false, 'ip_address' and 'subnet_mask' are required.")
2762
+ for label, val in (("ip_address", ip_address), ("subnet_mask", subnet_mask)):
2763
+ if not is_ipv4(val):
2764
+ return self.error_response(f"Invalid IPv4 value for {label}.")
2765
+ # gateway/dns are optional but validate if provided
2766
+ for label, val in (("gateway", gateway), ("dns_server", dns_server)):
2767
+ if val is not None and not is_ipv4(val):
2768
+ return self.error_response(f"Invalid IPv4 value for {label}.")
2769
+
2770
+ if device_port is not None:
2771
+ try:
2772
+ device_port = int(device_port)
2773
+ if device_port <= 0 or device_port > 65535:
2774
+ raise ValueError()
2775
+ except Exception:
2776
+ return self.error_response("device_port must be a valid integer (1..65535).")
2777
+
2778
+ # ---- 3) GET current device ----
2779
+ async with httpx.AsyncClient(verify=False) as client:
2780
+ get_resp = await client.get(f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
2781
+ headers=headers)
2782
+ if get_resp.status_code != 200:
2783
+ return self.error_response(f"GET device failed: {get_resp.status_code} - {get_resp.text}")
2784
+
2785
+ body = get_resp.json() or {}
2786
+ dev = body.get("Device") or {}
2787
+ lan = dict(dev.get("lan") or {})
2788
+
2789
+ # ---- 4) Merge only LAN fields (keep other keys intact) ----
2790
+ # alias helper to write into existing/expected keys
2791
+ def assign_with_alias(d: Dict[str, Any], value: Any, aliases: List[str]) -> None:
2792
+ """
2793
+ Write value into the first key that already exists in d; otherwise use the first alias.
2794
+ """
2795
+ key = None
2796
+ for k in aliases:
2797
+ if k in d:
2798
+ key = k
2799
+ break
2800
+ if key is None:
2801
+ key = aliases[0]
2802
+ d[key] = value
2803
+
2804
+ # DHCP toggle
2805
+ assign_with_alias(lan, as_bool_str(dhcp_enabled), ["enable_dhcp", "dhcp", "use_dhcp"])
2806
+
2807
+ applied: Dict[str, Any] = {"dhcp_enabled": dhcp_enabled}
2808
+
2809
+ # Static fields only when DHCP is disabled
2810
+ if not dhcp_enabled:
2811
+ assign_with_alias(lan, ip_address, ["ip", "ip_addr", "ip_address"])
2812
+ assign_with_alias(lan, subnet_mask, ["subnet_mask", "netmask", "subnet"])
2813
+ if gateway is not None:
2814
+ assign_with_alias(lan, gateway, ["gateway", "gw", "router"])
2815
+ applied["gateway"] = gateway
2816
+ if dns_server is not None:
2817
+ assign_with_alias(lan, dns_server, ["dns_server", "dns", "primary_dns", "dns1"])
2818
+ applied["dns_server"] = dns_server
2819
+ applied["ip_address"] = ip_address
2820
+ applied["subnet_mask"] = subnet_mask
2821
+
2822
+ # Device port (optional)
2823
+ if device_port is not None:
2824
+ assign_with_alias(lan, str(device_port), ["device_port", "port"])
2825
+ applied["device_port"] = device_port
2826
+
2827
+ payload = {"Device": {"lan": lan}}
2828
+
2829
+ # ---- 5) PUT minimal payload ----
2830
+ async with httpx.AsyncClient(verify=False) as client:
2831
+ put_resp = await client.put(
2832
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
2833
+ headers=headers,
2834
+ json=payload
2835
+ )
2836
+
2837
+ if put_resp.status_code != 200:
2838
+ return self.error_response(
2839
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
2840
+ {"request_body": payload, "device_id": int(device_id)}
2841
+ )
2842
+
2843
+ # ---- 6) Done ----
2844
+ return self.success_response({
2845
+ "message": "Network (TCP/IP) updated successfully.",
2846
+ "device_id": int(device_id),
2847
+ "applied": applied,
2848
+ "request_body": payload
2849
+ })
2850
+
2851
+ except Exception as e:
2852
+ return await self.handle_api_error(e)
2853
+
2854
+ async def update_device_serial_comm(self, args: Dict[str, Any]) -> Sequence[TextContent]:
2855
+ """
2856
+ Update RS-485 serial communication settings while preserving all other device fields.
2857
+ - API: GET /api/devices/{id} -> take baseline (UI-like payload)
2858
+ PUT /api/devices/{id} with merged body (only RS-485 + showosdpresult changed)
2859
+ - Input:
2860
+ device_id?: int
2861
+ device_name?: str (exact or contains; resolves to unique device)
2862
+ rs485_mode?: "master"|"slave"|"default" or 1|2|3
2863
+ baud_rate?: 9600|19200|38400|57600|115200 (int or str)
2864
+ show_osdp_result?: "controller"|"device" or 0|1 (0=show controller result, 1=show device result)
2865
+ dry_run?: bool
2866
+ - Behavior:
2867
+ * Preserves every field from GET payload and applies only requested changes.
2868
+ * Validates OSDP result option is applicable (mode != master) when possible.
2869
+ * Returns a concise preview when dry_run=True.
2870
+ """
2871
+ try:
2872
+ # ---------- 0) auth ----------
2873
+ self.check_auth()
2874
+ headers = {
2875
+ "bs-session-id": self.get_session_id(),
2876
+ "Content-Type": "application/json"
2877
+ }
2878
+
2879
+ # ---------- 1) tiny inline helpers (method-local; no external deps) ----------
2880
+ def _norm_mode(v) -> Optional[int]:
2881
+ """Map input to RS-485 mode code: master=1, slave=2, default=3."""
2882
+ if v is None:
2883
+ return None
2884
+ if isinstance(v, (int, float)) and int(v) in (1, 2, 3):
2885
+ return int(v)
2886
+ s = str(v).strip().lower()
2887
+ if s in ("master",):
2888
+ return 1
2889
+ if s in ("slave",):
2890
+ return 2
2891
+ if s in ("default", "device_default", "auto", "장치 기본값"):
2892
+ return 3
2893
+ return None
2894
+
2895
+ def _norm_baud(v) -> Optional[int]:
2896
+ """Allow only supported baud rates."""
2897
+ if v is None:
2898
+ return None
2899
+ try:
2900
+ b = int(str(v))
2901
+ except Exception:
2902
+ return None
2903
+ return b if b in (9600, 19200, 38400, 57600, 115200) else None
2904
+
2905
+ def _norm_show(v) -> Optional[int]:
2906
+ """Map to 0(controller) or 1(device)."""
2907
+ if v is None:
2908
+ return None
2909
+ if isinstance(v, (int, float)) and int(v) in (0, 1):
2910
+ return int(v)
2911
+ s = str(v).strip().lower()
2912
+ if s in ("controller", "0"):
2913
+ return 0
2914
+ if s in ("device", "1"):
2915
+ return 1
2916
+ return None
2917
+
2918
+ async def _http_with_retry(method: str, url: str, **kwargs) -> httpx.Response:
2919
+ last_exc = None
2920
+ for attempt in range(3):
2921
+ try:
2922
+ async with httpx.AsyncClient(
2923
+ verify=False,
2924
+ timeout=httpx.Timeout(10.0, read=20.0)
2925
+ ) as client:
2926
+ resp = await client.request(method, url, **kwargs)
2927
+ if resp.status_code >= 500:
2928
+ await asyncio.sleep(0.6 * (attempt + 1))
2929
+ continue
2930
+ return resp
2931
+ except Exception as e:
2932
+ last_exc = e
2933
+ await asyncio.sleep(0.6 * (attempt + 1))
2934
+ raise RuntimeError(f"HTTP temporary failure: {repr(last_exc)}")
2935
+
2936
+ # ---------- 2) resolve target device (id > name) ----------
2937
+ device_id = None
2938
+ if args.get("device_id") is not None:
2939
+ try:
2940
+ device_id = int(args["device_id"])
2941
+ except Exception:
2942
+ return self.error_response("Invalid 'device_id'.")
2943
+ elif args.get("device_name"):
2944
+ # list all -> exact/contains (case-insensitive) -> must be unique
2945
+ resp = await _http_with_retry(
2946
+ "GET",
2947
+ f"{self.session.config.biostar_url}/api/devices",
2948
+ headers=headers,
2949
+ params={"limit": "0", "order_by": "id:true"}
2950
+ )
2951
+ if resp.status_code != 200:
2952
+ return self.error_response(f"Failed to list devices: {resp.status_code} - {resp.text}")
2953
+ rows = ((resp.json().get("DeviceCollection") or {}).get("rows") or [])
2954
+ q = str(args["device_name"]).strip().casefold()
2955
+ hits = []
2956
+ for r in rows:
2957
+ nm = str(r.get("name") or "").casefold()
2958
+ if nm == q or q in nm:
2959
+ try:
2960
+ hits.append({"id": int(r.get("id")), "name": r.get("name")})
2961
+ except Exception:
2962
+ pass
2963
+ if not hits:
2964
+ return self.error_response("Device with given name not found.", {"query": args["device_name"]})
2965
+ if len(hits) > 1:
2966
+ return self.error_response("Multiple devices matched. Please specify 'device_id'.",
2967
+ {"candidates": hits})
2968
+ device_id = hits[0]["id"]
2969
+ else:
2970
+ return self.error_response("Either 'device_id' or 'device_name' is required.")
2971
+
2972
+ # ---------- 3) normalize inputs ----------
2973
+ mode_code = _norm_mode(args.get("rs485_mode"))
2974
+ baud = _norm_baud(args.get("baud_rate"))
2975
+ show_val = _norm_show(args.get("show_osdp_result"))
2976
+
2977
+ if args.get("rs485_mode") is not None and mode_code is None:
2978
+ return self.error_response("Invalid 'rs485_mode'. Use master|slave|default or 1|2|3.")
2979
+ if args.get("baud_rate") is not None and baud is None:
2980
+ return self.error_response("Invalid 'baud_rate'. Use one of 9600,19200,38400,57600,115200.")
2981
+ if args.get("show_osdp_result") is not None and show_val is None:
2982
+ return self.error_response("Invalid 'show_osdp_result'. Use controller|device or 0|1.")
2983
+
2984
+ # ---------- 4) GET current device (baseline to preserve) ----------
2985
+ get_r = await _http_with_retry(
2986
+ "GET",
2987
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
2988
+ headers=headers
2989
+ )
2990
+ if get_r.status_code != 200:
2991
+ return self.error_response(f"Failed to get device: {get_r.status_code} - {get_r.text}")
2992
+ raw = get_r.json() or {}
2993
+ base = (raw.get("Device") or raw) # accept either shape
2994
+
2995
+ # Make sure rs485 structure exists (non-destructive)
2996
+ if "rs485" not in base or not isinstance(base.get("rs485"), dict):
2997
+ base["rs485"] = {}
2998
+ if "channels" not in base["rs485"] or not isinstance(base["rs485"].get("channels"), list):
2999
+ base["rs485"]["channels"] = [{}]
3000
+ if not base["rs485"]["channels"]:
3001
+ base["rs485"]["channels"].append({})
3002
+
3003
+ # Current mode (for validation of show_osdp_result)
3004
+ current_mode = None
3005
+ try:
3006
+ current_mode = int(str((base.get("rs485") or {}).get("mode")))
3007
+ except Exception:
3008
+ current_mode = None
3009
+ final_mode = mode_code if mode_code is not None else current_mode
3010
+
3011
+ # ---------- 5) Apply requested changes only ----------
3012
+ changed_fields = []
3013
+
3014
+ if mode_code is not None:
3015
+ base["rs485"]["mode"] = mode_code
3016
+ changed_fields.append("rs485.mode")
3017
+
3018
+ if baud is not None:
3019
+ base["rs485"]["channels"][0]["baudrate"] = baud
3020
+ changed_fields.append("rs485.channels[0].baudrate")
3021
+
3022
+ if show_val is not None:
3023
+ # Optional applicability check: show result only when slave/auto(default)
3024
+ # If we know the resulting mode and it's master(1), block; otherwise allow.
3025
+ if final_mode == 1:
3026
+ return self.error_response(
3027
+ "show_osdp_result is not applicable when RS-485 mode is 'master'. Change mode first or omit this field."
3028
+ )
3029
+ # Write to whichever key exists; default to 'showosdpresult'
3030
+ if "showosdpresult" in base or "show_osdp_result" not in base:
3031
+ base["showosdpresult"] = show_val
3032
+ if "show_osdp_result" in base:
3033
+ base["show_osdp_result"] = show_val
3034
+ changed_fields.append("showosdpresult")
3035
+
3036
+ if not changed_fields:
3037
+ return self.error_response(
3038
+ "Nothing to update. Provide at least one of rs485_mode, baud_rate, show_osdp_result.")
3039
+
3040
+ body = {"Device": base}
3041
+
3042
+ # ---------- 6) dry-run preview ----------
3043
+ if bool(args.get("dry_run", False)):
3044
+ return self.success_response({
3045
+ "message": "Dry-run preview. No update performed.",
3046
+ "device_id": device_id,
3047
+ "changed_fields": changed_fields,
3048
+ "payload_preview": body
3049
+ })
3050
+
3051
+ # ---------- 7) PUT update ----------
3052
+ put_r = await _http_with_retry(
3053
+ "PUT",
3054
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
3055
+ headers=headers,
3056
+ json=body
3057
+ )
3058
+ if put_r.status_code not in (200, 204):
3059
+ return self.error_response(
3060
+ f"API call failed: {put_r.status_code} - {put_r.text}",
3061
+ {"request_body": body}
3062
+ )
3063
+
3064
+ return self.success_response({
3065
+ "message": "RS-485 serial settings updated.",
3066
+ "device_id": device_id,
3067
+ "changed_fields": changed_fields,
3068
+ "request_body": body
3069
+ })
3070
+
3071
+ except Exception as e:
3072
+ return await self.handle_api_error(e)
3073
+
3074
+ async def update_device_server_comm(self, args: Dict[str, Any]) -> Sequence[TextContent]:
3075
+ """
3076
+ Update only 'Server Communication' related settings while preserving all other device fields.
3077
+
3078
+ Flow:
3079
+ 1) Resolve target device (id/name/door) using the project's resolver.
3080
+ 2) GET /api/devices/{id} to obtain the baseline payload.
3081
+ 3) Merge only these fields:
3082
+ - connect_from_device -> Device.lan.connection_mode (or aliases)
3083
+ - server_address -> lan.server_ip/server_address or Device.server.{ip/address}/server_connected.ip_addr
3084
+ - server_port -> lan.server_port or Device.server.port
3085
+ 4) PUT /api/devices/{id} with {"Device": <merged>} (minimal change).
3086
+ All messages, comments, and return payloads are in English by request.
3087
+ """
3088
+ try:
3089
+ self.check_auth()
3090
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
3091
+
3092
+ # ---- 1) Resolve target device ----
3093
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
3094
+ if device_id is None:
3095
+ return early
3096
+
3097
+ # ---- 2) Validate inputs ----
3098
+ if not any(k in args for k in ("connect_from_device", "server_address", "server_port")):
3099
+ return self.error_response(
3100
+ "No updatable fields provided (connect_from_device/server_address/server_port).")
3101
+
3102
+ server_addr = args.get("server_address")
3103
+ if server_addr is not None:
3104
+ if not isinstance(server_addr, str) or not server_addr.strip():
3105
+ return self.error_response("server_address must be a non-empty string.")
3106
+ server_addr = server_addr.strip()
3107
+
3108
+ server_port = args.get("server_port")
3109
+ if server_port is not None:
3110
+ try:
3111
+ server_port = int(server_port)
3112
+ if not (1 <= server_port <= 65535):
3113
+ raise ValueError()
3114
+ except Exception:
3115
+ return self.error_response("server_port must be an integer between 1 and 65535.")
3116
+
3117
+ connect_from_device = args.get("connect_from_device")
3118
+ if connect_from_device is not None:
3119
+ connect_from_device = bool(connect_from_device)
3120
+
3121
+ # ---- 3) GET current device ----
3122
+ async with httpx.AsyncClient(verify=False) as client:
3123
+ get_resp = await client.get(
3124
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
3125
+ headers=headers
3126
+ )
3127
+ if get_resp.status_code != 200:
3128
+ return self.error_response(f"GET device failed: {get_resp.status_code} - {get_resp.text}")
3129
+
3130
+ base_json = get_resp.json() or {}
3131
+ device_obj = base_json.get("Device") or {}
3132
+
3133
+ # ---- 4) Small helpers for safe merge ----
3134
+ def assign_with_alias(d: Dict[str, Any], value: Any, aliases: List[str]) -> None:
3135
+ """Write into the first existing key in d; otherwise use the first alias."""
3136
+ key = None
3137
+ for k in aliases:
3138
+ if k in d:
3139
+ key = k
3140
+ break
3141
+ if key is None:
3142
+ key = aliases[0]
3143
+ d[key] = value
3144
+
3145
+ # Copy blocks to avoid mutating references unexpectedly
3146
+ lan = dict(device_obj.get("lan") or {})
3147
+ server_block = dict(device_obj.get("server") or {})
3148
+ server_connected = dict(device_obj.get("server_connected") or {})
3149
+
3150
+ applied: Dict[str, Any] = {}
3151
+
3152
+ # ---- 5) connect_from_device -> connection_mode (preserve existing representation) ----
3153
+ if connect_from_device is not None:
3154
+ existing = lan.get("connection_mode")
3155
+
3156
+ def to_same_type(flag: bool, exemplar) -> Any:
3157
+ """If exemplar looks numeric -> 0/1; if string -> 'device_to_server'/'server_to_device'."""
3158
+ if isinstance(exemplar, (int, float)) or (isinstance(exemplar, str) and exemplar.isdigit()):
3159
+ return 1 if flag else 0
3160
+ if isinstance(exemplar, str):
3161
+ return "device_to_server" if flag else "server_to_device"
3162
+ return "device_to_server" if flag else "server_to_device"
3163
+
3164
+ new_mode = to_same_type(connect_from_device, existing)
3165
+ assign_with_alias(lan, new_mode, ["connection_mode", "conn_mode", "mode", "connectionType"])
3166
+ applied["connect_from_device"] = connect_from_device
3167
+
3168
+ # ---- 6) server address / port mapping (write to multiple likely shapes) ----
3169
+ if server_addr is not None:
3170
+ assign_with_alias(lan, server_addr, ["server_ip", "server_address", "server_host"])
3171
+ assign_with_alias(server_block, server_addr, ["ip", "address", "ip_addr", "host"])
3172
+ assign_with_alias(server_connected, server_addr, ["ip_addr", "ip"])
3173
+ applied["server_address"] = server_addr
3174
+
3175
+ if server_port is not None:
3176
+ assign_with_alias(lan, str(server_port), ["server_port", "port_to_server"])
3177
+ assign_with_alias(server_block, str(server_port), ["port", "server_port"])
3178
+ assign_with_alias(server_connected, str(server_port), ["port"])
3179
+ applied["server_port"] = server_port
3180
+
3181
+ # Merge back
3182
+ out_dev = dict(device_obj)
3183
+ if lan:
3184
+ out_dev["lan"] = lan
3185
+ if server_block:
3186
+ out_dev["server"] = server_block
3187
+ if server_connected:
3188
+ out_dev["server_connected"] = server_connected
3189
+
3190
+ payload = {"Device": out_dev}
3191
+
3192
+ # ---- 7) dry-run ----
3193
+ if bool(args.get("dry_run", False)):
3194
+ return self.success_response({
3195
+ "message": "Dry-run preview. No update performed.",
3196
+ "device_id": int(device_id),
3197
+ "applied": applied,
3198
+ "payload_preview": payload
3199
+ })
3200
+
3201
+ # ---- 8) PUT ----
3202
+ async with httpx.AsyncClient(verify=False) as client:
3203
+ put_resp = await client.put(
3204
+ f"{self.session.config.biostar_url}/api/devices/{int(device_id)}",
3205
+ headers=headers,
3206
+ json=payload
3207
+ )
3208
+ if put_resp.status_code != 200:
3209
+ return self.error_response(
3210
+ f"API call failed: {put_resp.status_code} - {put_resp.text}",
3211
+ {"request_body": payload, "device_id": int(device_id)}
3212
+ )
3213
+
3214
+ return self.success_response({
3215
+ "message": "Server communication settings updated.",
3216
+ "device_id": int(device_id),
3217
+ "applied": applied,
3218
+ "request_body": payload
3219
+ })
3220
+
3221
+ except Exception as e:
3222
+ return await self.handle_api_error(e)
3223
+
3224
+ async def update_device_fingerprint(self, args: Dict[str, Any]) -> Sequence[TextContent]:
3225
+ """
3226
+ Update fingerprint settings only (and authentication.matching_timeout) using a minimal merged payload.
3227
+ API flow:
3228
+ 1) Resolve target device id from args
3229
+ 2) GET /api/devices/{id} to fetch baseline
3230
+ 3) Merge only provided fields into {"Device":{"fingerprint":{...}, "authentication":{"matching_timeout":...}}}
3231
+ 4) PUT /api/devices/{id}
3232
+ All comments, messages, and returned strings are in English.
3233
+ """
3234
+ try:
3235
+ import re # local import to ensure availability
3236
+
3237
+ # --- 0) auth / headers ---
3238
+ self.check_auth()
3239
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
3240
+
3241
+ # --- 1) resolve device id ---
3242
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
3243
+ if device_id is None:
3244
+ return early
3245
+
3246
+ # --- 2) helpers / normalizers (EN-only labels) ---
3247
+ def _norm_str(v: Any) -> str:
3248
+ # unify underscores to spaces, squeeze spaces, lowercase
3249
+ return " ".join(str(v).replace("_", " ").split()).strip().lower()
3250
+
3251
+ def _norm_token(v: Any) -> str:
3252
+ s = str(v).strip().lower()
3253
+ s = re.sub(r'[_\-]+', ' ', s) # underscores/hyphens -> spaces
3254
+ s = re.sub(r'\s+', ' ', s) # collapse spaces
3255
+ return s
3256
+
3257
+ def _bool_str(v: Any) -> Optional[str]:
3258
+ # return "true"/"false" (server accepts these as strings)
3259
+ if v is None:
3260
+ return None
3261
+ if isinstance(v, bool):
3262
+ return "true" if v else "false"
3263
+ s = _norm_str(v)
3264
+ if s in {"true", "1", "yes", "y", "on", "enable", "enabled"}:
3265
+ return "true"
3266
+ if s in {"false", "0", "no", "n", "off", "disable", "disabled"}:
3267
+ return "false"
3268
+ return None
3269
+
3270
+ def _int_in_range(v: Any, lo: int, hi: int) -> Optional[int]:
3271
+ if v is None:
3272
+ return None
3273
+ try:
3274
+ iv = int(v)
3275
+ except Exception:
3276
+ return None
3277
+ return iv if lo <= iv <= hi else None
3278
+
3279
+ # 1:N Security Level: 0=Normal,1=Secure,2=Most Secure
3280
+ def _security(v: Any) -> Optional[int]:
3281
+ if v is None:
3282
+ return None
3283
+ try:
3284
+ iv = int(v)
3285
+ if iv in (0, 1, 2):
3286
+ return iv
3287
+ except Exception:
3288
+ pass
3289
+ s = _norm_str(v)
3290
+ m = {"normal": 0, "secure": 1, "most secure": 2, "mostsecure": 2, "highest": 2}
3291
+ return m.get(s)
3292
+
3293
+ # Fast Mode: 0=Auto,1=Normal,2=Fast,3=Fastest
3294
+ def _fast(v: Any) -> Optional[int]:
3295
+ if v is None:
3296
+ return None
3297
+ try:
3298
+ iv = int(v)
3299
+ if iv in (0, 1, 2, 3):
3300
+ return iv
3301
+ except Exception:
3302
+ pass
3303
+ s = _norm_str(v)
3304
+ m = {"auto": 0, "normal": 1, "fast": 2, "fastest": 3}
3305
+ return m.get(s)
3306
+
3307
+ # Sensor Mode (F2 actual behavior): 0=Always On, 1=Auto On ← NOTE: reversed from our initial assumption
3308
+ def _sensor_mode(v: Any) -> Optional[int]:
3309
+ if v is None:
3310
+ return None
3311
+ # accept ints and numeric strings
3312
+ try:
3313
+ iv = int(str(v).strip())
3314
+ if iv in (0, 1):
3315
+ return iv
3316
+ except Exception:
3317
+ pass
3318
+ s = _norm_token(v)
3319
+ # accept common spellings
3320
+ m = {
3321
+ "always on": 0, "always": 0, "always_on": 0, "always-on": 0,
3322
+ "auto on": 1, "auto": 1, "auto_on": 1, "auto-on": 1,
3323
+ }
3324
+ return m.get(s)
3325
+
3326
+ # LFD: 0=Off,1=Normal,2=Strong,3=Very Strong
3327
+ def _lfd(v: Any) -> Optional[int]:
3328
+ if v is None:
3329
+ return None
3330
+ try:
3331
+ iv = int(v)
3332
+ if iv in (0, 1, 2, 3):
3333
+ return iv
3334
+ except Exception:
3335
+ pass
3336
+ s = _norm_str(v)
3337
+ m = {"off": 0, "normal": 1, "strong": 2, "very strong": 3, "verystrong": 3}
3338
+ return m.get(s)
3339
+
3340
+ # --- 3) normalize requested values ---
3341
+ f_security = _security(args.get("security_level"))
3342
+ f_fast = _fast(args.get("fast_mode"))
3343
+ f_sense = _int_in_range(args.get("sensitivity"), 1, 7)
3344
+ f_scan = _int_in_range(args.get("scan_wait_time"), 1, 20)
3345
+ f_show_img = _bool_str(args.get("show_image"))
3346
+ f_sensor = _sensor_mode(args.get("sensor_mode"))
3347
+ f_adv_enr = _bool_str(args.get("advanced_enrollment"))
3348
+ f_lfd = _lfd(args.get("lfd_level"))
3349
+ f_dup = _bool_str(args.get("duplicate_check"))
3350
+ a_match = _int_in_range(args.get("matching_wait_time"), 1, 20)
3351
+
3352
+ invalids = []
3353
+ def _chk(name: str, val: Any, raw: Any):
3354
+ if raw is None:
3355
+ return
3356
+ if val is None:
3357
+ invalids.append(name)
3358
+
3359
+ _chk("security_level", f_security, args.get("security_level"))
3360
+ _chk("fast_mode", f_fast, args.get("fast_mode"))
3361
+ _chk("sensitivity", f_sense, args.get("sensitivity"))
3362
+ _chk("scan_wait_time", f_scan, args.get("scan_wait_time"))
3363
+ _chk("show_image", f_show_img, args.get("show_image"))
3364
+ _chk("sensor_mode", f_sensor, args.get("sensor_mode"))
3365
+ _chk("advanced_enrollment", f_adv_enr, args.get("advanced_enrollment"))
3366
+ _chk("lfd_level", f_lfd, args.get("lfd_level"))
3367
+ _chk("duplicate_check", f_dup, args.get("duplicate_check"))
3368
+ _chk("matching_wait_time", a_match, args.get("matching_wait_time"))
3369
+
3370
+ if invalids:
3371
+ return self.error_response(f"Invalid values for: {', '.join(invalids)}")
3372
+
3373
+ if all(v is None for v in (f_security, f_fast, f_sense, f_scan, f_show_img, f_sensor, f_adv_enr, f_lfd, f_dup, a_match)):
3374
+ return self.error_response("No fingerprint fields provided.")
3375
+
3376
+ # --- 4) GET baseline ---
3377
+ async def _req(method: str, url: str, **kwargs):
3378
+ last_exc = None
3379
+ for attempt in range(3):
3380
+ try:
3381
+ async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
3382
+ resp = await client.request(method, url, **kwargs)
3383
+ if resp.status_code >= 500:
3384
+ await asyncio.sleep(0.4 * (attempt + 1))
3385
+ continue
3386
+ return resp
3387
+ except Exception as e:
3388
+ last_exc = e
3389
+ await asyncio.sleep(0.4 * (attempt + 1))
3390
+ raise last_exc or RuntimeError("HTTP request failed")
3391
+
3392
+ base_url = self.session.config.biostar_url.rstrip("/")
3393
+ get_resp = await _req("GET", f"{base_url}/api/devices/{device_id}", headers=headers)
3394
+ if get_resp.status_code != 200:
3395
+ return self.error_response(f"GET /api/devices/{device_id} failed: {get_resp.status_code} {get_resp.text}")
3396
+
3397
+ try:
3398
+ device_cur = get_resp.json().get("Device", {})
3399
+ except Exception:
3400
+ return self.error_response("Malformed JSON from GET /api/devices/{id}.")
3401
+
3402
+ # --- 5) merge minimal changes ---
3403
+ # NOTE: Keep existing fingerprint block but override only provided fields.
3404
+ fp: Dict[str, Any] = dict(device_cur.get("fingerprint") or {})
3405
+ # keep string numerics for consistency with UI payloads
3406
+ if f_security is not None: fp["security_level"] = str(f_security)
3407
+ if f_fast is not None: fp["fast_mode"] = str(f_fast)
3408
+ if f_sense is not None: fp["sensitivity"] = str(int(f_sense))
3409
+ if f_scan is not None: fp["scan_timeout"] = str(int(f_scan))
3410
+ if f_show_img is not None: fp["show_image"] = f_show_img # "true"/"false"
3411
+ if f_sensor is not None: fp["sensor_mode"] = str(f_sensor) # << 0=Always On, 1=Auto On
3412
+ if f_adv_enr is not None: fp["advanced_enrollment"] = f_adv_enr
3413
+ if f_lfd is not None: fp["lfd_level"] = str(int(f_lfd))
3414
+ if f_dup is not None: fp["duplicate_check"] = f_dup
3415
+
3416
+ payload: Dict[str, Any] = {"Device": {"fingerprint": fp}}
3417
+ if a_match is not None:
3418
+ auth = dict(device_cur.get("authentication") or {})
3419
+ auth["matching_timeout"] = str(int(a_match))
3420
+ payload["Device"]["authentication"] = auth
3421
+
3422
+ # --- 6) dry-run preview ---
3423
+ if bool(args.get("dry_run")):
3424
+ return [TextContent(type="text", text=str({
3425
+ "status": "dry-run",
3426
+ "device_id": device_id,
3427
+ "request_payload": payload
3428
+ }))]
3429
+
3430
+ # --- 7) PUT update ---
3431
+ put_resp = await _req("PUT", f"{base_url}/api/devices/{device_id}", headers=headers, json=payload)
3432
+ try:
3433
+ body = put_resp.json()
3434
+ except Exception:
3435
+ body = {"raw_text": put_resp.text}
3436
+
3437
+ if put_resp.status_code >= 400:
3438
+ return [TextContent(type="text", text=str({
3439
+ "status": "error",
3440
+ "http_status": put_resp.status_code,
3441
+ "request_payload": payload,
3442
+ "response": body
3443
+ }))]
3444
+
3445
+ return [TextContent(type="text", text=str({
3446
+ "status": "success",
3447
+ "device_id": device_id,
3448
+ "request_payload": payload,
3449
+ "response": body
3450
+ }))]
3451
+
3452
+ except Exception as e:
3453
+ logger.exception("update_device_fingerprint failed")
3454
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
3455
+
3456
+ async def update_device_face(self, args: Dict[str, Any]) -> Sequence[TextContent]:
3457
+ """
3458
+ Update face settings only using a minimal merged payload, with a dedicated
3459
+ device-id resolver that NEVER PUTs using device_name. It will:
3460
+ 1) Use device_id directly if present
3461
+ 2) Else, search devices by device_name or device_search_text via GET /api/devices
3462
+ and bind the resolved id
3463
+ 3) Else, if door_id/door_name is provided, fall back to the generic resolver
3464
+ Keys supported (English-only):
3465
+ - security_level: 0=Normal,1=Secure,2=Most Secure -> face.security_level
3466
+ - motion_sensor (alias: sensitivity): 0=Off,1=Low,2=Medium,3=High -> face.sensitivity
3467
+ - lfd_level: 0=Normal,1=Secure,2=More Secure,3=Most Secure -> face.lfd_level
3468
+ - enroll_timeout: 10..20 (step 1) -> face.scan_wait_time
3469
+ - max_rotation: one of {15,30,45,60,75,90} -> face.max_rotation
3470
+ - detect_distance_min: {30..130 step10} -> face.detection_distance_min
3471
+ - detect_distance_max: {30..130 step10} or 255 ('over_130', ...) -> face.detection_distance_max
3472
+ - operation_mode: 0/1 or "normal"/"fusion" -> face.operation_mode
3473
+ - wide_search (bool) -> face.face_wide_search
3474
+ - fast_enroll (bool) -> face.quick_enrollment
3475
+ - save_image (bool) -> face.store_visual_face_image
3476
+ - duplicate_check (bool) -> face.duplicate_check
3477
+ - light_brightness (aliases: light_condition, light)
3478
+ "normal"→0, "high"→1, "off"/"not use"→3 -> face.proximity_level
3479
+ Returns dry-run preview if args['dry_run'] is truthy.
3480
+ """
3481
+ try:
3482
+ # --- auth ---
3483
+ self.check_auth()
3484
+ headers = {"bs-session-id": self.get_session_id(), "Content-Type": "application/json"}
3485
+
3486
+ # --- dedicated resolver (name -> id first, no PUT by name) ---
3487
+ async def _resolve_face_target_device_id() -> Tuple[Optional[int], Optional[Sequence[TextContent]]]:
3488
+ # 1) device_id wins
3489
+ raw_id = args.get("device_id")
3490
+ if raw_id not in (None, ""):
3491
+ try:
3492
+ return int(raw_id), None
3493
+ except Exception:
3494
+ pass # fall through to name search
3495
+
3496
+ # 2) name / search_text lookup
3497
+ q = args.get("device_name") or args.get("device_search_text")
3498
+ if isinstance(q, str) and q.strip():
3499
+ try:
3500
+ rows = await self._fetch_all_devices(headers)
3501
+ except Exception as e:
3502
+ return None, self.error_response(f"Failed to list devices: {type(e).__name__}: {e}")
3503
+ if not rows:
3504
+ return None, self.error_response("No devices found while resolving device_name.")
3505
+
3506
+ def norm(s: str) -> str:
3507
+ s = s.lower()
3508
+ s = re.sub(r'[\s\-_]+', ' ', s).strip()
3509
+ return s
3510
+
3511
+ qn = norm(q)
3512
+ # exact match first
3513
+ exact = [r for r in rows if norm(str(r.get("name", ""))) == qn]
3514
+ if len(exact) == 1 and str(exact[0].get("id", "")).isdigit():
3515
+ return int(exact[0]["id"]), None
3516
+
3517
+ # substring contains
3518
+ subs = [r for r in rows if qn in norm(str(r.get("name", "")))]
3519
+ if len(subs) == 1 and str(subs[0].get("id", "")).isdigit():
3520
+ return int(subs[0]["id"]), None
3521
+ if len(subs) > 1:
3522
+ # pick the closest length match as a simple tie-breaker
3523
+ subs.sort(key=lambda r: abs(len(norm(r.get("name", ""))) - len(qn)))
3524
+ if str(subs[0].get("id", "")).isdigit():
3525
+ return int(subs[0]["id"]), None
3526
+
3527
+ return None, self.error_response(f"Device not uniquely resolved from name '{q}'.")
3528
+
3529
+ # 3) fall back to generic only for door-based resolution
3530
+ if args.get("door_id") is not None or args.get("door_name") is not None:
3531
+ dev_id, early = await self._resolve_device_id_from_args(args, headers)
3532
+ if dev_id is None:
3533
+ return None, early
3534
+ return dev_id, None
3535
+
3536
+ return None, self.error_response("Device not resolved from args")
3537
+
3538
+ device_id, early = await _resolve_face_target_device_id()
3539
+ if device_id is None:
3540
+ return early
3541
+
3542
+ # ---------- helpers ----------
3543
+ def _norm_token(v: Any) -> str:
3544
+ s = str(v).strip().lower().replace("_", " ").replace("-", " ")
3545
+ return " ".join(s.split())
3546
+
3547
+ def _bool(v: Any) -> Optional[bool]:
3548
+ if v is None:
3549
+ return None
3550
+ if isinstance(v, bool):
3551
+ return v
3552
+ s = _norm_token(v)
3553
+ if s in {"true", "1", "yes", "on", "enable", "enabled"}:
3554
+ return True
3555
+ if s in {"false", "0", "no", "off", "disable", "disabled"}:
3556
+ return False
3557
+ return None
3558
+
3559
+ def _int_in_range(v: Any, lo: int, hi: int) -> Optional[int]:
3560
+ if v is None:
3561
+ return None
3562
+ try:
3563
+ iv = int(v)
3564
+ except Exception:
3565
+ return None
3566
+ return iv if lo <= iv <= hi else None
3567
+
3568
+ def _int_in_set(v: Any, allowed: Iterable[int]) -> Optional[int]:
3569
+ if v is None:
3570
+ return None
3571
+ try:
3572
+ iv = int(v)
3573
+ return iv if iv in allowed else None
3574
+ except Exception:
3575
+ return None
3576
+
3577
+ # enums
3578
+ def _security(v: Any) -> Optional[int]:
3579
+ if v is None:
3580
+ return None
3581
+ try:
3582
+ iv = int(v)
3583
+ if iv in (0, 1, 2):
3584
+ return iv
3585
+ except Exception:
3586
+ pass
3587
+ m = {"normal": 0, "secure": 1, "most secure": 2}
3588
+ return m.get(_norm_token(v))
3589
+
3590
+ def _motion(v: Any) -> Optional[int]:
3591
+ # 0=Off,1=Low,2=Medium,3=High → face.sensitivity
3592
+ if v is None:
3593
+ return None
3594
+ try:
3595
+ iv = int(v)
3596
+ if iv in (0, 1, 2, 3):
3597
+ return iv
3598
+ except Exception:
3599
+ pass
3600
+ m = {"off": 0, "low": 1, "medium": 2, "high": 3}
3601
+ return m.get(_norm_token(v))
3602
+
3603
+ def _lfd(v: Any) -> Optional[int]:
3604
+ if v is None:
3605
+ return None
3606
+ try:
3607
+ iv = int(v)
3608
+ if iv in (0, 1, 2, 3):
3609
+ return iv
3610
+ except Exception:
3611
+ pass
3612
+ m = {"normal": 0, "secure": 1, "more secure": 2, "most secure": 3}
3613
+ return m.get(_norm_token(v))
3614
+
3615
+ def _op_mode(v: Any) -> Optional[int]:
3616
+ if v is None:
3617
+ return None
3618
+ try:
3619
+ iv = int(v)
3620
+ if iv in (0, 1):
3621
+ return iv
3622
+ except Exception:
3623
+ pass
3624
+ m = {"normal": 0, "fusion": 1}
3625
+ return m.get(_norm_token(v))
3626
+
3627
+ def _brightness_to_proximity(v: Any) -> Optional[int]:
3628
+ """
3629
+ Light brightness (preview) mapped to face.proximity_level:
3630
+ normal -> 0
3631
+ high -> 1
3632
+ off / not use -> 3
3633
+ Only these three values are valid; value '2' is intentionally not used.
3634
+ """
3635
+ if v is None:
3636
+ return None
3637
+ try:
3638
+ iv = int(v)
3639
+ if iv in (0, 1, 3):
3640
+ return iv
3641
+ except Exception:
3642
+ pass
3643
+ t = _norm_token(v)
3644
+ m = {"normal": 0, "high": 1, "not use": 3, "off": 3}
3645
+ return m.get(t)
3646
+
3647
+ # ---------- normalize requested values ----------
3648
+ sec = _security(args.get("security_level"))
3649
+ mot = _motion(args.get("motion_sensor") if args.get("motion_sensor") is not None else args.get("sensitivity"))
3650
+ lfd = _lfd(args.get("lfd_level"))
3651
+ etime = _int_in_range(args.get("enroll_timeout"), 10, 20) # step 1, no snapping
3652
+ rot = _int_in_set(args.get("max_rotation"), {15, 30, 45, 60, 75, 90})
3653
+ dmin = _int_in_set(args.get("detect_distance_min"), {30,40,50,60,70,80,90,100,110,120,130})
3654
+ # accept int 255 or string aliases for over 130
3655
+ ddmax_raw = args.get("detect_distance_max")
3656
+ dmax = None
3657
+ if ddmax_raw is not None:
3658
+ if str(ddmax_raw).strip() == "255":
3659
+ dmax = 255
3660
+ else:
3661
+ tok = _norm_token(ddmax_raw)
3662
+ if tok in {"over 130", "above 130", "over-130", "130 plus", "130plus", "over 130cm", "above 130cm"}:
3663
+ dmax = 255
3664
+ else:
3665
+ dmax = _int_in_set(ddmax_raw, {30,40,50,60,70,80,90,100,110,120,130})
3666
+
3667
+ op = _op_mode(args.get("operation_mode"))
3668
+ wide = _bool(args.get("wide_search"))
3669
+ fast = _bool(args.get("fast_enroll"))
3670
+ save = _bool(args.get("save_image"))
3671
+ dup = _bool(args.get("duplicate_check"))
3672
+
3673
+ # brightness source keys (do NOT use preview_option; we map to proximity_level)
3674
+ lb_src = (
3675
+ args.get("light_brightness")
3676
+ if args.get("light_brightness") is not None else
3677
+ args.get("light_condition")
3678
+ if args.get("light_condition") is not None else
3679
+ args.get("light")
3680
+ )
3681
+ prox = _brightness_to_proximity(lb_src)
3682
+
3683
+ # validate provided-but-invalid
3684
+ invalids = []
3685
+ def _chk(name: str, val: Any, raw: Any):
3686
+ if raw is not None and val is None:
3687
+ invalids.append(name)
3688
+
3689
+ _chk("security_level", sec, args.get("security_level"))
3690
+ _chk("motion_sensor", mot, args.get("motion_sensor") if args.get("motion_sensor") is not None else args.get("sensitivity"))
3691
+ _chk("lfd_level", lfd, args.get("lfd_level"))
3692
+ _chk("enroll_timeout", etime, args.get("enroll_timeout"))
3693
+ _chk("max_rotation", rot, args.get("max_rotation"))
3694
+ _chk("detect_distance_min", dmin, args.get("detect_distance_min"))
3695
+ _chk("detect_distance_max", dmax, ddmax_raw)
3696
+ _chk("operation_mode", op, args.get("operation_mode"))
3697
+ _chk("wide_search", wide, args.get("wide_search"))
3698
+ _chk("fast_enroll", fast, args.get("fast_enroll"))
3699
+ _chk("save_image", save, args.get("save_image"))
3700
+ _chk("duplicate_check", dup, args.get("duplicate_check"))
3701
+ _chk("light_brightness", prox, lb_src)
3702
+
3703
+ if invalids:
3704
+ return self.error_response(f"Invalid values for: {', '.join(invalids)}")
3705
+
3706
+ if all(v is None for v in (sec, mot, lfd, etime, rot, dmin, dmax, op, wide, fast, save, dup, prox)):
3707
+ return self.error_response("No face fields provided.")
3708
+
3709
+ # ---------- GET baseline ----------
3710
+ async def _req(method: str, url: str, **kwargs):
3711
+ last_exc = None
3712
+ for attempt in range(3):
3713
+ try:
3714
+ async with httpx.AsyncClient(verify=False, timeout=httpx.Timeout(10.0, read=20.0)) as client:
3715
+ resp = await client.request(method, url, **kwargs)
3716
+ if resp.status_code >= 500:
3717
+ await asyncio.sleep(0.4 * (attempt + 1))
3718
+ continue
3719
+ return resp
3720
+ except Exception as e:
3721
+ last_exc = e
3722
+ await asyncio.sleep(0.4 * (attempt + 1))
3723
+ raise last_exc or RuntimeError("HTTP request failed")
3724
+
3725
+ base_url = self.session.config.biostar_url.rstrip("/")
3726
+ get_resp = await _req("GET", f"{base_url}/api/devices/{device_id}", headers=headers)
3727
+ if get_resp.status_code != 200:
3728
+ return self.error_response(f"GET /api/devices/{device_id} failed: {get_resp.status_code} {get_resp.text}")
3729
+
3730
+ try:
3731
+ device_cur = get_resp.json().get("Device", {})
3732
+ except Exception:
3733
+ return self.error_response("Malformed JSON from GET /api/devices/{id}.")
3734
+
3735
+ # ---------- merge minimal changes ----------
3736
+ face: Dict[str, Any] = dict(device_cur.get("face") or {})
3737
+ if sec is not None: face["security_level"] = str(sec)
3738
+ if mot is not None: face["sensitivity"] = str(mot) # maps to motion sensor level
3739
+ if lfd is not None: face["lfd_level"] = str(lfd)
3740
+ if etime is not None: face["scan_wait_time"] = int(etime)
3741
+ if rot is not None: face["max_rotation"] = int(rot)
3742
+ if dmin is not None: face["detection_distance_min"] = int(dmin)
3743
+ if dmax is not None: face["detection_distance_max"] = int(dmax)
3744
+ if op is not None: face["operation_mode"] = int(op)
3745
+ if wide is not None: face["face_wide_search"] = "true" if wide else "false"
3746
+ if fast is not None: face["quick_enrollment"] = "true" if fast else "false"
3747
+ if save is not None: face["store_visual_face_image"] = "true" if save else "false"
3748
+ if dup is not None: face["duplicate_check"] = "true" if dup else "false"
3749
+ if prox is not None: face["proximity_level"] = str(prox) # brightness -> proximity_level (0/1/3)
3750
+
3751
+ payload: Dict[str, Any] = {"Device": {"face": face}}
3752
+
3753
+ # dry-run
3754
+ if bool(args.get("dry_run")):
3755
+ return [TextContent(type="text", text=str({
3756
+ "status": "dry-run",
3757
+ "device_id": device_id,
3758
+ "request_payload": payload
3759
+ }))]
3760
+
3761
+ # PUT
3762
+ put_resp = await _req("PUT", f"{base_url}/api/devices/{device_id}", headers=headers, json=payload)
3763
+ try:
3764
+ body = put_resp.json()
3765
+ except Exception:
3766
+ body = {"raw_text": put_resp.text}
3767
+
3768
+ if put_resp.status_code >= 400:
3769
+ return [TextContent(type="text", text=str({
3770
+ "status": "error",
3771
+ "http_status": put_resp.status_code,
3772
+ "request_payload": payload,
3773
+ "response": body
3774
+ }))]
3775
+
3776
+ return [TextContent(type="text", text=str({
3777
+ "status": "success",
3778
+ "device_id": device_id,
3779
+ "request_payload": payload,
3780
+ "response": body
3781
+ }))]
3782
+
3783
+ except Exception as e:
3784
+ logger.exception("update_device_face failed")
3785
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
3786
+
3787
+ # ---------------------------------------------------------------------------------
3788
+ # UPDATED: auto-enable csn when sub-options imply CSN usage,
3789
+ # and if CSN is on with neither EM/MIFARE chosen, auto-enable both.
3790
+ # ---------------------------------------------------------------------------------
3791
+ async def update_device_card_csn(self, args: Dict[str, Any]) -> Sequence[TextContent]:
3792
+ """
3793
+ UI-aligned CSN updater: fetch full Device, mutate required fields, PUT full Device back.
3794
+
3795
+ Why this approach:
3796
+ - The device may ignore a minimal patch for 'use_csn'. The UI sends a full snapshot payload.
3797
+ - Turning CSN OFF in UI also puts wiegand.wiegand_csn_id.id = "-1" (based on your captured payload).
3798
+ - We replicate that behavior exactly while preserving all other fields.
3799
+
3800
+ Inputs (at least one target resolver + csn_enabled are required):
3801
+ - Target: device_id | device_name | device_search_text | door_id | door_name (+ edge when needed)
3802
+ - csn_enabled: bool (required)
3803
+ - em4100_enabled?: bool -> Device.card.use_em
3804
+ - mifare_felica_enabled?: bool -> Device.card.use_mifare_felica
3805
+ - byte_order?: "LSB"|"MSB"|0|1 -> Device.card.byte_order (LSB="1", MSB="0")
3806
+ - format_type?: "wiegand"|"normal" -> Device.card.use_wiegand_format (UI toggle only; no format-id change)
3807
+ - wiegand_format?: string -> Human-friendly name (mapped via WIEGAND_CSN_FORMATS)
3808
+ - wiegand_format_id?: int|string -> Device.wiegand.wiegand_csn_id (format row or {"id": "<n>"})
3809
+ - also_clear_wiegand_csn?: bool -> If true and csn_enabled=false, force wiegand_csn_id to "-1" (UI behavior)
3810
+ - dry_run?: bool -> Preview without PUT
3811
+
3812
+ Runtime rules added:
3813
+ - If any CSN sub-option is enabled or a Wiegand format is selected, csn_enabled is auto-forced to True.
3814
+ - If resulting csn_enabled is True and neither EM4100 nor MIFARE/Felica is selected, both will be auto-enabled.
3815
+ """
3816
+ try:
3817
+ # 0) session/auth
3818
+ self.check_auth()
3819
+ headers = {
3820
+ "bs-session-id": self.get_session_id(),
3821
+ "Content-Type": "application/json",
3822
+ }
3823
+
3824
+ # 1) resolve target device id using your existing resolver
3825
+ device_id, early = await self._resolve_device_id_from_args(args, headers)
3826
+ if device_id is None:
3827
+ return early
3828
+ device_id = int(device_id)
3829
+
3830
+ # 2) helpers (unchanged + small additions)
3831
+ def to_bool(v: Any) -> bool:
3832
+ if isinstance(v, bool):
3833
+ return v
3834
+ s = str(v).strip().lower()
3835
+ return s in {"1", "true", "yes", "y", "on", "enabled"}
3836
+
3837
+ def to_bs_bool(v: Any) -> str:
3838
+ return "true" if to_bool(v) else "false"
3839
+
3840
+ def map_byte_order(v: Any) -> Optional[str]:
3841
+ """Return '1' for LSB, '0' for MSB; None if not provided/invalid."""
3842
+ if v is None:
3843
+ return None
3844
+ s = str(v).strip().lower()
3845
+ if s in {"lsb", "1"}:
3846
+ return "1"
3847
+ if s in {"msb", "0"}:
3848
+ return "0"
3849
+ return None
3850
+
3851
+ def is_true_like(v: Any) -> bool:
3852
+ """Robust truthy check for existing device snapshot fields ('true'/'false', 1/0, etc.)."""
3853
+ if isinstance(v, bool):
3854
+ return v
3855
+ s = str(v).strip().lower()
3856
+ return s in {"true", "1", "yes", "y", "on", "enabled"}
3857
+
3858
+ if "csn_enabled" not in args:
3859
+ return self.error_response("Missing required argument: csn_enabled")
3860
+
3861
+ # --- auto-enable decision before GET baseline
3862
+ fmt = args.get("format_type")
3863
+ auto_enable_from_fmt = bool(fmt) and str(fmt).strip().lower() in {"wiegand", "wg", "on", "true", "1"}
3864
+ auto_enable = any([
3865
+ to_bool(args.get("em4100_enabled")),
3866
+ to_bool(args.get("mifare_felica_enabled")),
3867
+ auto_enable_from_fmt,
3868
+ args.get("wiegand_format") is not None,
3869
+ args.get("wiegand_format_id") is not None,
3870
+ args.get("byte_order") is not None,
3871
+ ])
3872
+
3873
+ want_csn_input = to_bool(args["csn_enabled"])
3874
+ want_csn = bool(want_csn_input or auto_enable) # requirement #1
3875
+
3876
+ also_clear_wg = bool(args.get("also_clear_wiegand_csn", True)) # default True to mirror UI
3877
+ dry_run = bool(args.get("dry_run"))
3878
+
3879
+ # 3) GET full device (baseline snapshot)
3880
+ async with httpx.AsyncClient(verify=False) as client:
3881
+ get_resp = await client.get(
3882
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
3883
+ headers=headers,
3884
+ )
3885
+ if get_resp.status_code != 200:
3886
+ return self.error_response(
3887
+ f"GET device failed: {get_resp.status_code} - {get_resp.text}"
3888
+ )
3889
+
3890
+ base = get_resp.json() or {}
3891
+ device_obj = base.get("Device") or base # accept {Device:{...}} or flat
3892
+ if not isinstance(device_obj, dict):
3893
+ return self.error_response("Malformed device payload from GET.")
3894
+
3895
+ # Ensure required nested structures exist
3896
+ device_obj.setdefault("card", {})
3897
+ device_obj.setdefault("wiegand", {})
3898
+
3899
+ card = device_obj["card"]
3900
+ wiegand = device_obj["wiegand"]
3901
+
3902
+ # 4) Apply changes (UI semantics)
3903
+ # Always set the desired CSN flag (with auto-enable applied)
3904
+ card["use_csn"] = to_bs_bool(want_csn)
3905
+
3906
+ # Optional subtype toggles: if provided, overwrite; else preserve as-is
3907
+ if "em4100_enabled" in args:
3908
+ card["use_em"] = to_bs_bool(args["em4100_enabled"])
3909
+ if "mifare_felica_enabled" in args:
3910
+ card["use_mifare_felica"] = to_bs_bool(args["mifare_felica_enabled"])
3911
+
3912
+ # Optional byte order: LSB='1', MSB='0'
3913
+ bo = map_byte_order(args.get("byte_order"))
3914
+ if bo is not None:
3915
+ card["byte_order"] = bo
3916
+
3917
+ # Optional format type: only toggles card.use_wiegand_format (do NOT touch wiegand.* formats here)
3918
+ if fmt is not None:
3919
+ ft = str(fmt).strip().lower()
3920
+ if ft in {"wiegand", "wg", "on", "true", "1"}:
3921
+ card["use_wiegand_format"] = "true"
3922
+ elif ft in {"normal", "off", "false", "0"}:
3923
+ card["use_wiegand_format"] = "false"
3924
+ else:
3925
+ return self.error_response("Invalid format_type. Use 'wiegand' or 'normal'.")
3926
+
3927
+ # ---------------- human-name → id mapping for Wiegand CSN ----------------
3928
+ name_arg = args.get("wiegand_format")
3929
+ id_arg = args.get("wiegand_format_id")
3930
+
3931
+ resolved_wf_id: Optional[str] = None
3932
+ if name_arg is not None:
3933
+ key = str(name_arg).strip()
3934
+ resolved_wf_id = WIEGAND_CSN_FORMATS.get(key)
3935
+ if resolved_wf_id is None:
3936
+ for k, v in WIEGAND_CSN_FORMATS.items():
3937
+ if k.lower() == key.lower():
3938
+ resolved_wf_id = v
3939
+ break
3940
+ if resolved_wf_id is None:
3941
+ return self.error_response(f"Unknown wiegand_format: {name_arg}")
3942
+
3943
+ if id_arg is not None:
3944
+ try:
3945
+ id_norm = str(int(str(id_arg).strip()))
3946
+ except Exception:
3947
+ return self.error_response(
3948
+ "Invalid wiegand_format_id. It must be an integer or integer-like string.")
3949
+ if resolved_wf_id is not None and id_norm != resolved_wf_id:
3950
+ return self.error_response(
3951
+ f"wiegand_format ('{name_arg}') conflicts with wiegand_format_id ('{id_norm}')."
3952
+ )
3953
+ resolved_wf_id = id_norm
3954
+
3955
+ if resolved_wf_id is not None:
3956
+ card["use_wiegand_format"] = "true"
3957
+ row = await self._fetch_wiegand_format_row(headers, resolved_wf_id)
3958
+ if row:
3959
+ wiegand["wiegand_csn_id"] = row
3960
+ else:
3961
+ wiegand["wiegand_csn_id"] = {"id": resolved_wf_id}
3962
+
3963
+ # Crucial: When turning CSN OFF, UI snapshot shows wiegand_csn_id.id = "-1".
3964
+ if not want_csn and also_clear_wg:
3965
+ wg_id = (wiegand.get("wiegand_csn_id") or {}).get("id")
3966
+ if str(wg_id) != "-1":
3967
+ wiegand["wiegand_csn_id"] = {"id": "-1"}
3968
+
3969
+ # --- requirement #2 (revised): if CSN is on and neither EM/MIFARE is selected, auto-enable both.
3970
+ em_effective = is_true_like(card.get("use_em"))
3971
+ mifare_effective = is_true_like(card.get("use_mifare_felica"))
3972
+ if want_csn and not (em_effective or mifare_effective):
3973
+ # Auto-fix: enable both when user didn't choose either
3974
+ card["use_em"] = "true"
3975
+ card["use_mifare_felica"] = "true"
3976
+ em_effective = True
3977
+ mifare_effective = True
3978
+
3979
+ full_payload = {"Device": device_obj}
3980
+
3981
+ # 5) Dry-run
3982
+ if dry_run:
3983
+ return self.success_response({
3984
+ "message": "[dry_run] Would PUT full Device snapshot aligned with UI behavior.",
3985
+ "device_id": device_id,
3986
+ "request_payload": full_payload
3987
+ })
3988
+
3989
+ # 6) PUT full snapshot (UI-like)
3990
+ async with httpx.AsyncClient(verify=False) as client:
3991
+ put_resp = await client.put(
3992
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
3993
+ headers=headers,
3994
+ json=full_payload,
3995
+ )
3996
+ if put_resp.status_code != 200:
3997
+ return self.error_response(
3998
+ f"PUT failed: {put_resp.status_code} - {put_resp.text}",
3999
+ {"request_body": full_payload, "device_id": device_id}
4000
+ )
4001
+
4002
+ # 7) Verify by GET (keep as-is)
4003
+ verify = {}
4004
+ try:
4005
+ async with httpx.AsyncClient(verify=False) as client:
4006
+ v = await client.get(
4007
+ f"{self.session.config.biostar_url}/api/devices/{device_id}",
4008
+ headers=headers,
4009
+ )
4010
+ if v.status_code == 200:
4011
+ vv = (v.json() or {}).get("Device", {}) or {}
4012
+ vc = vv.get("card", {}) or {}
4013
+ verify = {
4014
+ "use_csn": str(vc.get("use_csn")),
4015
+ "use_em": str(vc.get("use_em")),
4016
+ "use_mifare_felica": str(vc.get("use_mifare_felica")),
4017
+ "byte_order": str(vc.get("byte_order")),
4018
+ "use_wiegand_format": str(vc.get("use_wiegand_format")),
4019
+ }
4020
+ except Exception:
4021
+ pass
4022
+
4023
+ return self.success_response({
4024
+ "message": "Device CSN settings updated successfully.",
4025
+ "device_id": device_id,
4026
+ "request_payload": full_payload,
4027
+ "verify": verify
4028
+ })
4029
+
4030
+ except Exception as e:
4031
+ logger.exception("update_device_card_csn failed")
4032
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
4033
+
4034
+ async def _fetch_wiegand_format_row(self, headers: Dict[str, str], format_id: str) -> Optional[Dict[str, Any]]:
4035
+ """
4036
+ GET /api/wiegand_formats and return the matching row object by id.
4037
+
4038
+ Returns:
4039
+ - dict: Full row object of the selected Wiegand format (mirrors what the UI sends)
4040
+ - None: If not found or on any error
4041
+ """
4042
+ try:
4043
+ async with httpx.AsyncClient(verify=False) as client:
4044
+ resp = await client.get(
4045
+ f"{self.session.config.biostar_url}/api/wiegand_formats",
4046
+ headers=headers
4047
+ )
4048
+ if resp.status_code != 200:
4049
+ # Best-effort: UI sometimes sends only {"id": "<n>"}.
4050
+ # We'll fall back to id-only if collection fetch fails.
4051
+ return None
4052
+
4053
+ data = resp.json() or {}
4054
+ rows = (data.get("WiegandFormatCollection") or {}).get("rows", []) or []
4055
+ for row in rows:
4056
+ if str(row.get("id")) == str(format_id):
4057
+ return row
4058
+ except Exception:
4059
+ # Swallow and let caller fall back to id-only form.
4060
+ pass
4061
+ return None
4062
+
4063
+ async def list_waiting_devices(self, args: Dict[str, Any]) -> Sequence[TextContent]:
4064
+ """
4065
+ List devices waiting to be added to BioStar 2 server.
4066
+ GET /api/devices/waiting
4067
+ """
4068
+ try:
4069
+ self.check_auth()
4070
+
4071
+ headers = {
4072
+ "bs-session-id": self.get_session_id(),
4073
+ "Content-Type": "application/json"
4074
+ }
4075
+
4076
+ async with httpx.AsyncClient(verify=False) as client:
4077
+ response = await client.get(
4078
+ f"{self.session.config.biostar_url}/api/devices/waiting",
4079
+ headers=headers
4080
+ )
4081
+
4082
+ if response.status_code != 200:
4083
+ return self.error_response(f"Failed to retrieve waiting devices: {response.status_code} - {response.text}")
4084
+
4085
+ data = response.json()
4086
+ device_collection = data.get("DeviceCollection", {})
4087
+ rows = device_collection.get("rows", [])
4088
+
4089
+ # Apply filters
4090
+ ip_filter = args.get("ip_filter")
4091
+ device_type_id = args.get("device_type_id")
4092
+
4093
+ filtered_rows = rows
4094
+ if ip_filter:
4095
+ filtered_rows = [r for r in filtered_rows if ip_filter in r.get("lan", {}).get("ip", "")]
4096
+ if device_type_id:
4097
+ filtered_rows = [r for r in filtered_rows if str(r.get("device_type_id", {}).get("id", "")) == str(device_type_id)]
4098
+
4099
+ result = {
4100
+ "total": len(filtered_rows),
4101
+ "devices": []
4102
+ }
4103
+
4104
+ for device in filtered_rows:
4105
+ device_info = {
4106
+ "id": device.get("id"),
4107
+ "device_type_id": device.get("device_type_id", {}).get("id"),
4108
+ "ip": device.get("lan", {}).get("ip"),
4109
+ "connection_mode": device.get("lan", {}).get("connection_mode"),
4110
+ "status": device.get("connection", {}).get("status"),
4111
+ "support_alphanumeric": device.get("capacity", {}).get("support_alphanumeric"),
4112
+ "follower_server_id": device.get("follower_server_id")
4113
+ }
4114
+ result["devices"].append(device_info)
4115
+
4116
+ return self.success_response(result)
4117
+
4118
+ except Exception as e:
4119
+ logger.exception("list_waiting_devices failed")
4120
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
4121
+
4122
+ async def add_waiting_device(self, args: Dict[str, Any]) -> Sequence[TextContent]:
4123
+ """
4124
+ Add a device from the waiting list to BioStar 2 server.
4125
+ POST /api/devices with waiting device details.
4126
+ """
4127
+ try:
4128
+ self.check_auth()
4129
+
4130
+ headers = {
4131
+ "bs-session-id": self.get_session_id(),
4132
+ "Content-Type": "application/json"
4133
+ }
4134
+
4135
+ waiting_device_id = str(args["waiting_device_id"])
4136
+
4137
+ # First, get the waiting device details
4138
+ async with httpx.AsyncClient(verify=False) as client:
4139
+ waiting_response = await client.get(
4140
+ f"{self.session.config.biostar_url}/api/devices/waiting",
4141
+ headers=headers
4142
+ )
4143
+
4144
+ if waiting_response.status_code != 200:
4145
+ return self.error_response(f"Failed to retrieve waiting devices: {waiting_response.status_code}")
4146
+
4147
+ waiting_data = waiting_response.json()
4148
+ waiting_devices = waiting_data.get("DeviceCollection", {}).get("rows", [])
4149
+
4150
+ # Find the target device
4151
+ target_device = None
4152
+ for device in waiting_devices:
4153
+ if str(device.get("id")) == waiting_device_id:
4154
+ target_device = device
4155
+ break
4156
+
4157
+ if not target_device:
4158
+ return self.error_response(f"Device ID {waiting_device_id} not found in waiting list. Use list-waiting-devices first.")
4159
+
4160
+ # Extract device information
4161
+ device_type_id = args.get("device_type_id") or target_device.get("device_type_id", {}).get("id")
4162
+ ip_address = args.get("ip_address") or target_device.get("lan", {}).get("ip")
4163
+ connection_mode = target_device.get("lan", {}).get("connection_mode", "1")
4164
+ status = target_device.get("connection", {}).get("status", "3")
4165
+ support_alphanumeric = target_device.get("capacity", {}).get("support_alphanumeric", "true")
4166
+ use_alphanumeric = str(args.get("use_alphanumeric", True)).lower()
4167
+ follower_server_id = str(args.get("follower_server_id", target_device.get("follower_server_id", "1")))
4168
+ device_group_id = args.get("device_group_id", 1)
4169
+ pktversion = target_device.get("pktversion", "3")
4170
+
4171
+ # Generate device name if not provided
4172
+ device_name = args.get("name")
4173
+ if not device_name:
4174
+ # Get device type name mapping
4175
+ device_type_map = {
4176
+ "35": "BioStation 3",
4177
+ "34": "BioStation 2a",
4178
+ "33": "FaceStation F2",
4179
+ "8": "BioStation 2",
4180
+ "9": "BioStation A2"
4181
+ }
4182
+ device_type_name = device_type_map.get(str(device_type_id), f"Device Type {device_type_id}")
4183
+ device_name = f"{device_type_name} {waiting_device_id} ({ip_address})"
4184
+
4185
+ # Build payload following the user's example
4186
+ payload = {
4187
+ "FollowerServer": {
4188
+ "id": follower_server_id
4189
+ },
4190
+ "Device": {
4191
+ "id": waiting_device_id,
4192
+ "device_type_id": {
4193
+ "id": str(device_type_id)
4194
+ },
4195
+ "connection": {
4196
+ "status": status
4197
+ },
4198
+ "lan": {
4199
+ "ip": ip_address,
4200
+ "connection_mode": connection_mode
4201
+ },
4202
+ "system": {
4203
+ "use_alphanumeric": use_alphanumeric
4204
+ },
4205
+ "capacity": {
4206
+ "support_alphanumeric": support_alphanumeric
4207
+ },
4208
+ "support_occupancy": target_device.get("support_occupancy", "false"),
4209
+ "pktversion": pktversion,
4210
+ "follower_server_id": follower_server_id,
4211
+ "name": device_name,
4212
+ "device_group_id": {
4213
+ "id": device_group_id
4214
+ }
4215
+ },
4216
+ "set_alphanumeric": use_alphanumeric
4217
+ }
4218
+
4219
+ # POST to add device
4220
+ async with httpx.AsyncClient(verify=False) as client:
4221
+ response = await client.post(
4222
+ f"{self.session.config.biostar_url}/api/devices",
4223
+ headers=headers,
4224
+ json=payload
4225
+ )
4226
+
4227
+ if response.status_code not in [200, 201]:
4228
+ return self.error_response(f"Failed to add device: {response.status_code} - {response.text}")
4229
+
4230
+ response_data = response.json()
4231
+
4232
+ return self.success_response({
4233
+ "message": f"Device '{device_name}' added successfully from waiting list",
4234
+ "device_id": waiting_device_id,
4235
+ "device_name": device_name,
4236
+ "ip_address": ip_address,
4237
+ "response": response_data
4238
+ })
4239
+
4240
+ except Exception as e:
4241
+ logger.exception("add_waiting_device failed")
4242
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
4243
+
4244
+ async def bulk_add_waiting_devices(self, args: Dict[str, Any]) -> Sequence[TextContent]:
4245
+ """
4246
+ Add multiple devices from the waiting list in bulk.
4247
+ Iterates through each device and calls add_waiting_device logic.
4248
+ """
4249
+ try:
4250
+ self.check_auth()
4251
+
4252
+ waiting_device_ids = args.get("waiting_device_ids", [])
4253
+ if not isinstance(waiting_device_ids, (list, tuple)) or not waiting_device_ids:
4254
+ return self.error_response("Provide 'waiting_device_ids' as a non-empty array")
4255
+
4256
+ continue_on_error = bool(args.get("continue_on_error", True))
4257
+ device_group_id = args.get("device_group_id", 1)
4258
+ name_prefix = args.get("name_prefix", "")
4259
+ use_alphanumeric = args.get("use_alphanumeric", True)
4260
+
4261
+ results = []
4262
+ success_count = 0
4263
+
4264
+ for idx, device_id in enumerate(waiting_device_ids):
4265
+ try:
4266
+ # Build individual device args
4267
+ device_args = {
4268
+ "waiting_device_id": device_id,
4269
+ "device_group_id": device_group_id,
4270
+ "use_alphanumeric": use_alphanumeric
4271
+ }
4272
+
4273
+ if name_prefix:
4274
+ device_args["name_prefix"] = name_prefix
4275
+
4276
+ # Call add_waiting_device for this device
4277
+ single_result = await self.add_waiting_device(device_args)
4278
+
4279
+ # Parse result
4280
+ import ast
4281
+ parsed = None
4282
+ if isinstance(single_result, (list, tuple)) and single_result:
4283
+ t = getattr(single_result[0], "text", None)
4284
+ if isinstance(t, str) and t.strip():
4285
+ try:
4286
+ parsed = ast.literal_eval(t)
4287
+ except Exception:
4288
+ parsed = {"raw": t}
4289
+ else:
4290
+ parsed = {"raw": single_result}
4291
+
4292
+ ok = isinstance(parsed, dict) and str(parsed.get("status")) == "success"
4293
+ if ok:
4294
+ success_count += 1
4295
+ results.append({
4296
+ "index": idx,
4297
+ "status": "success",
4298
+ "device_id": device_id,
4299
+ "message": parsed.get("message")
4300
+ })
4301
+ else:
4302
+ results.append({
4303
+ "index": idx,
4304
+ "status": "error",
4305
+ "device_id": device_id,
4306
+ "response": parsed
4307
+ })
4308
+ if not continue_on_error:
4309
+ break
4310
+
4311
+ except Exception as e:
4312
+ results.append({
4313
+ "index": idx,
4314
+ "status": "error",
4315
+ "device_id": device_id,
4316
+ "error": str(e)
4317
+ })
4318
+ if not continue_on_error:
4319
+ break
4320
+
4321
+ return self.success_response({
4322
+ "message": f"Bulk add completed: {success_count}/{len(waiting_device_ids)} devices added",
4323
+ "total": len(waiting_device_ids),
4324
+ "success": success_count,
4325
+ "failed": len(waiting_device_ids) - success_count,
4326
+ "results": results
4327
+ })
4328
+
4329
+ except Exception as e:
4330
+ logger.exception("bulk_add_waiting_devices failed")
4331
+ return self.error_response(f"Exception: {type(e).__name__}: {e}")
4332
+
4333
+
4334
+ WIEGAND_CSN_FORMATS = {
4335
+ "26 bit SIA Standard-H10301": "0",
4336
+ "HID 37 bit-H10302": "1",
4337
+ "HID 37 bit-H10304": "2",
4338
+ "HID Corporate 1000": "3",
4339
+ "HID Corporate 1000 48bit": "4",
4340
+ "MIFARE CSN 32bit": "5",
4341
+ "MIFARE CSN 34bit (Parity)": "6",
4342
+ "DESFire 56bit": "7",
4343
+ "DESFire 58bit (Parity)": "8",
4344
+ }