фикс камер
This commit is contained in:
parent
ee38abbecb
commit
e4d8249e83
4 changed files with 377 additions and 8 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? {}
|
||||
const existingMeta = device?.device_meta ?? {}
|
||||
|
||||
if (category === 'camera') {
|
||||
// Camera: store plaintext password + urls in connection_params, http_port in device_meta
|
||||
const camParams: Record<string, unknown> = { ...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<string, unknown> = { ...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}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
onValuesChange={(changed) => {
|
||||
if (changed.category !== undefined) setCategory(changed.category)
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}>
|
||||
|
|
@ -227,6 +278,49 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
|
|||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{category === 'camera' && (
|
||||
<>
|
||||
<Divider orientation="left" plain>Настройки камеры</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="cam_http_port" label="HTTP порт">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} placeholder="80" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="cam_auth_type" label="Авторизация">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="basic"
|
||||
options={[
|
||||
{ value: 'basic', label: 'Basic' },
|
||||
{ value: 'digest', label: 'Digest' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="cam_username" label="Логин (веб-интерфейс)">
|
||||
<Input placeholder="admin" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="cam_password" label="Пароль (веб-интерфейс, открытый текст)">
|
||||
<Input.Password placeholder="••••••••" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="cam_frame_url" label="Frame URL (MJPEG снимок)">
|
||||
<Input placeholder="http://192.168.1.100/cgi-bin/snapshot.jpg" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cam_stream_url" label="Stream URL (RTSP)">
|
||||
<Input placeholder="rtsp://192.168.1.100:554/Streaming/Channels/101" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 <CameraStatusFromTask taskId={latestTask.id} deviceId={deviceId} taskDate={latestTask.created_at} />
|
||||
}
|
||||
|
||||
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<string, unknown>; system_time?: Record<string, unknown> } | undefined
|
||||
|
||||
if (isLoading) return <Spin size="small" />
|
||||
if (!data?.device_info) return null
|
||||
|
||||
const di = data.device_info
|
||||
const st = data.system_time ?? {}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
Последний статус
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, fontWeight: 'normal' }}>
|
||||
({dayjs(taskDate).format('DD.MM.YY HH:mm')})
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Descriptions size="small" column={2}>
|
||||
{di.model && <Descriptions.Item label="Модель">{String(di.model)}</Descriptions.Item>}
|
||||
{di.firmwareVersion && <Descriptions.Item label="Прошивка">{String(di.firmwareVersion)}</Descriptions.Item>}
|
||||
{di.serialNumber && <Descriptions.Item label="Серийный №">{String(di.serialNumber)}</Descriptions.Item>}
|
||||
{di.macAddress && <Descriptions.Item label="MAC">{String(di.macAddress)}</Descriptions.Item>}
|
||||
{st.localTime && <Descriptions.Item label="Время камеры">{String(st.localTime)}</Descriptions.Item>}
|
||||
{st.timeZone && <Descriptions.Item label="Часовой пояс">{String(st.timeZone)}</Descriptions.Item>}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
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<string | null>(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(
|
||||
<span>Задача запущена. <a href={`/tasks/${task.id}`} target="_blank" rel="noopener noreferrer">Открыть</a></span>,
|
||||
5,
|
||||
)
|
||||
} catch {
|
||||
message.error('Не удалось запустить задачу')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={14}>
|
||||
{/* Connection parameters */}
|
||||
<Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}>
|
||||
<Descriptions size="small" column={1}>
|
||||
<Descriptions.Item label="HTTP порт">
|
||||
<Typography.Text code>{String(httpPort)}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
{authType && (
|
||||
<Descriptions.Item label="Авторизация">
|
||||
<Tag color={authType === 'digest' ? 'orange' : 'blue'}>{authType}</Tag>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{webUser && (
|
||||
<Descriptions.Item label="Логин (веб)">
|
||||
<Typography.Text code copyable>{webUser}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{creds?.connection_password && (
|
||||
<Descriptions.Item label="Пароль (веб)">
|
||||
<Typography.Text code copyable>{creds.connection_password}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="RTSP поток">
|
||||
<Space>
|
||||
<Typography.Text code copyable style={{ fontSize: 11 }}>{rtspUrl}</Typography.Text>
|
||||
<Tooltip title="Открыть в VLC (если установлен)">
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => { window.open(`vlc://${rtspUrl}`) }}
|
||||
>
|
||||
VLC
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
{params.frame_url && (
|
||||
<Descriptions.Item label="Frame URL">
|
||||
<Typography.Text code copyable style={{ fontSize: 11 }}>{String(params.frame_url)}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* Last camera status */}
|
||||
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
||||
|
||||
<Button
|
||||
icon={<InfoCircleOutlined />}
|
||||
loading={createTask.isPending}
|
||||
onClick={handleGetStatus}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
Получить статус камеры
|
||||
</Button>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={10}>
|
||||
{/* Live snapshot */}
|
||||
<Card
|
||||
size="small"
|
||||
title="Снимок с камеры"
|
||||
extra={
|
||||
<Space size={8}>
|
||||
<Select
|
||||
size="small"
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
options={[
|
||||
{ value: 1, label: 'Канал 1' },
|
||||
{ value: 2, label: 'Канал 2' },
|
||||
{ value: 3, label: 'Канал 3' },
|
||||
]}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{snapshotError ? (
|
||||
<div style={{ padding: '16px 0', textAlign: 'center' }}>
|
||||
<Typography.Text type="danger">{snapshotError}</Typography.Text>
|
||||
<br />
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
key={snapshotKey}
|
||||
src={snapshotSrc}
|
||||
alt="Camera snapshot"
|
||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||
onError={() => setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')}
|
||||
onLoad={() => setSnapshotError(null)}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
|
||||
Снимок загружается через сервер — не требует прямого доступа к камере из браузера
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Snapshots tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
|
||||
|
|
@ -1054,8 +1265,7 @@ export function DeviceDetailPage() {
|
|||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* SSH block — always shown */}
|
||||
<SshBlock device={device} obj={obj} creds={creds} credsLoading={credsLoading} />
|
||||
{/* SSH block — временно скрыт */}
|
||||
|
||||
{/* System info (Linux-like devices) */}
|
||||
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
|
||||
|
|
@ -1094,6 +1304,11 @@ export function DeviceDetailPage() {
|
|||
</Row>
|
||||
),
|
||||
},
|
||||
...(device.category === 'camera' ? [{
|
||||
key: 'camera',
|
||||
label: 'Камера',
|
||||
children: <CameraTab device={device} objId={objId} tasks={tasks} creds={creds} />,
|
||||
}] : []),
|
||||
{
|
||||
key: 'alerts',
|
||||
label: 'Алерты',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue