фикс камер 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 = [] params_schema = []
async def execute( async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter self, device: Device, obj: Object, params: dict, emit: Emitter, db=None
) -> ActionResult: ) -> ActionResult:
await emit(f"[{device.ip}] GET /ISAPI/System/deviceInfo", "info") await emit(f"[{device.ip}] GET /ISAPI/System/deviceInfo", "info")
info_result = await camera_service.isapi_get(device, obj, "System/deviceInfo") info_result = await camera_service.isapi_get(device, obj, "System/deviceInfo")

View file

@ -27,7 +27,7 @@ class CameraSetNTPAction(BaseAction):
] ]
async def execute( async def execute(
self, device: Device, obj: Object, params: dict, emit: Emitter self, device: Device, obj: Object, params: dict, emit: Emitter, db=None
) -> ActionResult: ) -> ActionResult:
server = params.get("server", "").strip() server = params.get("server", "").strip()
if not server: if not server:

View file

@ -4,7 +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 fastapi.responses import Response as FastAPIResponse, StreamingResponse
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
@ -267,22 +267,44 @@ async def get_device_credentials(
async def camera_snapshot( async def camera_snapshot(
obj_id: int, obj_id: int,
device_id: int, device_id: int,
channel: int = 1,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user), _: 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) device = await _get_device(db, obj_id, device_id)
if device.category != "camera": if device.category != "camera":
raise HTTPException(status_code=400, detail="Device is not a camera") raise HTTPException(status_code=400, detail="Device is not a camera")
obj = await _get_object(db, obj_id) obj = await _get_object(db, obj_id)
try: 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: except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) raise HTTPException(status_code=502, detail=str(exc))
return FastAPIResponse(content=image_bytes, media_type=content_type) 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) @router.patch("/{device_id}", response_model=DeviceRead)
async def update_device( async def update_device(
obj_id: int, obj_id: int,

View file

@ -10,7 +10,7 @@ ISAPI base: http://<ip>/ISAPI/
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import AsyncIterator, Optional
import httpx import httpx
@ -33,18 +33,31 @@ class CameraResult:
def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str, int]: 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" Returns (username, password, http_port).
port = device.device_meta.get("http_port", 80) if device.device_meta else 80
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) password = decrypt(device.ssh_pass_override_enc)
elif obj.ssh_pass_enc: elif obj.ssh_pass_enc:
password = decrypt(obj.ssh_pass_enc) password = decrypt(obj.ssh_pass_enc)
else: else:
password = "" password = ""
return user, password, int(port) return user, password, port
def _xml_to_dict(element: ET.Element) -> dict: def _xml_to_dict(element: ET.Element) -> dict:
@ -133,6 +146,38 @@ async def isapi_snapshot(
return resp.content, content_type 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( async def isapi_put(
device: Device, device: Device,
obj: Object, obj: Object,

View file

@ -623,6 +623,8 @@ function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string;
) )
} }
type ViewMode = 'snapshot' | 'stream'
function CameraTab({ function CameraTab({
device, device,
objId, objId,
@ -636,20 +638,24 @@ function CameraTab({
}) { }) {
const [snapshotKey, setSnapshotKey] = useState(0) const [snapshotKey, setSnapshotKey] = useState(0)
const [snapshotError, setSnapshotError] = useState<string | null>(null) 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 createTask = useCreateTask()
const params = device.connection_params ?? {} const params = device.connection_params ?? {}
const meta = device.device_meta ?? {} const meta = device.device_meta ?? {}
const httpPort = meta.http_port ?? 80 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 authType: string | null = (params.auth_type as string) ?? null
const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null const webUser: string | null = (params.username as string) ?? device.ssh_user_override ?? null
// Build default RTSP URL if not in connection_params // RTSP URL for display/copy (with creds if available)
const rtspUrl = streamUrl ?? `rtsp://${device.ip}:554/Streaming/Channels/${channel}01` 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 () => { const handleGetStatus = async () => {
try { try {
@ -669,8 +675,8 @@ function CameraTab({
return ( return (
<Row gutter={24}> <Row gutter={24}>
<Col xs={24} lg={14}> {/* Left: params + status */}
{/* Connection parameters */} <Col xs={24} lg={12}>
<Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}> <Card size="small" title="Параметры подключения" style={{ marginBottom: 16 }}>
<Descriptions size="small" column={1}> <Descriptions size="small" column={1}>
<Descriptions.Item label="HTTP порт"> <Descriptions.Item label="HTTP порт">
@ -682,28 +688,19 @@ function CameraTab({
</Descriptions.Item> </Descriptions.Item>
)} )}
{webUser && ( {webUser && (
<Descriptions.Item label="Логин (веб)"> <Descriptions.Item label="Логин">
<Typography.Text code copyable>{webUser}</Typography.Text> <Typography.Text code copyable>{webUser}</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
)} )}
{creds?.connection_password && ( {creds?.connection_password && (
<Descriptions.Item label="Пароль (веб)"> <Descriptions.Item label="Пароль">
<Typography.Text code copyable>{creds.connection_password}</Typography.Text> <Typography.Text code copyable>{creds.connection_password}</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
)} )}
<Descriptions.Item label="RTSP поток"> <Descriptions.Item label="RTSP">
<Space> <Typography.Text code copyable={{ text: rtspWithCreds }} style={{ fontSize: 11 }}>
<Typography.Text code copyable style={{ fontSize: 11 }}>{rtspUrl}</Typography.Text> {rtspBase}
<Tooltip title="Открыть в VLC (если установлен)"> </Typography.Text>
<Button
size="small"
type="link"
onClick={() => { window.open(`vlc://${rtspUrl}`) }}
>
VLC
</Button>
</Tooltip>
</Space>
</Descriptions.Item> </Descriptions.Item>
{params.frame_url && ( {params.frame_url && (
<Descriptions.Item label="Frame URL"> <Descriptions.Item label="Frame URL">
@ -713,56 +710,50 @@ function CameraTab({
</Descriptions> </Descriptions>
</Card> </Card>
{/* Last camera status */}
<CameraStatusInfo deviceId={device.id} tasks={tasks} /> <CameraStatusInfo deviceId={device.id} tasks={tasks} />
<Button <Button
icon={<InfoCircleOutlined />} icon={<InfoCircleOutlined />}
loading={createTask.isPending} loading={createTask.isPending}
onClick={handleGetStatus} onClick={handleGetStatus}
style={{ marginBottom: 16 }}
> >
Получить статус камеры Обновить статус камеры
</Button> </Button>
</Col> </Col>
<Col xs={24} lg={10}> {/* Right: live view */}
{/* Live snapshot */} <Col xs={24} lg={12}>
<Card <Card
size="small" size="small"
title="Снимок с камеры" title="Просмотр"
extra={ extra={
<Space size={8}> <Space size={8}>
<Select <Select
size="small" size="small"
value={channel} value={viewMode}
onChange={setChannel} onChange={(v) => { setViewMode(v); setSnapshotError(null); setStreamError(null) }}
options={[ options={[
{ value: 1, label: 'Канал 1' }, { value: 'snapshot', label: 'Снимок' },
{ value: 2, label: 'Канал 2' }, { value: 'stream', label: 'MJPEG поток' },
{ value: 3, label: 'Канал 3' },
]} ]}
style={{ width: 90 }} style={{ width: 120 }}
/> />
{viewMode === 'snapshot' && (
<Button <Button
size="small" size="small"
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }} onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
> />
Обновить )}
</Button>
</Space> </Space>
} }
> >
{snapshotError ? ( {viewMode === 'snapshot' && (
<div style={{ padding: '16px 0', textAlign: 'center' }}> snapshotError ? (
<Typography.Text type="danger">{snapshotError}</Typography.Text> <div style={{ padding: '24px 0', textAlign: 'center' }}>
<Typography.Text type="danger" style={{ fontSize: 12 }}>{snapshotError}</Typography.Text>
<br /> <br />
<Button <Button size="small" style={{ marginTop: 8 }} onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}>
size="small"
style={{ marginTop: 8 }}
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
>
Повторить Повторить
</Button> </Button>
</div> </div>
@ -772,12 +763,38 @@ function CameraTab({
src={snapshotSrc} src={snapshotSrc}
alt="Camera snapshot" alt="Camera snapshot"
style={{ width: '100%', borderRadius: 4, display: 'block' }} style={{ width: '100%', borderRadius: 4, display: 'block' }}
onError={() => setSnapshotError('Не удалось загрузить снимок. Проверьте подключение и кредиалы камеры.')} onError={() => setSnapshotError('Не удалось загрузить снимок — проверьте подключение и кредиалы камеры.')}
onLoad={() => setSnapshotError(null)} 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> </Typography.Text>
</Card> </Card>
</Col> </Col>