фикс камер 2
This commit is contained in:
parent
e4d8249e83
commit
0bbf06aeed
5 changed files with 160 additions and 76 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,71 +710,91 @@ 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 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
{viewMode === 'snapshot' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||
/>
|
||||
)}
|
||||
</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)}
|
||||
/>
|
||||
{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>
|
||||
</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 }}>
|
||||
Снимок загружается через сервер — не требует прямого доступа к камере из браузера
|
||||
|
||||
{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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue