220 lines
7.1 KiB
Python
220 lines
7.1 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 (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,
|
|
)
|