suprema-biostar-mcp 1.0.1__py3-none-any.whl

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