suprema-biostar-mcp 1.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- biostar_x_mcp_server/__init__.py +25 -0
- biostar_x_mcp_server/__main__.py +15 -0
- biostar_x_mcp_server/config.py +87 -0
- biostar_x_mcp_server/handlers/__init__.py +35 -0
- biostar_x_mcp_server/handlers/access_handler.py +2162 -0
- biostar_x_mcp_server/handlers/audit_handler.py +489 -0
- biostar_x_mcp_server/handlers/auth_handler.py +216 -0
- biostar_x_mcp_server/handlers/base_handler.py +228 -0
- biostar_x_mcp_server/handlers/card_handler.py +746 -0
- biostar_x_mcp_server/handlers/device_handler.py +4344 -0
- biostar_x_mcp_server/handlers/door_handler.py +3969 -0
- biostar_x_mcp_server/handlers/event_handler.py +1331 -0
- biostar_x_mcp_server/handlers/file_handler.py +212 -0
- biostar_x_mcp_server/handlers/help_web_handler.py +379 -0
- biostar_x_mcp_server/handlers/log_handler.py +1051 -0
- biostar_x_mcp_server/handlers/navigation_handler.py +109 -0
- biostar_x_mcp_server/handlers/occupancy_handler.py +541 -0
- biostar_x_mcp_server/handlers/user_handler.py +3568 -0
- biostar_x_mcp_server/schemas/__init__.py +21 -0
- biostar_x_mcp_server/schemas/access.py +158 -0
- biostar_x_mcp_server/schemas/audit.py +73 -0
- biostar_x_mcp_server/schemas/auth.py +24 -0
- biostar_x_mcp_server/schemas/cards.py +128 -0
- biostar_x_mcp_server/schemas/devices.py +496 -0
- biostar_x_mcp_server/schemas/doors.py +306 -0
- biostar_x_mcp_server/schemas/events.py +104 -0
- biostar_x_mcp_server/schemas/files.py +7 -0
- biostar_x_mcp_server/schemas/help.py +29 -0
- biostar_x_mcp_server/schemas/logs.py +33 -0
- biostar_x_mcp_server/schemas/occupancy.py +19 -0
- biostar_x_mcp_server/schemas/tool_response.py +29 -0
- biostar_x_mcp_server/schemas/users.py +166 -0
- biostar_x_mcp_server/server.py +335 -0
- biostar_x_mcp_server/session.py +221 -0
- biostar_x_mcp_server/tool_manager.py +172 -0
- biostar_x_mcp_server/tools/__init__.py +45 -0
- biostar_x_mcp_server/tools/access.py +510 -0
- biostar_x_mcp_server/tools/audit.py +227 -0
- biostar_x_mcp_server/tools/auth.py +59 -0
- biostar_x_mcp_server/tools/cards.py +269 -0
- biostar_x_mcp_server/tools/categories.py +197 -0
- biostar_x_mcp_server/tools/devices.py +1552 -0
- biostar_x_mcp_server/tools/doors.py +865 -0
- biostar_x_mcp_server/tools/events.py +305 -0
- biostar_x_mcp_server/tools/files.py +28 -0
- biostar_x_mcp_server/tools/help.py +80 -0
- biostar_x_mcp_server/tools/logs.py +123 -0
- biostar_x_mcp_server/tools/navigation.py +89 -0
- biostar_x_mcp_server/tools/occupancy.py +91 -0
- biostar_x_mcp_server/tools/users.py +1113 -0
- biostar_x_mcp_server/utils/__init__.py +31 -0
- biostar_x_mcp_server/utils/category_mapper.py +206 -0
- biostar_x_mcp_server/utils/decorators.py +101 -0
- biostar_x_mcp_server/utils/language_detector.py +51 -0
- biostar_x_mcp_server/utils/search.py +42 -0
- biostar_x_mcp_server/utils/timezone.py +122 -0
- suprema_biostar_mcp-1.0.1.dist-info/METADATA +163 -0
- suprema_biostar_mcp-1.0.1.dist-info/RECORD +61 -0
- suprema_biostar_mcp-1.0.1.dist-info/WHEEL +4 -0
- suprema_biostar_mcp-1.0.1.dist-info/entry_points.txt +2 -0
- suprema_biostar_mcp-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
}
|