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,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Navigation Handler
|
|
3
|
+
프론트엔드 페이지 이동 정보를 생성
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Dict, Any, Optional
|
|
8
|
+
from .base_handler import BaseHandler
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NavigationHandler(BaseHandler):
|
|
14
|
+
"""Handle navigation requests"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, session=None):
|
|
17
|
+
"""Initialize without session (navigation doesn't need API calls)"""
|
|
18
|
+
self.client_session_id = None
|
|
19
|
+
|
|
20
|
+
async def navigate_to_page(self, args: dict):
|
|
21
|
+
"""
|
|
22
|
+
Generate navigation information for frontend
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
args: {
|
|
26
|
+
"page_type": "user_list" | "user_detail" | "door_list" | "door_detail" |
|
|
27
|
+
"device_list" | "device_detail" | "monitoring" | "custom_url",
|
|
28
|
+
"resource_id": "123" (optional, for detail pages),
|
|
29
|
+
"custom_url": "/custom/path" (optional, for custom_url type),
|
|
30
|
+
"action": {
|
|
31
|
+
"type": "click" | "scroll",
|
|
32
|
+
"ng_label_key": "common.information"
|
|
33
|
+
} (optional),
|
|
34
|
+
"reason": "Brief explanation" (optional)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Navigation metadata for frontend
|
|
39
|
+
"""
|
|
40
|
+
page_type = args.get("page_type")
|
|
41
|
+
resource_id = args.get("resource_id")
|
|
42
|
+
custom_url = args.get("custom_url")
|
|
43
|
+
action = args.get("action")
|
|
44
|
+
reason = args.get("reason", "")
|
|
45
|
+
|
|
46
|
+
# Build navigation object
|
|
47
|
+
navigation = {
|
|
48
|
+
"enabled": True,
|
|
49
|
+
"page_type": page_type,
|
|
50
|
+
"resource_id": resource_id,
|
|
51
|
+
"custom_url": custom_url,
|
|
52
|
+
"action": action,
|
|
53
|
+
"reason": reason
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# Generate user-friendly path
|
|
57
|
+
path = self._generate_path(page_type, resource_id, custom_url)
|
|
58
|
+
|
|
59
|
+
# Create response message
|
|
60
|
+
message_parts = []
|
|
61
|
+
|
|
62
|
+
if reason:
|
|
63
|
+
message_parts.append(f" {reason}")
|
|
64
|
+
|
|
65
|
+
message_parts.append(f" 페이지 이동: {path}")
|
|
66
|
+
|
|
67
|
+
if action:
|
|
68
|
+
action_type = action.get("type", "")
|
|
69
|
+
if action_type == "click":
|
|
70
|
+
message_parts.append(f" 클릭: {action.get('ng_label_key', '')}")
|
|
71
|
+
elif action_type == "scroll":
|
|
72
|
+
message_parts.append(f" 스크롤: {action.get('ng_label_key', '')}")
|
|
73
|
+
|
|
74
|
+
message = "\n".join(message_parts)
|
|
75
|
+
|
|
76
|
+
logger.info(f" Navigation: {page_type} → {path}")
|
|
77
|
+
|
|
78
|
+
# Return as TextContent with navigation metadata
|
|
79
|
+
return [{
|
|
80
|
+
"type": "text",
|
|
81
|
+
"text": json.dumps({
|
|
82
|
+
"message": message,
|
|
83
|
+
"navigation": navigation,
|
|
84
|
+
"path": path
|
|
85
|
+
}, ensure_ascii=False)
|
|
86
|
+
}]
|
|
87
|
+
|
|
88
|
+
def _generate_path(self, page_type: str, resource_id: Optional[str] = None,
|
|
89
|
+
custom_url: Optional[str] = None) -> str:
|
|
90
|
+
"""Generate frontend path from navigation info"""
|
|
91
|
+
|
|
92
|
+
if page_type == "custom_url" and custom_url:
|
|
93
|
+
return custom_url
|
|
94
|
+
|
|
95
|
+
path_map = {
|
|
96
|
+
"user_list": "/team",
|
|
97
|
+
"user_detail": f"/team/user/detail/{resource_id}" if resource_id else "/team",
|
|
98
|
+
"door_list": "/settings/door",
|
|
99
|
+
"door_detail": f"/settings/door/detail/{resource_id}" if resource_id else "/settings/door",
|
|
100
|
+
"device_list": "/settings/device",
|
|
101
|
+
"device_detail": f"/settings/device/detail/{resource_id}" if resource_id else "/settings/device",
|
|
102
|
+
"access_control": "/settings/access-control",
|
|
103
|
+
"access_group_list": "/settings/access-control",
|
|
104
|
+
"access_group_detail": f"/settings/access-control/detail/{resource_id}" if resource_id else "/settings/access-control",
|
|
105
|
+
"monitoring": "/arena"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return path_map.get(page_type, "/")
|
|
109
|
+
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Occupancy Analysis Handler
|
|
3
|
+
재실 현황 분석 핸들러
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any, Dict, Optional, List
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from mcp.types import TextContent
|
|
11
|
+
from .base_handler import BaseHandler
|
|
12
|
+
from .event_handler import EventHandler
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OccupancyHandler(BaseHandler):
|
|
18
|
+
"""Handle occupancy analysis operations"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, session):
|
|
21
|
+
super().__init__(session)
|
|
22
|
+
self.event_handler = EventHandler(session)
|
|
23
|
+
|
|
24
|
+
async def get_occupancy_status(self, args: Dict[str, Any]) -> list[TextContent]:
|
|
25
|
+
"""
|
|
26
|
+
Analyze current occupancy status of a door.
|
|
27
|
+
출입문의 현재 재실 현황을 분석합니다.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
# Extract parameters from args dict
|
|
31
|
+
door_name = args.get("door_name")
|
|
32
|
+
door_id = args.get("door_id")
|
|
33
|
+
|
|
34
|
+
# Handle both days and hours parameters
|
|
35
|
+
days = args.get("days")
|
|
36
|
+
hours_param = args.get("hours")
|
|
37
|
+
|
|
38
|
+
if days is not None:
|
|
39
|
+
# Convert days to hours
|
|
40
|
+
hours = int(days) * 24
|
|
41
|
+
elif hours_param is not None:
|
|
42
|
+
hours = int(hours_param)
|
|
43
|
+
else:
|
|
44
|
+
hours = 24 # Default to today
|
|
45
|
+
|
|
46
|
+
include_details = bool(args.get("include_details", True))
|
|
47
|
+
|
|
48
|
+
logger.info(f" Starting occupancy analysis for door: {door_name or door_id}")
|
|
49
|
+
|
|
50
|
+
# Step 1: Get door information
|
|
51
|
+
door_info = None
|
|
52
|
+
if door_id:
|
|
53
|
+
logger.info(f" Searching by door_id: {door_id}")
|
|
54
|
+
# Get specific door by ID
|
|
55
|
+
response = await self.session.get(f"/api/doors/{door_id}")
|
|
56
|
+
response_data = response.json() if response else {}
|
|
57
|
+
if response_data and 'Door' in response_data:
|
|
58
|
+
door_info = response_data['Door']
|
|
59
|
+
logger.info(f" Found door: {door_info.get('name')}")
|
|
60
|
+
elif door_name:
|
|
61
|
+
logger.info(f" Searching by door_name: {door_name}")
|
|
62
|
+
# Search for door by name (flexible matching)
|
|
63
|
+
response = await self.session.get("/api/doors")
|
|
64
|
+
response_data = response.json() if response else {}
|
|
65
|
+
if response_data and 'DoorCollection' in response_data:
|
|
66
|
+
doors = response_data['DoorCollection'].get('rows', [])
|
|
67
|
+
logger.info(f" Total doors available: {len(doors)}")
|
|
68
|
+
|
|
69
|
+
# Log all door names for debugging
|
|
70
|
+
for d in doors:
|
|
71
|
+
logger.info(f" - {d.get('name')} (ID: {d.get('id')})")
|
|
72
|
+
|
|
73
|
+
# Normalize search string (remove spaces)
|
|
74
|
+
search_normalized = door_name.lower().replace(' ', '').replace(' ', '')
|
|
75
|
+
logger.info(f" Normalized search: {search_normalized}")
|
|
76
|
+
|
|
77
|
+
# Try exact match first
|
|
78
|
+
for door in doors:
|
|
79
|
+
door_normalized = door.get('name', '').lower().replace(' ', '').replace(' ', '')
|
|
80
|
+
if search_normalized == door_normalized:
|
|
81
|
+
door_info = door
|
|
82
|
+
logger.info(f" Exact match found: {door.get('name')}")
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
# If no exact match, try partial match
|
|
86
|
+
if not door_info:
|
|
87
|
+
for door in doors:
|
|
88
|
+
door_normalized = door.get('name', '').lower().replace(' ', '').replace(' ', '')
|
|
89
|
+
if search_normalized in door_normalized or door_normalized in search_normalized:
|
|
90
|
+
door_info = door
|
|
91
|
+
logger.info(f" Partial match found: {door.get('name')}")
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if not door_info:
|
|
95
|
+
error_msg = f"출입문을 찾을 수 없습니다: {door_name or door_id}"
|
|
96
|
+
logger.error(f" {error_msg}")
|
|
97
|
+
return self.error_response(
|
|
98
|
+
error_msg,
|
|
99
|
+
{"door_name": door_name, "door_id": door_id}
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Step 2: Extract entry/exit device information
|
|
103
|
+
door_id_actual = door_info.get('id')
|
|
104
|
+
door_name_actual = door_info.get('name', 'Unknown')
|
|
105
|
+
entry_device_raw = door_info.get('entry_device_id')
|
|
106
|
+
exit_device_raw = door_info.get('exit_device_id')
|
|
107
|
+
|
|
108
|
+
# Extract device IDs (handle both dict and string formats)
|
|
109
|
+
entry_device_id = None
|
|
110
|
+
exit_device_id = None
|
|
111
|
+
|
|
112
|
+
if entry_device_raw:
|
|
113
|
+
if isinstance(entry_device_raw, dict):
|
|
114
|
+
entry_device_id = entry_device_raw.get('id')
|
|
115
|
+
else:
|
|
116
|
+
entry_device_id = str(entry_device_raw)
|
|
117
|
+
|
|
118
|
+
if exit_device_raw:
|
|
119
|
+
if isinstance(exit_device_raw, dict):
|
|
120
|
+
exit_device_id = exit_device_raw.get('id')
|
|
121
|
+
else:
|
|
122
|
+
exit_device_id = str(exit_device_raw)
|
|
123
|
+
|
|
124
|
+
logger.info(f" Door Info:")
|
|
125
|
+
logger.info(f" Door ID: {door_id_actual}")
|
|
126
|
+
logger.info(f" Door Name: {door_name_actual}")
|
|
127
|
+
logger.info(f" Entry Device RAW: {entry_device_raw}")
|
|
128
|
+
logger.info(f" Exit Device RAW: {exit_device_raw}")
|
|
129
|
+
logger.info(f" Entry Device ID (extracted): {entry_device_id}")
|
|
130
|
+
logger.info(f" Exit Device ID (extracted): {exit_device_id}")
|
|
131
|
+
|
|
132
|
+
# Get device names
|
|
133
|
+
entry_device_name = "Unknown"
|
|
134
|
+
exit_device_name = "Unknown"
|
|
135
|
+
|
|
136
|
+
if entry_device_id:
|
|
137
|
+
try:
|
|
138
|
+
dev_response = await self.session.get(f"/api/devices/{entry_device_id}")
|
|
139
|
+
dev_data = dev_response.json() if dev_response else {}
|
|
140
|
+
if dev_data and 'Device' in dev_data:
|
|
141
|
+
entry_device_name = dev_data['Device'].get('name', 'Unknown')
|
|
142
|
+
logger.info(f" Entry Device: {entry_device_name}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.warning(f" Failed to get entry device name: {e}")
|
|
145
|
+
|
|
146
|
+
# Step 2.5: If no exit device is configured, try to find slave device (exit device)
|
|
147
|
+
if not exit_device_id and entry_device_id:
|
|
148
|
+
logger.info(f" No exit device configured, checking for slave device...")
|
|
149
|
+
try:
|
|
150
|
+
dev_response = await self.session.get(f"/api/devices/{entry_device_id}")
|
|
151
|
+
dev_data = dev_response.json() if dev_response else {}
|
|
152
|
+
if dev_data and 'Device' in dev_data:
|
|
153
|
+
device_info = dev_data['Device']
|
|
154
|
+
# Check for slave device
|
|
155
|
+
slave_device = device_info.get('slave_device')
|
|
156
|
+
if slave_device:
|
|
157
|
+
if isinstance(slave_device, dict):
|
|
158
|
+
exit_device_id = slave_device.get('id')
|
|
159
|
+
exit_device_name = slave_device.get('name', 'Unknown')
|
|
160
|
+
else:
|
|
161
|
+
exit_device_id = str(slave_device)
|
|
162
|
+
# Try to get slave device name
|
|
163
|
+
try:
|
|
164
|
+
slave_response = await self.session.get(f"/api/devices/{exit_device_id}")
|
|
165
|
+
slave_data = slave_response.json() if slave_response else {}
|
|
166
|
+
if slave_data and 'Device' in slave_data:
|
|
167
|
+
exit_device_name = slave_data['Device'].get('name', 'Unknown')
|
|
168
|
+
except:
|
|
169
|
+
exit_device_name = f"Device {exit_device_id}"
|
|
170
|
+
logger.info(f" Found slave device (exit): {exit_device_name} (ID: {exit_device_id})")
|
|
171
|
+
else:
|
|
172
|
+
logger.info(f" ℹ No slave device found for entry device")
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.warning(f" Failed to check for slave device: {e}")
|
|
175
|
+
|
|
176
|
+
if exit_device_id:
|
|
177
|
+
try:
|
|
178
|
+
dev_response = await self.session.get(f"/api/devices/{exit_device_id}")
|
|
179
|
+
dev_data = dev_response.json() if dev_response else {}
|
|
180
|
+
if dev_data and 'Device' in dev_data:
|
|
181
|
+
exit_device_name = dev_data['Device'].get('name', 'Unknown')
|
|
182
|
+
logger.info(f" Exit Device: {exit_device_name}")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.warning(f" Failed to get exit device name: {e}")
|
|
185
|
+
|
|
186
|
+
# Step 3: Get authentication events for entry and exit devices
|
|
187
|
+
end_time = datetime.now()
|
|
188
|
+
|
|
189
|
+
# Use today's date (00:00:00) as start if hours parameter is default (24)
|
|
190
|
+
# This ensures we only get TODAY's events, not last 24 hours
|
|
191
|
+
if hours == 24:
|
|
192
|
+
# Today from midnight
|
|
193
|
+
start_time = end_time.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
194
|
+
logger.info(f" Using TODAY (from midnight): {start_time}")
|
|
195
|
+
else:
|
|
196
|
+
# Custom hours range
|
|
197
|
+
start_time = end_time - timedelta(hours=hours)
|
|
198
|
+
logger.info(f" Using last {hours} hours: {start_time}")
|
|
199
|
+
|
|
200
|
+
# Format datetime for API (ISO 8601 format with timezone)
|
|
201
|
+
start_datetime = start_time.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
202
|
+
end_datetime = end_time.strftime("%Y-%m-%dT%H:%M:%S.999Z")
|
|
203
|
+
|
|
204
|
+
logger.info(f" Searching authentication events for devices of door: {door_name_actual}")
|
|
205
|
+
logger.info(f" Entry Device ID: {entry_device_id}")
|
|
206
|
+
logger.info(f" Exit Device ID: {exit_device_id}")
|
|
207
|
+
logger.info(f" Time range: {start_datetime} ~ {end_datetime}")
|
|
208
|
+
|
|
209
|
+
# Search for authentication events from both entry and exit devices
|
|
210
|
+
device_ids = []
|
|
211
|
+
if entry_device_id:
|
|
212
|
+
device_ids.append(str(entry_device_id))
|
|
213
|
+
if exit_device_id and exit_device_id != entry_device_id:
|
|
214
|
+
device_ids.append(str(exit_device_id))
|
|
215
|
+
|
|
216
|
+
if not device_ids:
|
|
217
|
+
logger.warning(f" No devices configured for door {door_name_actual}")
|
|
218
|
+
return self.error_response(
|
|
219
|
+
f"출입문 {door_name_actual}에 입실/퇴실 장치가 설정되지 않았습니다.",
|
|
220
|
+
{"door_id": door_id_actual, "door_name": door_name_actual}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Use search_events with AUTHENTICATION SUCCESS event codes
|
|
224
|
+
# These are all "authentication succeeded" event types from BioStar
|
|
225
|
+
AUTH_SUCCESS_CODES = [
|
|
226
|
+
"4112", "4107", "4106", "4113", "4105", "4104", "4103", "4102", "4114", "4101",
|
|
227
|
+
"4100", "4115", "4099", "4098", "4097", "4145", "4128", "4123", "4122", "4129",
|
|
228
|
+
"4121", "4120", "4119", "4118", "4139", "4138", "4137", "4140", "4136", "4135",
|
|
229
|
+
"4134", "4133", "4624", "4625", "4617", "4616", "4626", "4627", "4611", "4610",
|
|
230
|
+
"4640", "4641", "4633", "4632", "4651", "4652", "4648", "4647", "4870", "4869",
|
|
231
|
+
"4868", "4867", "4872", "4871", "4866", "4865", "5382", "5381", "5384", "5383",
|
|
232
|
+
"5378", "5377"
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
search_args = {
|
|
236
|
+
"start_datetime": start_datetime,
|
|
237
|
+
"end_datetime": end_datetime,
|
|
238
|
+
"conditions": [
|
|
239
|
+
{
|
|
240
|
+
"column": "datetime",
|
|
241
|
+
"operator": 3, # BETWEEN
|
|
242
|
+
"values": [start_datetime, end_datetime]
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"column": "event_type_id.code",
|
|
246
|
+
"operator": 2, # IN (BioStar API uses 2 for IN operation)
|
|
247
|
+
"values": AUTH_SUCCESS_CODES
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
"limit": 1000,
|
|
251
|
+
"offset": 0,
|
|
252
|
+
"order_by": "datetime",
|
|
253
|
+
"order_type": "desc"
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
logger.info(f" Searching authentication events for door: {door_name_actual} (ID: {door_id_actual})")
|
|
257
|
+
logger.info(f" Search with {len(AUTH_SUCCESS_CODES)} auth event codes")
|
|
258
|
+
events_result = await self.event_handler.search_events(search_args)
|
|
259
|
+
|
|
260
|
+
# Extract events from EventHandler response
|
|
261
|
+
events = []
|
|
262
|
+
logger.info(f" Event search result type: {type(events_result)}")
|
|
263
|
+
|
|
264
|
+
# Log the actual query sent to API
|
|
265
|
+
if isinstance(events_result, list) and len(events_result) > 0:
|
|
266
|
+
result_text = events_result[0].text if hasattr(events_result[0], 'text') else str(events_result[0])
|
|
267
|
+
try:
|
|
268
|
+
result_data = json.loads(result_text)
|
|
269
|
+
if 'data' in result_data and 'request_query' in result_data['data']:
|
|
270
|
+
logger.info(f" Actual API query sent: {json.dumps(result_data['data']['request_query'], indent=2, ensure_ascii=False)}")
|
|
271
|
+
except:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
if events_result and len(events_result) > 0:
|
|
275
|
+
# Parse the TextContent response
|
|
276
|
+
result_text = events_result[0].text
|
|
277
|
+
logger.info(f" Result text length: {len(result_text)} chars")
|
|
278
|
+
logger.info(f" Result preview: {result_text[:200]}...")
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
result_data = json.loads(result_text)
|
|
282
|
+
logger.info(f" Result status: {result_data.get('status')}")
|
|
283
|
+
|
|
284
|
+
if result_data.get('status') == 'success':
|
|
285
|
+
events = result_data.get('data', {}).get('events', [])
|
|
286
|
+
logger.info(f" Found {len(events)} events for door {door_name_actual}")
|
|
287
|
+
|
|
288
|
+
# Log sample events
|
|
289
|
+
if events:
|
|
290
|
+
logger.info(f" Sample events (first 3):")
|
|
291
|
+
for i, evt in enumerate(events[:3]):
|
|
292
|
+
logger.info(f" {i+1}. Time: {evt.get('datetime')}, User: {evt.get('user_id')}, Device: {evt.get('device_id')}, Event: {evt.get('event_type_id')}")
|
|
293
|
+
|
|
294
|
+
# DEBUG: Print FULL first event to see actual structure
|
|
295
|
+
logger.info(f" FULL first event structure:")
|
|
296
|
+
logger.info(json.dumps(events[0], indent=2, ensure_ascii=False, default=str)[:2000])
|
|
297
|
+
else:
|
|
298
|
+
logger.error(f" Event search failed: {result_data.get('error', 'Unknown error')}")
|
|
299
|
+
except json.JSONDecodeError as e:
|
|
300
|
+
logger.error(f" Failed to parse event result: {e}")
|
|
301
|
+
else:
|
|
302
|
+
logger.warning(f" No event result returned")
|
|
303
|
+
|
|
304
|
+
# Step 4: Analyze entry/exit patterns
|
|
305
|
+
user_status = {} # user_id -> {"name": str, "entries": [], "exits": [], "status": str}
|
|
306
|
+
|
|
307
|
+
# Step 4.1: If still no exit device, dynamically detect from events
|
|
308
|
+
# Collect all unique device IDs from events
|
|
309
|
+
if not exit_device_id:
|
|
310
|
+
logger.info(f" Dynamically detecting exit device from events...")
|
|
311
|
+
event_devices = set()
|
|
312
|
+
for event in events:
|
|
313
|
+
device_obj = event.get('device_id', {})
|
|
314
|
+
if isinstance(device_obj, dict):
|
|
315
|
+
device_id_temp = device_obj.get('id')
|
|
316
|
+
else:
|
|
317
|
+
device_id_temp = device_obj
|
|
318
|
+
if device_id_temp:
|
|
319
|
+
event_devices.add(str(device_id_temp))
|
|
320
|
+
|
|
321
|
+
logger.info(f" Devices found in events: {event_devices}")
|
|
322
|
+
|
|
323
|
+
# If there's a device that's NOT the entry device, treat it as exit device
|
|
324
|
+
other_devices = event_devices - {str(entry_device_id)}
|
|
325
|
+
if other_devices:
|
|
326
|
+
# Use the first other device as exit device
|
|
327
|
+
exit_device_id = list(other_devices)[0]
|
|
328
|
+
logger.info(f" Auto-detected exit device: {exit_device_id}")
|
|
329
|
+
|
|
330
|
+
# Try to get device name
|
|
331
|
+
try:
|
|
332
|
+
dev_response = await self.session.get(f"/api/devices/{exit_device_id}")
|
|
333
|
+
dev_data = dev_response.json() if dev_response else {}
|
|
334
|
+
if dev_data and 'Device' in dev_data:
|
|
335
|
+
exit_device_name = dev_data['Device'].get('name', 'Unknown')
|
|
336
|
+
logger.info(f" Auto-detected exit device name: {exit_device_name}")
|
|
337
|
+
except:
|
|
338
|
+
exit_device_name = f"Device {exit_device_id}"
|
|
339
|
+
else:
|
|
340
|
+
logger.info(f" ℹ Only entry device found in events, no exit device")
|
|
341
|
+
|
|
342
|
+
skipped_no_user = 0
|
|
343
|
+
for event in events:
|
|
344
|
+
# Extract nested values (API returns nested dicts)
|
|
345
|
+
user_obj = event.get('user_id', {})
|
|
346
|
+
device_obj = event.get('device_id', {})
|
|
347
|
+
event_type = event.get('event_type_id', {})
|
|
348
|
+
|
|
349
|
+
# Handle both dict and direct value formats
|
|
350
|
+
if isinstance(user_obj, dict):
|
|
351
|
+
user_id = user_obj.get('user_id')
|
|
352
|
+
user_name = user_obj.get('name', f"User {user_id}")
|
|
353
|
+
else:
|
|
354
|
+
user_id = user_obj
|
|
355
|
+
user_name = f"User {user_id}"
|
|
356
|
+
|
|
357
|
+
if isinstance(device_obj, dict):
|
|
358
|
+
device_id = device_obj.get('id')
|
|
359
|
+
else:
|
|
360
|
+
device_id = device_obj
|
|
361
|
+
|
|
362
|
+
event_datetime = event.get('datetime')
|
|
363
|
+
event_code = event_type.get('code') if isinstance(event_type, dict) else event_type
|
|
364
|
+
|
|
365
|
+
if not user_id:
|
|
366
|
+
skipped_no_user += 1
|
|
367
|
+
logger.info(f" Skipping event (no user_id): device={device_id}, code={event_code}")
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
logger.info(f" Processing: user={user_id} ({user_name}), device={device_id}, code={event_code}")
|
|
371
|
+
|
|
372
|
+
# Initialize user tracking
|
|
373
|
+
if user_id not in user_status:
|
|
374
|
+
user_status[user_id] = {
|
|
375
|
+
"name": user_name,
|
|
376
|
+
"entries": [],
|
|
377
|
+
"exits": [],
|
|
378
|
+
"status": "unknown"
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# Categorize as entry or exit based on device (if exit device is configured)
|
|
382
|
+
if exit_device_id and str(device_id) == str(exit_device_id):
|
|
383
|
+
# Exit device detected
|
|
384
|
+
user_status[user_id]["exits"].append({
|
|
385
|
+
"datetime": event_datetime,
|
|
386
|
+
"device": exit_device_name
|
|
387
|
+
})
|
|
388
|
+
else:
|
|
389
|
+
# Default: treat as entry (includes entry device and any unrecognized devices)
|
|
390
|
+
user_status[user_id]["entries"].append({
|
|
391
|
+
"datetime": event_datetime,
|
|
392
|
+
"device": entry_device_name if str(device_id) == str(entry_device_id) else f"Device {device_id}"
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
# Log analysis start
|
|
396
|
+
logger.info(f" Analyzing entry/exit patterns for {len(user_status)} users")
|
|
397
|
+
logger.info(f" Total events: {len(events)}, User events: {len(events) - skipped_no_user}, Skipped (no user): {skipped_no_user}")
|
|
398
|
+
|
|
399
|
+
# Step 5: Determine current status for each user
|
|
400
|
+
currently_inside = []
|
|
401
|
+
recently_exited = []
|
|
402
|
+
|
|
403
|
+
for user_id, data in user_status.items():
|
|
404
|
+
entries = sorted(data["entries"], key=lambda x: x["datetime"], reverse=True)
|
|
405
|
+
exits = sorted(data["exits"], key=lambda x: x["datetime"], reverse=True)
|
|
406
|
+
|
|
407
|
+
# Get most recent entry and exit
|
|
408
|
+
last_entry = entries[0] if entries else None
|
|
409
|
+
last_exit = exits[0] if exits else None
|
|
410
|
+
|
|
411
|
+
# Determine status
|
|
412
|
+
if last_entry and not last_exit:
|
|
413
|
+
# Entered but never exited
|
|
414
|
+
data["status"] = "inside"
|
|
415
|
+
data["last_entry_time"] = last_entry["datetime"]
|
|
416
|
+
currently_inside.append(data)
|
|
417
|
+
elif last_entry and last_exit:
|
|
418
|
+
# Compare timestamps
|
|
419
|
+
if last_entry["datetime"] > last_exit["datetime"]:
|
|
420
|
+
# Last action was entry
|
|
421
|
+
data["status"] = "inside"
|
|
422
|
+
data["last_entry_time"] = last_entry["datetime"]
|
|
423
|
+
currently_inside.append(data)
|
|
424
|
+
else:
|
|
425
|
+
# Last action was exit
|
|
426
|
+
data["status"] = "outside"
|
|
427
|
+
data["last_exit_time"] = last_exit["datetime"]
|
|
428
|
+
recently_exited.append(data)
|
|
429
|
+
elif last_exit:
|
|
430
|
+
# Only exits recorded (unusual)
|
|
431
|
+
data["status"] = "outside"
|
|
432
|
+
data["last_exit_time"] = last_exit["datetime"]
|
|
433
|
+
recently_exited.append(data)
|
|
434
|
+
|
|
435
|
+
# Step 6: Prepare response
|
|
436
|
+
result = {
|
|
437
|
+
"door": {
|
|
438
|
+
"id": door_id_actual,
|
|
439
|
+
"name": door_name_actual,
|
|
440
|
+
"entry_device": {
|
|
441
|
+
"id": entry_device_id,
|
|
442
|
+
"name": entry_device_name
|
|
443
|
+
},
|
|
444
|
+
"exit_device": {
|
|
445
|
+
"id": exit_device_id,
|
|
446
|
+
"name": exit_device_name
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
"analysis_period": {
|
|
450
|
+
"start": start_datetime,
|
|
451
|
+
"end": end_datetime,
|
|
452
|
+
"hours": hours
|
|
453
|
+
},
|
|
454
|
+
"summary": {
|
|
455
|
+
"total_users_tracked": len(user_status),
|
|
456
|
+
"currently_inside": len(currently_inside),
|
|
457
|
+
"recently_exited": len(recently_exited),
|
|
458
|
+
"total_events": len(events)
|
|
459
|
+
},
|
|
460
|
+
"currently_inside": [
|
|
461
|
+
{
|
|
462
|
+
"user_id": uid,
|
|
463
|
+
"name": data["name"],
|
|
464
|
+
"entry_time": data.get("last_entry_time"),
|
|
465
|
+
"total_entries": len(data["entries"]),
|
|
466
|
+
"total_exits": len(data["exits"])
|
|
467
|
+
}
|
|
468
|
+
for uid, data in user_status.items()
|
|
469
|
+
if data["status"] == "inside"
|
|
470
|
+
],
|
|
471
|
+
"recently_exited": [
|
|
472
|
+
{
|
|
473
|
+
"user_id": uid,
|
|
474
|
+
"name": data["name"],
|
|
475
|
+
"exit_time": data.get("last_exit_time"),
|
|
476
|
+
"total_entries": len(data["entries"]),
|
|
477
|
+
"total_exits": len(data["exits"])
|
|
478
|
+
}
|
|
479
|
+
for uid, data in user_status.items()
|
|
480
|
+
if data["status"] == "outside"
|
|
481
|
+
][:10] # Limit to last 10 exited users
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if include_details:
|
|
485
|
+
result["all_events"] = events[:50] # Include first 50 events for reference
|
|
486
|
+
|
|
487
|
+
# Log final summary
|
|
488
|
+
logger.info(f" Analysis Summary:")
|
|
489
|
+
logger.info(f" Currently inside: {len(currently_inside)} users")
|
|
490
|
+
logger.info(f" Recently exited: {len(recently_exited)} users")
|
|
491
|
+
logger.info(f" Total events analyzed: {len(events)}")
|
|
492
|
+
|
|
493
|
+
if currently_inside:
|
|
494
|
+
logger.info(f" Users currently inside:")
|
|
495
|
+
for u in currently_inside[:5]: # Show first 5
|
|
496
|
+
logger.info(f" - {u['name']} (entered at {u.get('last_entry_time')})")
|
|
497
|
+
|
|
498
|
+
return self.success_response(
|
|
499
|
+
result,
|
|
500
|
+
f" {door_name_actual} 재실 현황 분석 완료 (최근 {hours}시간)"
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
except Exception as e:
|
|
504
|
+
logger.error(f" Error in occupancy analysis: {e}")
|
|
505
|
+
import traceback
|
|
506
|
+
logger.error(traceback.format_exc())
|
|
507
|
+
return self.error_response(
|
|
508
|
+
f"재실 현황 분석 중 오류 발생: {str(e)}",
|
|
509
|
+
{"exception": str(e), "traceback": traceback.format_exc()}
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
async def analyze_door_traffic(self, args: Dict[str, Any]) -> list[TextContent]:
|
|
513
|
+
"""
|
|
514
|
+
Analyze door traffic patterns.
|
|
515
|
+
출입문의 출입 패턴을 분석합니다.
|
|
516
|
+
"""
|
|
517
|
+
try:
|
|
518
|
+
# Extract parameters from args dict
|
|
519
|
+
door_name = args.get("door_name")
|
|
520
|
+
door_id = args.get("door_id")
|
|
521
|
+
start_date = args.get("start_date")
|
|
522
|
+
end_date = args.get("end_date")
|
|
523
|
+
|
|
524
|
+
# This is a simplified version - can be expanded
|
|
525
|
+
return self.success_response(
|
|
526
|
+
{
|
|
527
|
+
"message": "Door traffic analysis feature - Coming soon!",
|
|
528
|
+
"door_name": door_name,
|
|
529
|
+
"door_id": door_id,
|
|
530
|
+
"start_date": start_date,
|
|
531
|
+
"end_date": end_date
|
|
532
|
+
},
|
|
533
|
+
"출입 패턴 분석 기능 (준비 중)"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
except Exception as e:
|
|
537
|
+
return self.error_response(
|
|
538
|
+
f"출입 패턴 분석 중 오류 발생: {str(e)}",
|
|
539
|
+
{"exception": str(e)}
|
|
540
|
+
)
|
|
541
|
+
|