From f788067d5a72ae66210a2692de31381b464b599b Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 14 Apr 2026 15:30:02 +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=D1=8B=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/camera.py | 22 +++- frontend/src/pages/DeviceDetail.tsx | 158 ++++++++++++++++------------ 2 files changed, 111 insertions(+), 69 deletions(-) diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index c755975..7972b9e 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -8,6 +8,7 @@ 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 @@ -18,6 +19,8 @@ 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: @@ -50,13 +53,21 @@ def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str, 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 @@ -129,6 +140,7 @@ async def isapi_snapshot( ch = f"{channel:02d}1" url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture" + 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) @@ -137,10 +149,16 @@ async def isapi_snapshot( 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("Ошибка авторизации (401) — проверьте логин/пароль камеры") + raise RuntimeError( + f"Ошибка авторизации (401) — user={user!r}, url={url}. " + "Проверьте логин/пароль в настройках камеры." + ) if resp.status_code >= 400: - raise RuntimeError(f"Камера вернула HTTP {resp.status_code}") + raise RuntimeError(f"Камера вернула HTTP {resp.status_code}, url={url}") content_type = resp.headers.get("content-type", "image/jpeg") return resp.content, content_type diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx index 5467224..ec7b865 100644 --- a/frontend/src/pages/DeviceDetail.tsx +++ b/frontend/src/pages/DeviceDetail.tsx @@ -35,10 +35,11 @@ import { } from 'antd' import { useQueryClient } from '@tanstack/react-query' import dayjs from 'dayjs' -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts' import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem, type ObjectItem, type DeviceCredentials } from '../api/objects' +import { api } from '../api/client' import { getDeviceWebUrl } from '../utils/deviceUrl' import { elapsedAgo } from '../utils/time' import { useSnapshots, useSnapshotDiff } from '../api/snapshots' @@ -625,6 +626,42 @@ function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string; type ViewMode = 'snapshot' | 'stream' +// Loads image via axios (with JWT token), converts to blob URL +function useCameraBlob(url: string, enabled: boolean) { + const [blobUrl, setBlobUrl] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const load = async () => { + setLoading(true) + setError(null) + console.debug('[Camera] fetching', url) + try { + const resp = await api.get(url, { responseType: 'blob' }) + const blob = resp.data as Blob + console.debug('[Camera] got blob', blob.type, blob.size, 'bytes') + const objectUrl = URL.createObjectURL(blob) + setBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return objectUrl }) + } catch (err: any) { + const status = err?.response?.status + const detail = err?.response?.data?.detail ?? err?.message ?? 'Неизвестная ошибка' + const msg = status ? `HTTP ${status}: ${detail}` : String(detail) + console.error('[Camera] error', status, detail, err) + setError(msg) + } finally { + setLoading(false) + } + } + + const blobUrlRef = useRef(blobUrl) + blobUrlRef.current = blobUrl + useEffect(() => { + return () => { if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current) } + }, []) + + return { blobUrl, loading, error, reload: load } +} + function CameraTab({ device, objId, @@ -636,27 +673,25 @@ function CameraTab({ tasks: Task[] | undefined creds: DeviceCredentials | undefined }) { - const [snapshotKey, setSnapshotKey] = useState(0) - const [snapshotError, setSnapshotError] = useState(null) const [viewMode, setViewMode] = useState('snapshot') - const [streamError, setStreamError] = useState(null) const createTask = useCreateTask() + const snapshotUrl = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot` + const streamUrl = `/api/v1/objects/${objId}/devices/${device.id}/camera/stream` + + const snapshot = useCameraBlob(snapshotUrl, viewMode === 'snapshot') + const stream = useCameraBlob(streamUrl, viewMode === 'stream') + const params = device.connection_params ?? {} const meta = device.device_meta ?? {} const httpPort = meta.http_port ?? 80 const authType: string | null = (params.auth_type as string) ?? null const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null - - // 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.replace('rtsp://', `rtsp://${encodeURIComponent(webUser)}:${encodeURIComponent(creds.connection_password)}@`) : rtspBase - 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 { const task = await createTask.mutateAsync({ @@ -673,6 +708,12 @@ function CameraTab({ } } + const handleSwitchMode = (v: ViewMode) => { + setViewMode(v) + } + + const activeBlob = viewMode === 'snapshot' ? snapshot : stream + return ( {/* Left: params + status */} @@ -712,11 +753,7 @@ function CameraTab({ - @@ -731,70 +768,57 @@ function CameraTab({