From 0bbf06aeed19423848abff5a98fc2f21f27ff07a Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 14 Apr 2026 15:22:38 +0300 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D0=BA=D0=B0=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/plugins/builtin/camera/get_status.py | 2 +- backend/app/plugins/builtin/camera/set_ntp.py | 2 +- backend/app/routers/devices.py | 30 +++- backend/app/services/camera.py | 57 ++++++- frontend/src/pages/DeviceDetail.tsx | 145 ++++++++++-------- 5 files changed, 160 insertions(+), 76 deletions(-) diff --git a/backend/app/plugins/builtin/camera/get_status.py b/backend/app/plugins/builtin/camera/get_status.py index 3145851..198c295 100644 --- a/backend/app/plugins/builtin/camera/get_status.py +++ b/backend/app/plugins/builtin/camera/get_status.py @@ -15,7 +15,7 @@ class CameraGetStatusAction(BaseAction): params_schema = [] async def execute( - self, device: Device, obj: Object, params: dict, emit: Emitter + self, device: Device, obj: Object, params: dict, emit: Emitter, db=None ) -> ActionResult: await emit(f"[{device.ip}] GET /ISAPI/System/deviceInfo", "info") info_result = await camera_service.isapi_get(device, obj, "System/deviceInfo") diff --git a/backend/app/plugins/builtin/camera/set_ntp.py b/backend/app/plugins/builtin/camera/set_ntp.py index 265e792..96a2dee 100644 --- a/backend/app/plugins/builtin/camera/set_ntp.py +++ b/backend/app/plugins/builtin/camera/set_ntp.py @@ -27,7 +27,7 @@ class CameraSetNTPAction(BaseAction): ] async def execute( - self, device: Device, obj: Object, params: dict, emit: Emitter + self, device: Device, obj: Object, params: dict, emit: Emitter, db=None ) -> ActionResult: server = params.get("server", "").strip() if not server: diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index 01f0c8e..f59f1bf 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status -from fastapi.responses import Response as FastAPIResponse +from fastapi.responses import Response as FastAPIResponse, StreamingResponse from sqlalchemy import select, update as sa_update from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession @@ -267,22 +267,44 @@ async def get_device_credentials( async def camera_snapshot( obj_id: int, device_id: int, - channel: int = 1, db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user), ): - """Proxy a JPEG snapshot from a camera via ISAPI (or frame_url from connection_params).""" + """Proxy a JPEG snapshot from a camera (channel 1 main stream).""" device = await _get_device(db, obj_id, device_id) if device.category != "camera": raise HTTPException(status_code=400, detail="Device is not a camera") obj = await _get_object(db, obj_id) try: - image_bytes, content_type = await camera_service.isapi_snapshot(device, obj, channel=channel) + image_bytes, content_type = await camera_service.isapi_snapshot(device, obj) except RuntimeError as exc: raise HTTPException(status_code=502, detail=str(exc)) return FastAPIResponse(content=image_bytes, media_type=content_type) +@router.get("/{device_id}/camera/stream") +async def camera_stream( + obj_id: int, + device_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Proxy MJPEG stream from a camera.""" + device = await _get_device(db, obj_id, device_id) + if device.category != "camera": + raise HTTPException(status_code=400, detail="Device is not a camera") + obj = await _get_object(db, obj_id) + try: + generator = camera_service.mjpeg_stream(device, obj) + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + return StreamingResponse( + generator, + media_type="multipart/x-mixed-replace; boundary=--myboundary", + headers={"Cache-Control": "no-cache"}, + ) + + @router.patch("/{device_id}", response_model=DeviceRead) async def update_device( obj_id: int, diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index 2fdce2c..c755975 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -10,7 +10,7 @@ ISAPI base: http:///ISAPI/ import xml.etree.ElementTree as ET from dataclasses import dataclass -from typing import Optional +from typing import AsyncIterator, Optional import httpx @@ -33,18 +33,31 @@ class CameraResult: def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str, int]: - """Returns (username, password, http_port) — device overrides take priority.""" - user = device.ssh_user_override or obj.ssh_user or "admin" - port = device.device_meta.get("http_port", 80) if device.device_meta else 80 + """ + Returns (username, password, http_port). - if device.ssh_pass_override_enc: + 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 + elif device.ssh_pass_override_enc: password = decrypt(device.ssh_pass_override_enc) elif obj.ssh_pass_enc: password = decrypt(obj.ssh_pass_enc) else: password = "" - return user, password, int(port) + return user, password, port def _xml_to_dict(element: ET.Element) -> dict: @@ -133,6 +146,38 @@ async def isapi_snapshot( 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: + # Hikvision ISAPI MJPEG: channel 1 main = 101 + ch = f"{channel:02d}1" + url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/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, diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx index ece7108..5467224 100644 --- a/frontend/src/pages/DeviceDetail.tsx +++ b/frontend/src/pages/DeviceDetail.tsx @@ -623,6 +623,8 @@ function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string; ) } +type ViewMode = 'snapshot' | 'stream' + function CameraTab({ device, objId, @@ -636,20 +638,24 @@ function CameraTab({ }) { const [snapshotKey, setSnapshotKey] = useState(0) const [snapshotError, setSnapshotError] = useState(null) - const [channel, setChannel] = useState(1) + const [viewMode, setViewMode] = useState('snapshot') + const [streamError, setStreamError] = useState(null) const createTask = useCreateTask() const params = device.connection_params ?? {} const meta = device.device_meta ?? {} const httpPort = meta.http_port ?? 80 - const streamUrl: string | null = (params.stream_url as string) ?? null const authType: string | null = (params.auth_type as string) ?? null const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null - // Build default RTSP URL if not in connection_params - const rtspUrl = streamUrl ?? `rtsp://${device.ip}:554/Streaming/Channels/${channel}01` + // RTSP URL for display/copy (with creds if available) + const rtspBase = (params.stream_url as string) ?? `rtsp://${device.ip}:554/Streaming/Channels/101` + const rtspWithCreds = creds?.connection_password && webUser + ? rtspBase.replace('rtsp://', `rtsp://${webUser}:${creds.connection_password}@`) + : rtspBase - const snapshotSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot?channel=${channel}&_k=${snapshotKey}` + const snapshotSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot?_k=${snapshotKey}` + const streamSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/stream` const handleGetStatus = async () => { try { @@ -669,8 +675,8 @@ function CameraTab({ return ( - - {/* Connection parameters */} + {/* Left: params + status */} + @@ -682,28 +688,19 @@ function CameraTab({ )} {webUser && ( - + {webUser} )} {creds?.connection_password && ( - + {creds.connection_password} )} - - - {rtspUrl} - - - - + + + {rtspBase} + {params.frame_url && ( @@ -713,71 +710,91 @@ function CameraTab({ - {/* Last camera status */} - - {/* Live snapshot */} + {/* Right: live view */} +