Phase 5: Camera Discovery (T049-T055)
Implemented complete camera discovery system with Redis caching:
**Tests:**
- Contract tests for GET /api/v1/cameras (list cameras)
- Contract tests for GET /api/v1/cameras/{id} (camera detail)
- Integration tests for camera data consistency
- Tests for caching behavior and all authentication roles
**Schemas:**
- CameraInfo: Camera data model (id, name, description, has_ptz, has_video_sensor, status)
- CameraListResponse: List endpoint response
- CameraDetailResponse: Detail endpoint response with extended fields
- CameraStatusEnum: Status constants (online, offline, unknown, error, maintenance)
**Services:**
- CameraService: list_cameras(), get_camera(), invalidate_cache()
- Additional methods: search_cameras(), get_online_cameras(), get_ptz_cameras()
- Integrated Redis caching with 60s TTL
- Automatic cache invalidation and refresh
**Router Endpoints:**
- GET /api/v1/cameras - List all cameras (cached, 60s TTL)
- GET /api/v1/cameras/{id} - Get camera details
- POST /api/v1/cameras/refresh - Force refresh (bypass cache)
- GET /api/v1/cameras/search/{query} - Search cameras by name/description
- GET /api/v1/cameras/filter/online - Get online cameras only
- GET /api/v1/cameras/filter/ptz - Get PTZ cameras only
**Authorization:**
- All camera endpoints require at least Viewer role
- All authenticated users can read camera data
**Integration:**
- Registered camera router in main.py
- Camera service communicates with SDK Bridge via gRPC
- Redis caching for performance optimization
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
203
src/api/services/camera_service.py
Normal file
203
src/api/services/camera_service.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Camera service for managing camera 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 camera data (60 seconds)
|
||||
CAMERA_CACHE_TTL = 60
|
||||
|
||||
|
||||
class CameraService:
|
||||
"""Service for camera operations"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize camera service"""
|
||||
pass
|
||||
|
||||
async def list_cameras(self, use_cache: bool = True) -> Dict[str, Any]:
|
||||
"""
|
||||
Get list of all cameras from SDK Bridge
|
||||
|
||||
Args:
|
||||
use_cache: Whether to use Redis cache (default: True)
|
||||
|
||||
Returns:
|
||||
Dictionary with 'cameras' list and 'total' count
|
||||
"""
|
||||
cache_key = "cameras:list"
|
||||
|
||||
# Try to get from cache first
|
||||
if use_cache:
|
||||
cached_data = await redis_client.get_json(cache_key)
|
||||
if cached_data:
|
||||
logger.info("camera_list_cache_hit")
|
||||
return cached_data
|
||||
|
||||
logger.info("camera_list_cache_miss_fetching_from_sdk")
|
||||
|
||||
try:
|
||||
# Fetch cameras from SDK Bridge via gRPC
|
||||
cameras = await sdk_bridge_client.list_cameras()
|
||||
|
||||
# Transform to response format
|
||||
result = {
|
||||
"cameras": cameras,
|
||||
"total": len(cameras)
|
||||
}
|
||||
|
||||
# Cache the result
|
||||
if use_cache:
|
||||
await redis_client.set_json(cache_key, result, expire=CAMERA_CACHE_TTL)
|
||||
logger.info("camera_list_cached", count=len(cameras), ttl=CAMERA_CACHE_TTL)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("camera_list_failed", error=str(e), exc_info=True)
|
||||
# Return empty list on error
|
||||
return {"cameras": [], "total": 0}
|
||||
|
||||
async def get_camera(self, camera_id: int, use_cache: bool = True) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get single camera by ID
|
||||
|
||||
Args:
|
||||
camera_id: Camera ID (channel number)
|
||||
use_cache: Whether to use Redis cache (default: True)
|
||||
|
||||
Returns:
|
||||
Camera dictionary or None if not found
|
||||
"""
|
||||
cache_key = f"cameras:detail:{camera_id}"
|
||||
|
||||
# Try to get from cache first
|
||||
if use_cache:
|
||||
cached_data = await redis_client.get_json(cache_key)
|
||||
if cached_data:
|
||||
logger.info("camera_detail_cache_hit", camera_id=camera_id)
|
||||
return cached_data
|
||||
|
||||
logger.info("camera_detail_cache_miss_fetching_from_sdk", camera_id=camera_id)
|
||||
|
||||
try:
|
||||
# Fetch camera from SDK Bridge via gRPC
|
||||
camera = await sdk_bridge_client.get_camera(camera_id)
|
||||
|
||||
if not camera:
|
||||
logger.warning("camera_not_found", camera_id=camera_id)
|
||||
return None
|
||||
|
||||
# Cache the result
|
||||
if use_cache:
|
||||
await redis_client.set_json(cache_key, camera, expire=CAMERA_CACHE_TTL)
|
||||
logger.info("camera_detail_cached", camera_id=camera_id, ttl=CAMERA_CACHE_TTL)
|
||||
|
||||
return camera
|
||||
|
||||
except Exception as e:
|
||||
logger.error("camera_detail_failed", camera_id=camera_id, error=str(e), exc_info=True)
|
||||
return None
|
||||
|
||||
async def invalidate_cache(self, camera_id: Optional[int] = None) -> None:
|
||||
"""
|
||||
Invalidate camera cache
|
||||
|
||||
Args:
|
||||
camera_id: Specific camera ID to invalidate, or None to invalidate all
|
||||
"""
|
||||
if camera_id is not None:
|
||||
# Invalidate specific camera
|
||||
cache_key = f"cameras:detail:{camera_id}"
|
||||
await redis_client.delete(cache_key)
|
||||
logger.info("camera_cache_invalidated", camera_id=camera_id)
|
||||
else:
|
||||
# Invalidate camera list cache
|
||||
await redis_client.delete("cameras:list")
|
||||
logger.info("camera_list_cache_invalidated")
|
||||
|
||||
async def refresh_camera_list(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Force refresh camera list from SDK Bridge (bypass cache)
|
||||
|
||||
Returns:
|
||||
Dictionary with 'cameras' list and 'total' count
|
||||
"""
|
||||
logger.info("camera_list_force_refresh")
|
||||
|
||||
# Invalidate cache first
|
||||
await self.invalidate_cache()
|
||||
|
||||
# Fetch fresh data
|
||||
return await self.list_cameras(use_cache=False)
|
||||
|
||||
async def get_camera_count(self) -> int:
|
||||
"""
|
||||
Get total number of cameras
|
||||
|
||||
Returns:
|
||||
Total camera count
|
||||
"""
|
||||
result = await self.list_cameras(use_cache=True)
|
||||
return result["total"]
|
||||
|
||||
async def search_cameras(self, query: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search cameras by name or description
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
|
||||
Returns:
|
||||
List of matching cameras
|
||||
"""
|
||||
result = await self.list_cameras(use_cache=True)
|
||||
cameras = result["cameras"]
|
||||
|
||||
# Simple case-insensitive search
|
||||
query_lower = query.lower()
|
||||
matching = [
|
||||
cam for cam in cameras
|
||||
if query_lower in cam.get("name", "").lower()
|
||||
or query_lower in cam.get("description", "").lower()
|
||||
]
|
||||
|
||||
logger.info("camera_search", query=query, matches=len(matching))
|
||||
return matching
|
||||
|
||||
async def get_online_cameras(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of online cameras only
|
||||
|
||||
Returns:
|
||||
List of online cameras
|
||||
"""
|
||||
result = await self.list_cameras(use_cache=True)
|
||||
cameras = result["cameras"]
|
||||
|
||||
online = [cam for cam in cameras if cam.get("status") == "online"]
|
||||
|
||||
logger.info("online_cameras_retrieved", count=len(online), total=len(cameras))
|
||||
return online
|
||||
|
||||
async def get_ptz_cameras(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of cameras with PTZ capabilities
|
||||
|
||||
Returns:
|
||||
List of PTZ cameras
|
||||
"""
|
||||
result = await self.list_cameras(use_cache=True)
|
||||
cameras = result["cameras"]
|
||||
|
||||
ptz_cameras = [cam for cam in cameras if cam.get("has_ptz", False)]
|
||||
|
||||
logger.info("ptz_cameras_retrieved", count=len(ptz_cameras), total=len(cameras))
|
||||
return ptz_cameras
|
||||
Reference in New Issue
Block a user