From e4d8249e832e468011a1bdaed20f15eea0c90b09 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 14 Apr 2026 14:12:01 +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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/devices.py | 22 ++ backend/app/services/camera.py | 38 ++++ frontend/src/components/DeviceEditModal.tsx | 106 +++++++++- frontend/src/pages/DeviceDetail.tsx | 219 +++++++++++++++++++- 4 files changed, 377 insertions(+), 8 deletions(-) diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py index d58c13f..01f0c8e 100644 --- a/backend/app/routers/devices.py +++ b/backend/app/routers/devices.py @@ -4,6 +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 sqlalchemy import select, update as sa_update from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +21,7 @@ from app.schemas.device import ( DeviceRead, DeviceUpdate, ) +from app.services import camera as camera_service from app.services import crypto router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"]) @@ -261,6 +263,26 @@ async def get_device_credentials( } +@router.get("/{device_id}/camera/snapshot") +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).""" + 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) + except RuntimeError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + return FastAPIResponse(content=image_bytes, media_type=content_type) + + @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 c685496..2fdce2c 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -95,6 +95,44 @@ async def isapi_get( ) +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 ISAPI channel format: channel 1 main = 101 + ch = f"{channel:02d}1" + url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture" + + 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)) + + if resp.status_code == 401: + raise RuntimeError("Ошибка авторизации (401) — проверьте логин/пароль камеры") + if resp.status_code >= 400: + raise RuntimeError(f"Камера вернула HTTP {resp.status_code}") + + content_type = resp.headers.get("content-type", "image/jpeg") + return resp.content, content_type + + async def isapi_put( device: Device, obj: Object, diff --git a/frontend/src/components/DeviceEditModal.tsx b/frontend/src/components/DeviceEditModal.tsx index 519036f..91b2e33 100644 --- a/frontend/src/components/DeviceEditModal.tsx +++ b/frontend/src/components/DeviceEditModal.tsx @@ -10,7 +10,7 @@ import { Select, message, } from 'antd' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { useCreateDevice, useUpdateDevice, @@ -54,6 +54,7 @@ interface Props { export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) { const [form] = Form.useForm() + const [category, setCategory] = useState(device?.category ?? 'other') const { data: racks } = useRacks(objId) const { data: sshKeys } = useSSHKeys() const createDevice = useCreateDevice(objId) @@ -64,6 +65,7 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props useEffect(() => { if (!open) return if (device) { + const cp = device.connection_params ?? {} form.setFieldsValue({ ip: device.ip, hostname: device.hostname ?? '', @@ -78,23 +80,65 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props ssh_user_override: device.ssh_user_override ?? '', ssh_port_override: device.ssh_port_override ?? undefined, ssh_key_id_override: device.ssh_key_id_override ?? undefined, - db_via_tunnel: !!(device.connection_params?.db_via_tunnel), + db_via_tunnel: !!(cp.db_via_tunnel), + // camera fields + cam_http_port: (device.device_meta as any)?.http_port ?? undefined, + cam_auth_type: (cp.auth_type as string) ?? undefined, + cam_username: (cp.username as string) ?? undefined, + cam_password: (cp.password as string) ?? undefined, + cam_frame_url: (cp.frame_url as string) ?? undefined, + cam_stream_url: (cp.stream_url as string) ?? undefined, }) + setCategory(device.category) } else { form.resetFields() + setCategory('other') } }, [open, device]) const handleOk = async () => { const values = await form.validateFields() - const { db_via_tunnel, ...rest } = values + const { + db_via_tunnel, + cam_http_port, + cam_auth_type, + cam_username, + cam_password, + cam_frame_url, + cam_stream_url, + ...rest + } = values const payload = stripEmpty(rest) if ('rack_id' in values && values.rack_id === undefined) { payload.rack_id = null } - // Merge db_via_tunnel into connection_params + const existingParams = device?.connection_params ?? {} - payload.connection_params = { ...existingParams, db_via_tunnel: !!db_via_tunnel } + const existingMeta = device?.device_meta ?? {} + + if (category === 'camera') { + // Camera: store plaintext password + urls in connection_params, http_port in device_meta + const camParams: Record = { ...existingParams } + if (cam_auth_type) camParams.auth_type = cam_auth_type + else delete camParams.auth_type + if (cam_username) camParams.username = cam_username + else delete camParams.username + if (cam_password) camParams.password = cam_password // plaintext, no encryption + else delete camParams.password + if (cam_frame_url) camParams.frame_url = cam_frame_url + else delete camParams.frame_url + if (cam_stream_url) camParams.stream_url = cam_stream_url + else delete camParams.stream_url + payload.connection_params = camParams + + const camMeta: Record = { ...existingMeta } + if (cam_http_port) camMeta.http_port = cam_http_port + else delete camMeta.http_port + payload.device_meta = camMeta + } else { + // Non-camera: only db_via_tunnel in connection_params + payload.connection_params = { ...existingParams, db_via_tunnel: !!db_via_tunnel } + } try { let saved: DeviceItem if (device) { @@ -127,7 +171,14 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props okText={device ? 'Сохранить' : 'Добавить'} width={640} > -
+ { + if (changed.category !== undefined) setCategory(changed.category) + }} + > @@ -227,6 +278,49 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props )} + + {category === 'camera' && ( + <> + Настройки камеры + + + + + + + + + + + + + + + + + + + + + + + + + )} ) diff --git a/frontend/src/pages/DeviceDetail.tsx b/frontend/src/pages/DeviceDetail.tsx index efa9a14..ece7108 100644 --- a/frontend/src/pages/DeviceDetail.tsx +++ b/frontend/src/pages/DeviceDetail.tsx @@ -9,6 +9,7 @@ import { FileSearchOutlined, InfoCircleOutlined, CameraOutlined, + ReloadOutlined, } from '@ant-design/icons' import { Badge, @@ -574,6 +575,216 @@ function AlertsTab({ deviceId }: { deviceId: number }) { ) } +// ─── Camera tab ─────────────────────────────────────────────────────────────── + +function CameraStatusInfo({ deviceId, tasks }: { deviceId: number; tasks: Task[] | undefined }) { + const latestTask = tasks + ?.filter((t) => t.type === 'camera_get_status' && t.status === 'success') + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0] + + if (!latestTask) return null + + return +} + +function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string; deviceId: number; taskDate: string }) { + const { data: detail, isLoading } = useTask(taskId) + const dr = detail?.device_results.find((r) => r.device_id === deviceId) + const data = dr?.data as { device_info?: Record; system_time?: Record } | undefined + + if (isLoading) return + if (!data?.device_info) return null + + const di = data.device_info + const st = data.system_time ?? {} + + return ( + + Последний статус + + ({dayjs(taskDate).format('DD.MM.YY HH:mm')}) + + + } + style={{ marginBottom: 16 }} + > + + {di.model && {String(di.model)}} + {di.firmwareVersion && {String(di.firmwareVersion)}} + {di.serialNumber && {String(di.serialNumber)}} + {di.macAddress && {String(di.macAddress)}} + {st.localTime && {String(st.localTime)}} + {st.timeZone && {String(st.timeZone)}} + + + ) +} + +function CameraTab({ + device, + objId, + tasks, + creds, +}: { + device: DeviceItem + objId: number + tasks: Task[] | undefined + creds: DeviceCredentials | undefined +}) { + const [snapshotKey, setSnapshotKey] = useState(0) + const [snapshotError, setSnapshotError] = useState(null) + const [channel, setChannel] = useState(1) + 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` + + const snapshotSrc = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot?channel=${channel}&_k=${snapshotKey}` + + const handleGetStatus = async () => { + try { + const task = await createTask.mutateAsync({ + type: 'camera_get_status', + target_scope: { type: 'device', id: device.id }, + params: {}, + }) + message.success( + Задача запущена. Открыть, + 5, + ) + } catch { + message.error('Не удалось запустить задачу') + } + } + + return ( + + + {/* Connection parameters */} + + + + {String(httpPort)} + + {authType && ( + + {authType} + + )} + {webUser && ( + + {webUser} + + )} + {creds?.connection_password && ( + + {creds.connection_password} + + )} + + + {rtspUrl} + + + + + + {params.frame_url && ( + + {String(params.frame_url)} + + )} + + + + {/* Last camera status */} + + + + + + + {/* Live snapshot */} + +