фикс камер

This commit is contained in:
dv 2026-04-14 14:12:01 +03:00
parent ee38abbecb
commit e4d8249e83
4 changed files with 377 additions and 8 deletions

View file

@ -4,6 +4,7 @@ from datetime import datetime, timezone
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status 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 import select, update as sa_update
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -20,6 +21,7 @@ from app.schemas.device import (
DeviceRead, DeviceRead,
DeviceUpdate, DeviceUpdate,
) )
from app.services import camera as camera_service
from app.services import crypto from app.services import crypto
router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"]) 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) @router.patch("/{device_id}", response_model=DeviceRead)
async def update_device( async def update_device(
obj_id: int, obj_id: int,

View file

@ -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( async def isapi_put(
device: Device, device: Device,
obj: Object, obj: Object,

View file

@ -10,7 +10,7 @@ import {
Select, Select,
message, message,
} from 'antd' } from 'antd'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { import {
useCreateDevice, useCreateDevice,
useUpdateDevice, useUpdateDevice,
@ -54,6 +54,7 @@ interface Props {
export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) { export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props) {
const [form] = Form.useForm() const [form] = Form.useForm()
const [category, setCategory] = useState(device?.category ?? 'other')
const { data: racks } = useRacks(objId) const { data: racks } = useRacks(objId)
const { data: sshKeys } = useSSHKeys() const { data: sshKeys } = useSSHKeys()
const createDevice = useCreateDevice(objId) const createDevice = useCreateDevice(objId)
@ -64,6 +65,7 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
if (device) { if (device) {
const cp = device.connection_params ?? {}
form.setFieldsValue({ form.setFieldsValue({
ip: device.ip, ip: device.ip,
hostname: device.hostname ?? '', hostname: device.hostname ?? '',
@ -78,23 +80,65 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
ssh_user_override: device.ssh_user_override ?? '', ssh_user_override: device.ssh_user_override ?? '',
ssh_port_override: device.ssh_port_override ?? undefined, ssh_port_override: device.ssh_port_override ?? undefined,
ssh_key_id_override: device.ssh_key_id_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 { } else {
form.resetFields() form.resetFields()
setCategory('other')
} }
}, [open, device]) }, [open, device])
const handleOk = async () => { const handleOk = async () => {
const values = await form.validateFields() 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) const payload = stripEmpty(rest)
if ('rack_id' in values && values.rack_id === undefined) { if ('rack_id' in values && values.rack_id === undefined) {
payload.rack_id = null payload.rack_id = null
} }
// Merge db_via_tunnel into connection_params
const existingParams = device?.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 } payload.connection_params = { ...existingParams, db_via_tunnel: !!db_via_tunnel }
}
try { try {
let saved: DeviceItem let saved: DeviceItem
if (device) { if (device) {
@ -127,7 +171,14 @@ export function DeviceEditModal({ open, device, objId, onClose, onSaved }: Props
okText={device ? 'Сохранить' : 'Добавить'} okText={device ? 'Сохранить' : 'Добавить'}
width={640} 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}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name="ip" label="IP-адрес" rules={[{ required: true, message: 'Обязательное поле' }]}> <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> </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> </Form>
</Modal> </Modal>
) )

View file

@ -9,6 +9,7 @@ import {
FileSearchOutlined, FileSearchOutlined,
InfoCircleOutlined, InfoCircleOutlined,
CameraOutlined, CameraOutlined,
ReloadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { import {
Badge, 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 ──────────────────────────────────────────────────────────── // ─── Snapshots tab ────────────────────────────────────────────────────────────
function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) { function SnapshotsTab({ objId, deviceId, device }: { objId: number; deviceId: number; device: DeviceItem }) {
@ -1054,8 +1265,7 @@ export function DeviceDetailPage() {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{/* SSH block — always shown */} {/* SSH block — временно скрыт */}
<SshBlock device={device} obj={obj} creds={creds} credsLoading={credsLoading} />
{/* System info (Linux-like devices) */} {/* System info (Linux-like devices) */}
{isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />} {isLinuxLike && <SystemInfoBlock device={device} tasks={tasks} />}
@ -1094,6 +1304,11 @@ export function DeviceDetailPage() {
</Row> </Row>
), ),
}, },
...(device.category === 'camera' ? [{
key: 'camera',
label: 'Камера',
children: <CameraTab device={device} objId={objId} tasks={tasks} creds={creds} />,
}] : []),
{ {
key: 'alerts', key: 'alerts',
label: 'Алерты', label: 'Алерты',