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>
204 lines
6.2 KiB
Python
204 lines
6.2 KiB
Python
"""
|
|
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
|