фикс камер 2

This commit is contained in:
dv 2026-04-14 15:22:38 +03:00
parent e4d8249e83
commit 0bbf06aeed
5 changed files with 160 additions and 76 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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,

View file

@ -10,7 +10,7 @@ ISAPI base: http://<ip>/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,

View file

@ -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<string | null>(null)
const [channel, setChannel] = useState(1)
const [viewMode, setViewMode] = useState<ViewMode>('snapshot')
const [streamError, setStreamError] = useState<string | null>(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 (
<Row gutter={24}>
<Col xs={24} lg={14}>
{/* Connection parameters */}
{/* Left: params + status */}
<Col xs={24} lg={12}>
<Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}>
<Descriptions size="small" column={1}>
<Descriptions.Item label="HTTP порт">
@ -682,28 +688,19 @@ function CameraTab({
</Descriptions.Item>
)}
{webUser && (
<Descriptions.Item label="Логин (веб)">
<Descriptions.Item label="Логин">
<Typography.Text code copyable>{webUser}</Typography.Text>
</Descriptions.Item>
)}
{creds?.connection_password && (
<Descriptions.Item label="Пароль (веб)">
<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 label="RTSP">
<Typography.Text code copyable={{ text: rtspWithCreds }} style={{ fontSize: 11 }}>
{rtspBase}
</Typography.Text>
</Descriptions.Item>
{params.frame_url && (
<Descriptions.Item label="Frame URL">
@ -713,56 +710,50 @@ function CameraTab({
</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 */}
{/* Right: live view */}
<Col xs={24} lg={12}>
<Card
size="small"
title="Снимок с камеры"
title="Просмотр"
extra={
<Space size={8}>
<Select
size="small"
value={channel}
onChange={setChannel}
value={viewMode}
onChange={(v) => { setViewMode(v); setSnapshotError(null); setStreamError(null) }}
options={[
{ value: 1, label: 'Канал 1' },
{ value: 2, label: 'Канал 2' },
{ value: 3, label: 'Канал 3' },
{ value: 'snapshot', label: 'Снимок' },
{ value: 'stream', label: 'MJPEG поток' },
]}
style={{ width: 90 }}
style={{ width: 120 }}
/>
{viewMode === 'snapshot' && (
<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>
{viewMode === 'snapshot' && (
snapshotError ? (
<div style={{ padding: '24px 0', textAlign: 'center' }}>
<Typography.Text type="danger" style={{ fontSize: 12 }}>{snapshotError}</Typography.Text>
<br />
<Button
size="small"
style={{ marginTop: 8 }}
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
>
<Button size="small" style={{ marginTop: 8 }} onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}>
Повторить
</Button>
</div>
@ -772,12 +763,38 @@ function CameraTab({
src={snapshotSrc}
alt="Camera snapshot"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={() => setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')}
onError={() => setSnapshotError('Не удалось загрузить снимок — проверьте подключение и кредиалы камеры.')}
onLoad={() => setSnapshotError(null)}
/>
)
)}
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 8 }}>
Снимок загружается через сервер не требует прямого доступа к камере из браузера
{viewMode === 'stream' && (
streamError ? (
<div style={{ padding: '24px 0', textAlign: 'center' }}>
<Typography.Text type="danger" style={{ fontSize: 12 }}>{streamError}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
Камера должна поддерживать MJPEG (ISAPI httppreview)
</Typography.Text>
<Button size="small" style={{ marginTop: 8 }} onClick={() => setStreamError(null)}>
Повторить
</Button>
</div>
) : (
<img
src={streamSrc}
alt="Camera MJPEG stream"
style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={() => setStreamError('Не удалось открыть поток. Камера может не поддерживать MJPEG через ISAPI.')}
/>
)
)}
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
{viewMode === 'stream'
? 'MJPEG поток через сервер (требует поддержки ISAPI httppreview)'
: 'Снимок через сервер — прямой доступ к камере из браузера не нужен'}
</Typography.Text>
</Card>
</Col>