This MVP release provides a complete full-stack solution for managing action mappings in Geutebruck's GeViScope and GeViSoft video surveillance systems. ## Features ### Flutter Web Application (Port 8081) - Modern, responsive UI for managing action mappings - Action picker dialog with full parameter configuration - Support for both GSC (GeViScope) and G-Core server actions - Consistent UI for input and output actions with edit/delete capabilities - Real-time action mapping creation, editing, and deletion - Server categorization (GSC: prefix for GeViScope, G-Core: prefix for G-Core servers) ### FastAPI REST Backend (Port 8000) - RESTful API for action mapping CRUD operations - Action template service with comprehensive action catalog (247 actions) - Server management (G-Core and GeViScope servers) - Configuration tree reading and writing - JWT authentication with role-based access control - PostgreSQL database integration ### C# SDK Bridge (gRPC, Port 50051) - Native integration with GeViSoft SDK (GeViProcAPINET_4_0.dll) - Action mapping creation with correct binary format - Support for GSC and G-Core action types - Proper Camera parameter inclusion in action strings (fixes CrossSwitch bug) - Action ID lookup table with server-specific action IDs - Configuration reading/writing via SetupClient ## Bug Fixes - **CrossSwitch Bug**: GSC and G-Core actions now correctly display camera/PTZ head parameters in GeViSet - Action strings now include Camera parameter: `@ PanLeft (Comment: "", Camera: 101028)` - Proper filter flags and VideoInput=0 for action mappings - Correct action ID assignment (4198 for GSC, 9294 for G-Core PanLeft) ## Technical Stack - **Frontend**: Flutter Web, Dart, Dio HTTP client - **Backend**: Python FastAPI, PostgreSQL, Redis - **SDK Bridge**: C# .NET 8.0, gRPC, GeViSoft SDK - **Authentication**: JWT tokens - **Configuration**: GeViSoft .set files (binary format) ## Credentials - GeViSoft/GeViScope: username=sysadmin, password=masterkey - Default admin: username=admin, password=admin123 ## Deployment All services run on localhost: - Flutter Web: http://localhost:8081 - FastAPI: http://localhost:8000 - SDK Bridge gRPC: localhost:50051 - GeViServer: localhost (default port) Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
230 lines
7.2 KiB
Python
230 lines
7.2 KiB
Python
"""
|
|
Monitor service for managing monitor discovery and information
|
|
"""
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime
|
|
import structlog
|
|
|
|
from clients.sdk_bridge_client import sdk_bridge_client
|
|
from clients.redis_client import redis_client
|
|
from config import settings
|
|
|
|
logger = structlog.get_logger()
|
|
|
|
# Redis cache TTL for monitor data (60 seconds)
|
|
MONITOR_CACHE_TTL = 60
|
|
|
|
|
|
class MonitorService:
|
|
"""Service for monitor operations"""
|
|
|
|
def __init__(self):
|
|
"""Initialize monitor service"""
|
|
pass
|
|
|
|
async def list_monitors(self, use_cache: bool = True) -> Dict[str, Any]:
|
|
"""
|
|
Get list of all monitors from SDK Bridge
|
|
|
|
Args:
|
|
use_cache: Whether to use Redis cache (default: True)
|
|
|
|
Returns:
|
|
Dictionary with 'monitors' list and 'total' count
|
|
"""
|
|
cache_key = "monitors:list"
|
|
|
|
# Try to get from cache first
|
|
if use_cache:
|
|
cached_data = await redis_client.get_json(cache_key)
|
|
if cached_data:
|
|
logger.info("monitor_list_cache_hit")
|
|
return cached_data
|
|
|
|
logger.info("monitor_list_cache_miss_fetching_from_sdk")
|
|
|
|
try:
|
|
# Fetch monitors from SDK Bridge via gRPC
|
|
monitors = await sdk_bridge_client.list_monitors()
|
|
|
|
# Transform to response format
|
|
result = {
|
|
"monitors": monitors,
|
|
"total": len(monitors)
|
|
}
|
|
|
|
# Cache the result
|
|
if use_cache:
|
|
await redis_client.set_json(cache_key, result, expire=MONITOR_CACHE_TTL)
|
|
logger.info("monitor_list_cached", count=len(monitors), ttl=MONITOR_CACHE_TTL)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error("monitor_list_failed", error=str(e), exc_info=True)
|
|
# Return empty list on error
|
|
return {"monitors": [], "total": 0}
|
|
|
|
async def get_monitor(self, monitor_id: int, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get single monitor by ID
|
|
|
|
Args:
|
|
monitor_id: Monitor ID (output channel number)
|
|
use_cache: Whether to use Redis cache (default: True)
|
|
|
|
Returns:
|
|
Monitor dictionary or None if not found
|
|
"""
|
|
cache_key = f"monitors:detail:{monitor_id}"
|
|
|
|
# Try to get from cache first
|
|
if use_cache:
|
|
cached_data = await redis_client.get_json(cache_key)
|
|
if cached_data:
|
|
logger.info("monitor_detail_cache_hit", monitor_id=monitor_id)
|
|
return cached_data
|
|
|
|
logger.info("monitor_detail_cache_miss_fetching_from_sdk", monitor_id=monitor_id)
|
|
|
|
try:
|
|
# Fetch monitor from SDK Bridge via gRPC
|
|
monitor = await sdk_bridge_client.get_monitor(monitor_id)
|
|
|
|
if not monitor:
|
|
logger.warning("monitor_not_found", monitor_id=monitor_id)
|
|
return None
|
|
|
|
# Cache the result
|
|
if use_cache:
|
|
await redis_client.set_json(cache_key, monitor, expire=MONITOR_CACHE_TTL)
|
|
logger.info("monitor_detail_cached", monitor_id=monitor_id, ttl=MONITOR_CACHE_TTL)
|
|
|
|
return monitor
|
|
|
|
except Exception as e:
|
|
logger.error("monitor_detail_failed", monitor_id=monitor_id, error=str(e), exc_info=True)
|
|
return None
|
|
|
|
async def invalidate_cache(self, monitor_id: Optional[int] = None) -> None:
|
|
"""
|
|
Invalidate monitor cache
|
|
|
|
Args:
|
|
monitor_id: Specific monitor ID to invalidate, or None to invalidate all
|
|
"""
|
|
if monitor_id is not None:
|
|
# Invalidate specific monitor
|
|
cache_key = f"monitors:detail:{monitor_id}"
|
|
await redis_client.delete(cache_key)
|
|
logger.info("monitor_cache_invalidated", monitor_id=monitor_id)
|
|
else:
|
|
# Invalidate monitor list cache
|
|
await redis_client.delete("monitors:list")
|
|
logger.info("monitor_list_cache_invalidated")
|
|
|
|
async def refresh_monitor_list(self) -> Dict[str, Any]:
|
|
"""
|
|
Force refresh monitor list from SDK Bridge (bypass cache)
|
|
|
|
Returns:
|
|
Dictionary with 'monitors' list and 'total' count
|
|
"""
|
|
logger.info("monitor_list_force_refresh")
|
|
|
|
# Invalidate cache first
|
|
await self.invalidate_cache()
|
|
|
|
# Fetch fresh data
|
|
return await self.list_monitors(use_cache=False)
|
|
|
|
async def get_monitor_count(self) -> int:
|
|
"""
|
|
Get total number of monitors
|
|
|
|
Returns:
|
|
Total monitor count
|
|
"""
|
|
result = await self.list_monitors(use_cache=True)
|
|
return result["total"]
|
|
|
|
async def search_monitors(self, query: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Search monitors by name or description
|
|
|
|
Args:
|
|
query: Search query string
|
|
|
|
Returns:
|
|
List of matching monitors
|
|
"""
|
|
result = await self.list_monitors(use_cache=True)
|
|
monitors = result["monitors"]
|
|
|
|
# Simple case-insensitive search
|
|
query_lower = query.lower()
|
|
matching = [
|
|
mon for mon in monitors
|
|
if query_lower in mon.get("name", "").lower()
|
|
or query_lower in mon.get("description", "").lower()
|
|
]
|
|
|
|
logger.info("monitor_search", query=query, matches=len(matching))
|
|
return matching
|
|
|
|
async def get_available_monitors(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get list of available (idle/free) monitors
|
|
|
|
Returns:
|
|
List of monitors with no camera assigned
|
|
"""
|
|
result = await self.list_monitors(use_cache=True)
|
|
monitors = result["monitors"]
|
|
|
|
# Available monitors have no camera assigned (current_camera_id is None or 0)
|
|
available = [
|
|
mon for mon in monitors
|
|
if mon.get("current_camera_id") is None or mon.get("current_camera_id") == 0
|
|
]
|
|
|
|
logger.info("available_monitors_retrieved", count=len(available), total=len(monitors))
|
|
return available
|
|
|
|
async def get_active_monitors(self) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get list of active monitors (displaying a camera)
|
|
|
|
Returns:
|
|
List of monitors with a camera assigned
|
|
"""
|
|
result = await self.list_monitors(use_cache=True)
|
|
monitors = result["monitors"]
|
|
|
|
# Active monitors have a camera assigned
|
|
active = [
|
|
mon for mon in monitors
|
|
if mon.get("current_camera_id") is not None and mon.get("current_camera_id") != 0
|
|
]
|
|
|
|
logger.info("active_monitors_retrieved", count=len(active), total=len(monitors))
|
|
return active
|
|
|
|
async def get_monitor_routing(self) -> Dict[int, Optional[int]]:
|
|
"""
|
|
Get current routing state (monitor_id -> camera_id mapping)
|
|
|
|
Returns:
|
|
Dictionary mapping monitor IDs to current camera IDs
|
|
"""
|
|
result = await self.list_monitors(use_cache=True)
|
|
monitors = result["monitors"]
|
|
|
|
routing = {
|
|
mon["id"]: mon.get("current_camera_id")
|
|
for mon in monitors
|
|
}
|
|
|
|
logger.info("monitor_routing_retrieved", monitors=len(routing))
|
|
return routing
|