""" CameraService — HTTP client for Hikvision ISAPI. All endpoints use HTTP Basic auth with the camera's web credentials. Credentials are resolved from device SSH overrides (reusing the same username/password fields) — camera HTTP auth often matches SSH. ISAPI base: http:///ISAPI/ """ import logging import xml.etree.ElementTree as ET from dataclasses import dataclass from typing import AsyncIterator, Optional import httpx from app.models.device import Device from app.models.object import Object from app.services.crypto import decrypt log = logging.getLogger(__name__) @dataclass class CameraResult: success: bool status_code: int = 0 body: str = "" data: dict = None error: str = "" def __post_init__(self): if self.data is None: self.data = {} def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str, int]: """ Returns (username, password, http_port). Priority: 1. connection_params.username / connection_params.password (plaintext, set via DeviceEditModal) 2. ssh_user_override / ssh_pass_override_enc (legacy) 3. object ssh_user / ssh_pass_enc """ cp = device.connection_params or {} meta = device.device_meta or {} port = int(meta.get("http_port", 80)) user = (cp.get("username") or device.ssh_user_override or obj.ssh_user or "admin") if cp.get("password"): password = str(cp["password"]) # plaintext from connection_params (DeviceEditModal) pwd_source = "connection_params.password" elif device.ssh_pass_override_enc: password = decrypt(device.ssh_pass_override_enc) pwd_source = "ssh_pass_override_enc" else: # Do NOT fall back to obj.ssh_pass_enc — object SSH password is for servers, not cameras password = "" pwd_source = "empty" log.info( "[camera] %s | user=%r port=%d pwd_source=%s pwd_len=%d cp_keys=%s", device.ip, user, port, pwd_source, len(password), list(cp.keys()), ) return user, password, port def _xml_to_dict(element: ET.Element) -> dict: """Recursively convert an XML element to a dict (simple, no attributes).""" result = {} for child in element: # Strip namespace if present: {ns}tag → tag tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag if len(child): result[tag] = _xml_to_dict(child) else: result[tag] = child.text return result async def isapi_get( device: Device, obj: Object, path: str, timeout: int = 10, ) -> CameraResult: """Send GET to ISAPI endpoint, return parsed XML body.""" user, password, port = _resolve_camera_credentials(device, obj) url = f"http://{device.ip}:{port}/ISAPI/{path.lstrip('/')}" try: async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client: resp = await client.get(url) except httpx.TimeoutException: return CameraResult(success=False, error="Connection timed out") except Exception as exc: return CameraResult(success=False, error=str(exc)) body = resp.text data: dict = {} if body.strip().startswith("<"): try: root = ET.fromstring(body) data = _xml_to_dict(root) except ET.ParseError: pass return CameraResult( success=resp.status_code < 400, status_code=resp.status_code, body=body, data=data, ) async def isapi_snapshot( device: Device, obj: Object, channel: int = 1, timeout: int = 10, ) -> tuple[bytes, str]: """ Fetch a JPEG snapshot from the camera via ISAPI. Returns (image_bytes, content_type). Raises RuntimeError on failure. """ user, password, port = _resolve_camera_credentials(device, obj) # Try connection_params.frame_url first (custom snapshot URL) frame_url: Optional[str] = (device.connection_params or {}).get("frame_url") if frame_url: url = frame_url else: # Hikvision: channel 1 main stream = 101, channel 2 = 201, etc. url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{channel}01/picture?snapShotImageType=JPEG" log.info("[camera] snapshot GET %s (user=%r)", url, user) try: async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client: resp = await client.get(url) except httpx.TimeoutException: raise RuntimeError("Камера не отвечает (timeout)") except Exception as exc: raise RuntimeError(str(exc)) log.info("[camera] snapshot response HTTP %d, content-type=%s, len=%d", resp.status_code, resp.headers.get("content-type"), len(resp.content)) if resp.status_code == 401: raise RuntimeError( f"401 — user={user!r} password={password!r} url={url}" ) if resp.status_code >= 400: raise RuntimeError(f"Камера вернула HTTP {resp.status_code}, url={url}") content_type = resp.headers.get("content-type", "image/jpeg") return resp.content, content_type async def mjpeg_stream( device: Device, obj: Object, channel: int = 1, timeout: int = 30, ) -> AsyncIterator[bytes]: """ Proxy MJPEG stream from camera. Yields raw bytes chunks. Tries connection_params.stream_url (if it looks like http/mjpeg), then falls back to Hikvision ISAPI MJPEG endpoint. """ user, password, port = _resolve_camera_credentials(device, obj) cp = device.connection_params or {} stream_url = str(cp.get("stream_url", "")) if cp.get("stream_url") else "" if stream_url.startswith("http"): url = stream_url else: url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{channel}01/httppreview" async with httpx.AsyncClient(auth=(user, password), timeout=httpx.Timeout(timeout)) as client: async with client.stream("GET", url) as resp: if resp.status_code == 401: raise RuntimeError("Ошибка авторизации (401)") if resp.status_code >= 400: raise RuntimeError(f"Камера вернула HTTP {resp.status_code}") async for chunk in resp.aiter_bytes(chunk_size=4096): yield chunk async def isapi_put( device: Device, obj: Object, path: str, xml_body: str, timeout: int = 10, ) -> CameraResult: """Send PUT with XML body to ISAPI endpoint.""" user, password, port = _resolve_camera_credentials(device, obj) url = f"http://{device.ip}:{port}/ISAPI/{path.lstrip('/')}" try: async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client: resp = await client.put( url, content=xml_body.encode(), headers={"Content-Type": "application/xml"}, ) except httpx.TimeoutException: return CameraResult(success=False, error="Connection timed out") except Exception as exc: return CameraResult(success=False, error=str(exc)) return CameraResult( success=resp.status_code < 400, status_code=resp.status_code, body=resp.text, )