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,746 @@
1
+ # handlers/card_handler.py
2
+ import logging
3
+ import re
4
+ from typing import Sequence, Dict, Any, Optional, List
5
+
6
+ import httpx
7
+
8
+ from .base_handler import BaseHandler
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # ─────────────────────────────────────────────────────────────────────────────
13
+ # Fixed mapping by BioStar 2 spec (card_type.id)
14
+ # ─────────────────────────────────────────────────────────────────────────────
15
+ CARD_KIND_TO_ID = {
16
+ "CSN": 0,
17
+ "WIEGAND": 1,
18
+ "SECURE_CREDENTIAL": 2,
19
+ "ACCESS_ON_CARD": 3,
20
+ "MOBILE_CSN": 4,
21
+ "WIEGAND_MOBILE": 5,
22
+ "QR_BARCODE": 6,
23
+ "BIOSTAR2_QR": 7,
24
+ }
25
+
26
+ # card_type.type fallback when GET /api/cards/types is missing or incomplete
27
+ TYPE_FALLBACK: Dict[int, int] = {
28
+ 0: 1, # CSN
29
+ 1: 10, # WIEGAND -> CSN_WIEGAND
30
+ 2: 2, # SECURE CREDENTIAL
31
+ 3: 3, # ACCESS ON CARD
32
+ 4: 4, # MOBILE CSN
33
+ 5: 5, # WIEGAND MOBILE
34
+ 6: 6, # QR/BARCODE
35
+ 7: 7, # BIOSTAR2 QR
36
+ }
37
+
38
+ # Common Wiegand format aliases
39
+ WIEGAND_FORMAT_ALIAS: Dict[str, int] = {
40
+ "26bit": 0, "26-bit": 0, "h10301": 0, "sia26": 0,
41
+ "hid37": 1, "h10302": 1, "37bit": 1, "37-bit": 1,
42
+ }
43
+
44
+ # Hard constraints (fail if violated)
45
+ WIEGAND_CONSTRAINTS: Dict[int, Dict[str, Any]] = {
46
+ # 26bit SIA Standard (H10301)
47
+ 0: {
48
+ "name": "26bit SIA Standard (H10301)",
49
+ "facility_min": 0, "facility_max": 255,
50
+ "card_min": 0, "card_max": 65535,
51
+ "example_display": "12-3456",
52
+ },
53
+ # HID 37bit (H10302)
54
+ 1: {
55
+ "name": "HID 37bit (H10302)",
56
+ "facility_min": 0, "facility_max": 65535,
57
+ "card_min": 0, "card_max": 524287,
58
+ "example_display": "100-12345",
59
+ },
60
+ }
61
+
62
+ # Presets for UX (includes constraints)
63
+ WIEGAND_PRESETS: List[Dict[str, Any]] = [
64
+ {
65
+ "id": 0,
66
+ "name": WIEGAND_CONSTRAINTS[0]["name"],
67
+ "aliases": ["26bit", "h10301", "sia26"],
68
+ "hint": f"display_card_id like '{WIEGAND_CONSTRAINTS[0]['example_display']}'",
69
+ "constraints": {
70
+ "facility_code": [WIEGAND_CONSTRAINTS[0]["facility_min"], WIEGAND_CONSTRAINTS[0]["facility_max"]],
71
+ "card_number": [WIEGAND_CONSTRAINTS[0]["card_min"], WIEGAND_CONSTRAINTS[0]["card_max"]],
72
+ },
73
+ },
74
+ {
75
+ "id": 1,
76
+ "name": WIEGAND_CONSTRAINTS[1]["name"],
77
+ "aliases": ["hid37", "h10302", "37bit"],
78
+ "hint": f"display_card_id like '{WIEGAND_CONSTRAINTS[1]['example_display']}'",
79
+ "constraints": {
80
+ "facility_code": [WIEGAND_CONSTRAINTS[1]["facility_min"], WIEGAND_CONSTRAINTS[1]["facility_max"]],
81
+ "card_number": [WIEGAND_CONSTRAINTS[1]["card_min"], WIEGAND_CONSTRAINTS[1]["card_max"]],
82
+ },
83
+ },
84
+ ]
85
+
86
+ # CSN validation rules
87
+ CSN_RULES = {
88
+ "min_len": 1,
89
+ "max_len": 32,
90
+ "no_leading_zero": True,
91
+ # strictly '0-9' (no Unicode digits)
92
+ "ascii_digits_only": True,
93
+ }
94
+
95
+ def _ascii_digits(s: str) -> bool:
96
+ """Return True if string contains ASCII digits only (0-9)."""
97
+ return bool(re.fullmatch(r"[0-9]+", s))
98
+
99
+
100
+ class CardsHandler(BaseHandler):
101
+ """
102
+ Handle card-related operations:
103
+ - per-type create
104
+ - card types lookup
105
+ - availability check
106
+ - optional assignment to user (PUT preferred, POST legacy fallback)
107
+ """
108
+
109
+ # ──────────────────────────── helpers ────────────────────────────
110
+ def _headers(self) -> Dict[str, str]:
111
+ return {
112
+ "bs-session-id": self.get_session_id(),
113
+ "Content-Type": "application/json",
114
+ }
115
+
116
+ async def _get_card_types_map(self) -> Dict[int, List[int]]:
117
+ """
118
+ GET /api/cards/types -> { card_type_id(int): [allowed type ints] }
119
+ Adjust parsing if your server responds differently.
120
+ """
121
+ self.check_auth()
122
+ url = f"{self.session.config.biostar_url}/api/cards/types"
123
+ async with httpx.AsyncClient(verify=False) as client:
124
+ resp = await client.get(url, headers=self._headers())
125
+ if resp.status_code != 200:
126
+ raise RuntimeError(f"Failed to get card types: {resp.status_code} - {resp.text}")
127
+
128
+ data = resp.json()
129
+ rows = (
130
+ data.get("CardTypes", {}).get("rows")
131
+ or data.get("rows")
132
+ or data.get("data")
133
+ or []
134
+ )
135
+
136
+ types_map: Dict[int, List[int]] = {}
137
+ for row in rows:
138
+ try:
139
+ cid = int(row.get("id"))
140
+ if "types" in row and isinstance(row["types"], list):
141
+ types_map[cid] = [int(x) for x in row["types"]]
142
+ elif "type" in row:
143
+ types_map[cid] = [int(row["type"])]
144
+ except Exception:
145
+ continue
146
+ return types_map
147
+
148
+ async def _resolve_type(self, card_type_id: int, explicit: Optional[int]) -> int:
149
+ """Return explicit type if given; otherwise resolve from /api/cards/types with fallback."""
150
+ if explicit is not None:
151
+ return int(explicit)
152
+ try:
153
+ types_map = await self._get_card_types_map()
154
+ cand = types_map.get(int(card_type_id))
155
+ if cand and len(cand) > 0:
156
+ return int(cand[0])
157
+ except Exception as e:
158
+ logger.warning(f"_resolve_type: failed to fetch types, using fallback. reason={e}")
159
+
160
+ if int(card_type_id) in TYPE_FALLBACK:
161
+ return TYPE_FALLBACK[int(card_type_id)]
162
+ raise RuntimeError(f"No available 'type' for card_type.id={card_type_id}")
163
+
164
+ async def _check_availability(self, card_id: str) -> bool:
165
+ """
166
+ Check via GET /api/v2/cards/registered/?card_id=...
167
+ If response contains "Card", it's already registered -> not available.
168
+ If only "Response":{"code":"0"} without "Card", it's available.
169
+ If endpoint not present (non-200), optimistically return True (server will enforce duplicates).
170
+ """
171
+ self.check_auth()
172
+ url = f"{self.session.config.biostar_url}/api/v2/cards/registered/"
173
+ params = {"card_id": card_id}
174
+ async with httpx.AsyncClient(verify=False) as client:
175
+ resp = await client.get(url, headers=self._headers(), params=params)
176
+
177
+ if resp.status_code != 200:
178
+ logger.info(f"_check_availability: endpoint unavailable or error {resp.status_code}, optimistic True")
179
+ return True
180
+
181
+ try:
182
+ data = resp.json()
183
+ except Exception:
184
+ return True
185
+
186
+ not_registered = "Card" not in data
187
+ return not_registered
188
+
189
+ # ── CSN validation ───────────────────────────────────────────────────────
190
+ def _validate_csn(self, card_id: str) -> Optional[str]:
191
+ """
192
+ CSN must follow:
193
+ - ASCII digits only (0-9)
194
+ - cannot start with '0' (configurable)
195
+ - length 1..32
196
+ """
197
+ s = (card_id or "").strip()
198
+ if not (CSN_RULES["min_len"] <= len(s) <= CSN_RULES["max_len"]):
199
+ return (
200
+ f"Invalid CSN card_id length. It must be {CSN_RULES['min_len']}–{CSN_RULES['max_len']} "
201
+ f"ASCII digits."
202
+ )
203
+ if CSN_RULES["ascii_digits_only"] and not _ascii_digits(s):
204
+ return "CSN card_id must contain ASCII digits only (0-9)."
205
+ if CSN_RULES["no_leading_zero"] and s.startswith("0"):
206
+ return "CSN card_id must not start with '0'."
207
+ return None
208
+
209
+ # ── Wiegand-specific validation ──────────────────────────────────────────
210
+ def _get_wiegand_constraints(self, wf_id: int) -> Optional[Dict[str, Any]]:
211
+ return WIEGAND_CONSTRAINTS.get(int(wf_id))
212
+
213
+ def _validate_display_card_id(self, display_card_id: str) -> Optional[str]:
214
+ """
215
+ Must be strictly numeric "FC-ID", e.g., "12-3456".
216
+ """
217
+ if not isinstance(display_card_id, str):
218
+ return "display_card_id must be a string like '12-3456'."
219
+ if not re.fullmatch(r"\d+-\d+", display_card_id):
220
+ return "display_card_id must be numeric 'FC-ID' (e.g., '12-3456')."
221
+ return None
222
+
223
+ def _validate_wiegand_values(
224
+ self,
225
+ *,
226
+ wf_id: int,
227
+ facility_code: Any,
228
+ card_number: Any,
229
+ display_card_id: Optional[str],
230
+ ) -> Optional[Dict[str, Any]]:
231
+ """
232
+ Return None if valid; otherwise return an error dict suitable for error_response details.
233
+ """
234
+ c = self._get_wiegand_constraints(wf_id)
235
+ if not c:
236
+ return {
237
+ "reason": "Unsupported Wiegand format id",
238
+ "wiegand_format_id": wf_id,
239
+ "supported_format_ids": list(WIEGAND_CONSTRAINTS.keys()),
240
+ }
241
+
242
+ # numeric checks
243
+ try:
244
+ fc = int(facility_code)
245
+ cn = int(card_number)
246
+ except Exception:
247
+ return {
248
+ "reason": "facility_code and card_number must be integers",
249
+ "got": {"facility_code": facility_code, "card_number": card_number},
250
+ }
251
+
252
+ # range checks
253
+ fc_min, fc_max = c["facility_min"], c["facility_max"]
254
+ cn_min, cn_max = c["card_min"], c["card_max"]
255
+ fc_ok = (fc_min <= fc <= fc_max)
256
+ cn_ok = (cn_min <= cn <= cn_max)
257
+ if not fc_ok or not cn_ok:
258
+ return {
259
+ "reason": "Wiegand value out of range",
260
+ "format": c["name"],
261
+ "expected_ranges": {
262
+ "facility_code": [fc_min, fc_max],
263
+ "card_number": [cn_min, cn_max],
264
+ },
265
+ "got": {"facility_code": facility_code, "card_number": card_number},
266
+ "hint": f"Try display_card_id like '{c['example_display']}'",
267
+ }
268
+
269
+ # display_card_id pattern + consistency with fc/cn
270
+ if display_card_id is not None:
271
+ msg = self._validate_display_card_id(display_card_id)
272
+ if msg:
273
+ return {
274
+ "reason": msg,
275
+ "example": c["example_display"],
276
+ }
277
+ # match to fc-cn
278
+ m = display_card_id.split("-")
279
+ dfc, dcn = int(m[0]), int(m[1])
280
+ if dfc != fc or dcn != cn:
281
+ return {
282
+ "reason": "display_card_id must match facility_code and card_number",
283
+ "expected": f"{fc}-{cn}",
284
+ "got": display_card_id,
285
+ }
286
+
287
+ # all good
288
+ return None
289
+
290
+ def _rows_payload(
291
+ self,
292
+ card_id: str,
293
+ card_type_id: int,
294
+ card_type_type: int,
295
+ *,
296
+ wiegand_format_id: Optional[int] = None,
297
+ mobile_opts: Optional[Dict[str, Any]] = None,
298
+ display_card_id: Optional[str] = None,
299
+ ) -> Dict[str, Any]:
300
+ """
301
+ Build POST /api/cards payload
302
+ """
303
+ row: Dict[str, Any] = {
304
+ "card_id": str(card_id),
305
+ "card_type": {"id": str(int(card_type_id)), "type": str(int(card_type_type))},
306
+ }
307
+
308
+ if wiegand_format_id is not None:
309
+ row["wiegand_format_id"] = {"id": str(int(wiegand_format_id))}
310
+
311
+ if display_card_id:
312
+ row["display_card_id"] = str(display_card_id)
313
+
314
+ if mobile_opts:
315
+ for k in ("isUserPhoto", "isDepartment", "isTitle", "start_datetime", "expiry_datetime"):
316
+ if k in mobile_opts and mobile_opts[k] is not None:
317
+ row[k] = mobile_opts[k]
318
+ if "display_card_id" in mobile_opts and mobile_opts["display_card_id"]:
319
+ row["display_card_id"] = mobile_opts["display_card_id"]
320
+
321
+ return {"CardCollection": {"rows": [row]}}
322
+
323
+ async def _post_create(self, payload: Dict[str, Any]) -> httpx.Response:
324
+ self.check_auth()
325
+ url = f"{self.session.config.biostar_url}/api/cards"
326
+ async with httpx.AsyncClient(verify=False) as client:
327
+ resp = await client.post(url, headers=self._headers(), json=payload)
328
+ return resp
329
+
330
+ # ── Assignment helpers ─────────────────────────────────────────────────
331
+ async def _assign_to_user_put(
332
+ self,
333
+ user_id: str,
334
+ create_json: Dict[str, Any],
335
+ *,
336
+ card_id: str,
337
+ card_type_id: int,
338
+ card_type_type: int,
339
+ ) -> Dict[str, Any]:
340
+ """
341
+ Preferred: PUT /api/users/{user_id} with {"User":{"cards":[{...}]}}
342
+ """
343
+ created_rows = (create_json.get("CardCollection") or {}).get("rows") or []
344
+ created_row = created_rows[0] if created_rows else {}
345
+
346
+ card_payload = {
347
+ **({"id": str(created_row.get("id"))} if created_row.get("id") is not None else {}),
348
+ "card_id": str(card_id),
349
+ "display_card_id": str(created_row.get("display_card_id") or card_id),
350
+ "status": "1",
351
+ "is_blocked": "false",
352
+ "is_assigned": "true",
353
+ "card_type": {
354
+ "id": str(card_type_id),
355
+ "type": str(card_type_type),
356
+ },
357
+ "mobile_card": "false",
358
+ "issue_count": "1",
359
+ "card_slot": "1",
360
+ "card_mask": "0",
361
+ "wiegand_format_id": {"id": str((created_row.get("wiegand_format_id") or {}).get("id") or 0)},
362
+ "start_datetime": created_row.get("start_datetime") or "2001-01-01T00:00:00.00Z",
363
+ "expiry_datetime": created_row.get("expiry_datetime") or "2030-12-31T23:59:00.00Z",
364
+ }
365
+
366
+ payload = {"User": {"cards": [card_payload]}}
367
+ url = f"{self.session.config.biostar_url}/api/users/{user_id}"
368
+ async with httpx.AsyncClient(verify=False) as client:
369
+ resp = await client.put(url, headers=self._headers(), json=payload)
370
+
371
+ if resp.status_code != 200:
372
+ return {
373
+ "assigned": False,
374
+ "method": "PUT",
375
+ "status": resp.status_code,
376
+ "error": resp.text,
377
+ "request_body": payload,
378
+ }
379
+ return {
380
+ "assigned": True,
381
+ "method": "PUT",
382
+ "response": resp.json(),
383
+ "request_body": payload,
384
+ }
385
+
386
+ async def _assign_to_user_post_legacy(
387
+ self,
388
+ user_id: str,
389
+ *,
390
+ card_id: str,
391
+ card_type_id: int,
392
+ card_type_type: int,
393
+ ) -> Dict[str, Any]:
394
+ """
395
+ Legacy fallback: POST /api/users/{user_id}/cards with {"Card":{...}}
396
+ """
397
+ payload = {
398
+ "Card": {
399
+ "card_id": str(card_id),
400
+ "card_type": {"id": str(card_type_id), "type": str(card_type_type)},
401
+ }
402
+ }
403
+ url = f"{self.session.config.biostar_url}/api/users/{user_id}/cards"
404
+ async with httpx.AsyncClient(verify=False) as client:
405
+ resp = await client.post(url, headers=self._headers(), json=payload)
406
+
407
+ if resp.status_code != 200:
408
+ return {
409
+ "assigned": False,
410
+ "method": "POST", # fixed typo from earlier
411
+ "status": resp.status_code,
412
+ "error": resp.text,
413
+ "request_body": payload,
414
+ }
415
+ return {
416
+ "assigned": True,
417
+ "method": "POST",
418
+ "response": resp.json(),
419
+ "request_body": payload,
420
+ }
421
+
422
+ async def _assign_to_user(
423
+ self,
424
+ user_id: str,
425
+ create_json: Dict[str, Any],
426
+ *,
427
+ card_id: str,
428
+ card_type_id: int,
429
+ card_type_type: int,
430
+ ) -> Dict[str, Any]:
431
+ """
432
+ Try PUT first; if it fails, fallback to POST legacy endpoint.
433
+ """
434
+ put_res = await self._assign_to_user_put(
435
+ user_id,
436
+ create_json,
437
+ card_id=card_id,
438
+ card_type_id=card_type_id,
439
+ card_type_type=card_type_type,
440
+ )
441
+ if put_res.get("assigned"):
442
+ return put_res
443
+ post_res = await self._assign_to_user_post_legacy(
444
+ user_id,
445
+ card_id=card_id,
446
+ card_type_id=card_type_id,
447
+ card_type_type=card_type_type,
448
+ )
449
+ return {"put": put_res, "post": post_res, "assigned": bool(post_res.get("assigned"))}
450
+
451
+ # ── Creation flow ────────────────────────────────────────────────────────
452
+ async def _create_card_flow(
453
+ self,
454
+ *,
455
+ card_id_raw: Any,
456
+ card_type_id: int,
457
+ card_type_type_raw: Optional[Any],
458
+ assign_to_user_id: Optional[Any],
459
+ skip_availability_check: bool,
460
+ dry_run: bool,
461
+ validate_csn: bool = False,
462
+ extra_payload: Optional[Dict[str, Any]] = None,
463
+ ) -> Sequence[Dict[str, Any]]:
464
+ """Shared creation flow used by each per-type method."""
465
+ try:
466
+ card_id = str(card_id_raw).strip() if card_id_raw is not None else ""
467
+ if not card_id:
468
+ return self.error_response("card_id is required")
469
+
470
+ if validate_csn:
471
+ err = self._validate_csn(card_id)
472
+ if err:
473
+ return self.error_response(f"Invalid CSN card_id: {err}")
474
+
475
+ if not skip_availability_check:
476
+ available = await self._check_availability(card_id)
477
+ if not available:
478
+ return self.error_response(f"card_id '{card_id}' is not available (already exists).")
479
+
480
+ card_type_type = await self._resolve_type(
481
+ card_type_id,
482
+ int(card_type_type_raw) if card_type_type_raw is not None else None
483
+ )
484
+
485
+ extra_payload = extra_payload or {}
486
+ payload = self._rows_payload(
487
+ card_id,
488
+ card_type_id,
489
+ card_type_type,
490
+ wiegand_format_id=extra_payload.get("wiegand_format_id"),
491
+ mobile_opts=extra_payload.get("mobile_opts"),
492
+ display_card_id=extra_payload.get("display_card_id"),
493
+ )
494
+
495
+ if dry_run:
496
+ return self.success_response({"dry_run": True, "request_body": payload})
497
+
498
+ resp = await self._post_create(payload)
499
+ if resp.status_code != 200:
500
+ try:
501
+ ej = resp.json()
502
+ except Exception:
503
+ ej = None
504
+ if ej:
505
+ ec = ej.get("error_code") or ej.get("code")
506
+ msg = ej.get("message") or resp.text
507
+ return self.error_response(f"Failed to create card: {ec or resp.status_code} - {msg}")
508
+ return self.error_response(f"Failed to create card: {resp.status_code} - {resp.text}")
509
+
510
+ create_json = resp.json()
511
+ result: Dict[str, Any] = {
512
+ "message": "Card created",
513
+ "request_body": payload,
514
+ "response": create_json,
515
+ }
516
+
517
+ if assign_to_user_id is not None:
518
+ assign_res = await self._assign_to_user(
519
+ user_id=str(assign_to_user_id),
520
+ create_json=create_json,
521
+ card_id=card_id,
522
+ card_type_id=card_type_id,
523
+ card_type_type=card_type_type,
524
+ )
525
+ result["assignment"] = assign_res
526
+ if not assign_res.get("assigned"):
527
+ result["message"] = "Card created, but assignment failed."
528
+
529
+ return self.success_response(result)
530
+
531
+ except Exception as e:
532
+ return await self.handle_api_error(e)
533
+
534
+ # ──────────────────────────── public tools ────────────────────────────
535
+ async def get_card_types(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
536
+ """Tool: get-card-types"""
537
+ try:
538
+ self.check_auth()
539
+ types_map = await self._get_card_types_map()
540
+ return self.success_response({"types": types_map})
541
+ except Exception as e:
542
+ return await self.handle_api_error(e)
543
+
544
+ async def get_wiegand_format_presets(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
545
+ """Tool: get-wiegand-format-presets"""
546
+ try:
547
+ self.check_auth()
548
+ return self.success_response({
549
+ "presets": WIEGAND_PRESETS,
550
+ "how_to_choose": (
551
+ "Choose a format used at your site. For 26bit (H10301), facility_code must be 0–255 and "
552
+ "card_number 0–65535. For HID37 (H10302), facility_code 0–65535 and card_number 0–524287. "
553
+ "display_card_id must be 'FC-ID' (e.g., '12-3456')."
554
+ ),
555
+ "examples": [
556
+ {"wiegand_format_id": 0, "facility_code": 12, "card_number": 3456, "display_card_id": "12-3456"},
557
+ {"wiegand_format": "HID37", "facility_code": 100, "card_number": 12345, "display_card_id": "100-12345"},
558
+ ],
559
+ })
560
+ except Exception as e:
561
+ return await self.handle_api_error(e)
562
+
563
+ async def check_card_availability(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
564
+ """Tool: check-card-availability"""
565
+ try:
566
+ self.check_auth()
567
+ card_id = str(args.get("card_id", "")).strip()
568
+ if not card_id:
569
+ return self.error_response("card_id is required")
570
+ available = await self._check_availability(card_id)
571
+ return self.success_response({"card_id": card_id, "available": available})
572
+ except Exception as e:
573
+ return await self.handle_api_error(e)
574
+
575
+ # ── Per-type creators (names align with tool names) ───────────────────
576
+ async def create_card_csn(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
577
+ return await self._create_card_flow(
578
+ card_id_raw=args.get("card_id"),
579
+ card_type_id=CARD_KIND_TO_ID["CSN"],
580
+ card_type_type_raw=args.get("card_type_type"),
581
+ assign_to_user_id=args.get("assign_to_user_id"),
582
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
583
+ dry_run=bool(args.get("dry_run", False)),
584
+ validate_csn=True,
585
+ )
586
+
587
+ async def create_card_wiegand(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
588
+ # Resolve wiegand_format_id from alias if needed
589
+ wf_id = args.get("wiegand_format_id")
590
+ wf_alias = args.get("wiegand_format")
591
+ if wf_id is None and wf_alias:
592
+ wf_id = WIEGAND_FORMAT_ALIAS.get(str(wf_alias).lower())
593
+
594
+ if wf_id is None:
595
+ return self.error_response(
596
+ "wiegand_format_id is required for Wiegand cards.",
597
+ {"presets": WIEGAND_PRESETS}
598
+ )
599
+
600
+ # Required by tool schema; double-check & validate ranges
601
+ facility = args.get("facility_code")
602
+ number = args.get("card_number")
603
+ display_override = args.get("display_card_id")
604
+
605
+ # card_id must be numeric ASCII digits (no coercion)
606
+ cid_raw = args.get("card_id")
607
+ cid = str(cid_raw).strip() if cid_raw is not None else ""
608
+ if not cid:
609
+ return self.error_response("card_id is required")
610
+ if not _ascii_digits(cid):
611
+ return self.error_response("Wiegand card_id must be numeric (ASCII digits 0-9 only).")
612
+
613
+ # Validate facility/card_number + display_card_id consistency & ranges
614
+ err_details = self._validate_wiegand_values(
615
+ wf_id=int(wf_id),
616
+ facility_code=facility,
617
+ card_number=number,
618
+ display_card_id=display_override,
619
+ )
620
+ if err_details:
621
+ return self.error_response(
622
+ "Invalid Wiegand parameters for selected format.",
623
+ err_details
624
+ )
625
+
626
+ extra: Dict[str, Any] = {"wiegand_format_id": int(wf_id)}
627
+ if display_override:
628
+ extra["display_card_id"] = str(display_override)
629
+ else:
630
+ # Safe to set display as "FC-ID" derived from user-provided numeric inputs (no mutation)
631
+ extra["display_card_id"] = f"{int(facility)}-{int(number)}"
632
+
633
+ return await self._create_card_flow(
634
+ card_id_raw=cid,
635
+ card_type_id=CARD_KIND_TO_ID["WIEGAND"],
636
+ card_type_type_raw=args.get("card_type_type"),
637
+ assign_to_user_id=args.get("assign_to_user_id"),
638
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
639
+ dry_run=bool(args.get("dry_run", False)),
640
+ extra_payload=extra,
641
+ )
642
+
643
+ async def create_card_secure_credential(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
644
+ return await self._create_card_flow(
645
+ card_id_raw=args.get("card_id"),
646
+ card_type_id=CARD_KIND_TO_ID["SECURE_CREDENTIAL"],
647
+ card_type_type_raw=args.get("card_type_type"),
648
+ assign_to_user_id=args.get("assign_to_user_id"),
649
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
650
+ dry_run=bool(args.get("dry_run", False)),
651
+ )
652
+
653
+ async def create_card_access_on_card(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
654
+ return await self._create_card_flow(
655
+ card_id_raw=args.get("card_id"),
656
+ card_type_id=CARD_KIND_TO_ID["ACCESS_ON_CARD"],
657
+ card_type_type_raw=args.get("card_type_type"),
658
+ assign_to_user_id=args.get("assign_to_user_id"),
659
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
660
+ dry_run=bool(args.get("dry_run", False)),
661
+ )
662
+
663
+ async def create_card_mobile_csn(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
664
+ mobile_opts = {k: args.get(k) for k in (
665
+ "isUserPhoto", "isDepartment", "isTitle", "start_datetime", "expiry_datetime", "display_card_id"
666
+ )}
667
+ return await self._create_card_flow(
668
+ card_id_raw=args.get("card_id"),
669
+ card_type_id=CARD_KIND_TO_ID["MOBILE_CSN"],
670
+ card_type_type_raw=args.get("card_type_type"),
671
+ assign_to_user_id=args.get("assign_to_user_id"),
672
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
673
+ dry_run=bool(args.get("dry_run", False)),
674
+ extra_payload={"mobile_opts": mobile_opts},
675
+ )
676
+
677
+ async def create_card_wiegand_mobile(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
678
+ wf_id = args.get("wiegand_format_id")
679
+ wf_alias = args.get("wiegand_format")
680
+ if wf_id is None and wf_alias:
681
+ wf_id = WIEGAND_FORMAT_ALIAS.get(str(wf_alias).lower())
682
+
683
+ if wf_id is None:
684
+ return self.error_response(
685
+ "wiegand_format_id is required for Wiegand Mobile cards.",
686
+ {"presets": WIEGAND_PRESETS},
687
+ )
688
+
689
+ facility = args.get("facility_code")
690
+ number = args.get("card_number")
691
+ display_override = args.get("display_card_id")
692
+
693
+ cid_raw = args.get("card_id")
694
+ cid = str(cid_raw).strip() if cid_raw is not None else ""
695
+ if not cid:
696
+ return self.error_response("card_id is required")
697
+ if not _ascii_digits(cid):
698
+ return self.error_response("Wiegand Mobile card_id must be numeric (ASCII digits 0-9 only).")
699
+
700
+ err_details = self._validate_wiegand_values(
701
+ wf_id=int(wf_id),
702
+ facility_code=facility,
703
+ card_number=number,
704
+ display_card_id=display_override,
705
+ )
706
+ if err_details:
707
+ return self.error_response(
708
+ "Invalid Wiegand parameters for selected format.",
709
+ err_details
710
+ )
711
+
712
+ extra: Dict[str, Any] = {"wiegand_format_id": int(wf_id)}
713
+ if display_override:
714
+ extra["display_card_id"] = str(display_override)
715
+ else:
716
+ extra["display_card_id"] = f"{int(facility)}-{int(number)}"
717
+
718
+ return await self._create_card_flow(
719
+ card_id_raw=cid,
720
+ card_type_id=CARD_KIND_TO_ID["WIEGAND_MOBILE"],
721
+ card_type_type_raw=args.get("card_type_type"),
722
+ assign_to_user_id=args.get("assign_to_user_id"),
723
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
724
+ dry_run=bool(args.get("dry_run", False)),
725
+ extra_payload=extra,
726
+ )
727
+
728
+ async def create_card_qr_barcode(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
729
+ return await self._create_card_flow(
730
+ card_id_raw=args.get("card_id"),
731
+ card_type_id=CARD_KIND_TO_ID["QR_BARCODE"],
732
+ card_type_type_raw=args.get("card_type_type"),
733
+ assign_to_user_id=args.get("assign_to_user_id"),
734
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
735
+ dry_run=bool(args.get("dry_run", False)),
736
+ )
737
+
738
+ async def create_card_biostar2_qr(self, args: Dict[str, Any]) -> Sequence[Dict[str, Any]]:
739
+ return await self._create_card_flow(
740
+ card_id_raw=args.get("card_id"),
741
+ card_type_id=CARD_KIND_TO_ID["BIOSTAR2_QR"],
742
+ card_type_type_raw=args.get("card_type_type"),
743
+ assign_to_user_id=args.get("assign_to_user_id"),
744
+ skip_availability_check=bool(args.get("skip_availability_check", False)),
745
+ dry_run=bool(args.get("dry_run", False)),
746
+ )