Files
geutebruck-api/src/api/services/camera_service.py
Geutebruck API Developer 4866a8edc3 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>
2025-12-09 09:19:27 +01:00

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