utp_service/backend/app/services/camera.py
2026-04-14 15:41:19 +03:00

223 lines
7.2 KiB
Python

"""
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://<ip>/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
pwd_source = "connection_params.password"
elif device.ssh_pass_override_enc:
password = decrypt(device.ssh_pass_override_enc)
pwd_source = "ssh_pass_override_enc"
elif obj.ssh_pass_enc:
password = decrypt(obj.ssh_pass_enc)
pwd_source = "obj.ssh_pass_enc"
else:
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}, 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,
)