фикс камер 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 = []
|
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")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,71 +710,91 @@ 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 }}
|
||||||
/>
|
/>
|
||||||
<Button
|
{viewMode === 'snapshot' && (
|
||||||
size="small"
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
size="small"
|
||||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
icon={<ReloadOutlined />}
|
||||||
>
|
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' }}>
|
||||||
<br />
|
<Typography.Text type="danger" style={{ fontSize: 12 }}>{snapshotError}</Typography.Text>
|
||||||
<Button
|
<br />
|
||||||
size="small"
|
<Button size="small" style={{ marginTop: 8 }} onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}>
|
||||||
style={{ marginTop: 8 }}
|
Повторить
|
||||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
</Button>
|
||||||
>
|
</div>
|
||||||
Повторить
|
) : (
|
||||||
</Button>
|
<img
|
||||||
</div>
|
key={snapshotKey}
|
||||||
) : (
|
src={snapshotSrc}
|
||||||
<img
|
alt="Camera snapshot"
|
||||||
key={snapshotKey}
|
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||||
src={snapshotSrc}
|
onError={() => setSnapshotError('Не удалось загрузить снимок — проверьте подключение и кредиалы камеры.')}
|
||||||
alt="Camera snapshot"
|
onLoad={() => setSnapshotError(null)}
|
||||||
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>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue