фикс камеры 3
This commit is contained in:
parent
0bbf06aeed
commit
f788067d5a
2 changed files with 111 additions and 69 deletions
|
|
@ -8,6 +8,7 @@ username/password fields) — camera HTTP auth often matches SSH.
|
||||||
ISAPI base: http://<ip>/ISAPI/
|
ISAPI base: http://<ip>/ISAPI/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator, Optional
|
from typing import AsyncIterator, Optional
|
||||||
|
|
@ -18,6 +19,8 @@ from app.models.device import Device
|
||||||
from app.models.object import Object
|
from app.models.object import Object
|
||||||
from app.services.crypto import decrypt
|
from app.services.crypto import decrypt
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CameraResult:
|
class CameraResult:
|
||||||
|
|
@ -50,13 +53,21 @@ def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str,
|
||||||
|
|
||||||
if cp.get("password"):
|
if cp.get("password"):
|
||||||
password = str(cp["password"]) # plaintext from connection_params
|
password = str(cp["password"]) # plaintext from connection_params
|
||||||
|
pwd_source = "connection_params.password"
|
||||||
elif device.ssh_pass_override_enc:
|
elif device.ssh_pass_override_enc:
|
||||||
password = decrypt(device.ssh_pass_override_enc)
|
password = decrypt(device.ssh_pass_override_enc)
|
||||||
|
pwd_source = "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)
|
||||||
|
pwd_source = "obj.ssh_pass_enc"
|
||||||
else:
|
else:
|
||||||
password = ""
|
password = ""
|
||||||
|
pwd_source = "empty"
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"[camera] %s | user=%r port=%d pwd_source=%s pwd_len=%d cp_keys=%s",
|
||||||
|
device.ip, user, port, pwd_source, len(password), list(cp.keys()),
|
||||||
|
)
|
||||||
return user, password, port
|
return user, password, port
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -129,6 +140,7 @@ async def isapi_snapshot(
|
||||||
ch = f"{channel:02d}1"
|
ch = f"{channel:02d}1"
|
||||||
url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture"
|
url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture"
|
||||||
|
|
||||||
|
log.info("[camera] snapshot GET %s (user=%r)", url, user)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
|
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
|
||||||
resp = await client.get(url)
|
resp = await client.get(url)
|
||||||
|
|
@ -137,10 +149,16 @@ async def isapi_snapshot(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RuntimeError(str(exc))
|
raise RuntimeError(str(exc))
|
||||||
|
|
||||||
|
log.info("[camera] snapshot response HTTP %d, content-type=%s, len=%d",
|
||||||
|
resp.status_code, resp.headers.get("content-type"), len(resp.content))
|
||||||
|
|
||||||
if resp.status_code == 401:
|
if resp.status_code == 401:
|
||||||
raise RuntimeError("Ошибка авторизации (401) — проверьте логин/пароль камеры")
|
raise RuntimeError(
|
||||||
|
f"Ошибка авторизации (401) — user={user!r}, url={url}. "
|
||||||
|
"Проверьте логин/пароль в настройках камеры."
|
||||||
|
)
|
||||||
if resp.status_code >= 400:
|
if resp.status_code >= 400:
|
||||||
raise RuntimeError(f"Камера вернула HTTP {resp.status_code}")
|
raise RuntimeError(f"Камера вернула HTTP {resp.status_code}, url={url}")
|
||||||
|
|
||||||
content_type = resp.headers.get("content-type", "image/jpeg")
|
content_type = resp.headers.get("content-type", "image/jpeg")
|
||||||
return resp.content, content_type
|
return resp.content, content_type
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,11 @@ import {
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts'
|
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts'
|
||||||
import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem, type ObjectItem, type DeviceCredentials } from '../api/objects'
|
import { useDevice, useDeleteDevice, useObject, useDeviceCredentials, type DeviceItem, type ObjectItem, type DeviceCredentials } from '../api/objects'
|
||||||
|
import { api } from '../api/client'
|
||||||
import { getDeviceWebUrl } from '../utils/deviceUrl'
|
import { getDeviceWebUrl } from '../utils/deviceUrl'
|
||||||
import { elapsedAgo } from '../utils/time'
|
import { elapsedAgo } from '../utils/time'
|
||||||
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
|
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
|
||||||
|
|
@ -625,6 +626,42 @@ function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string;
|
||||||
|
|
||||||
type ViewMode = 'snapshot' | 'stream'
|
type ViewMode = 'snapshot' | 'stream'
|
||||||
|
|
||||||
|
// Loads image via axios (with JWT token), converts to blob URL
|
||||||
|
function useCameraBlob(url: string, enabled: boolean) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
console.debug('[Camera] fetching', url)
|
||||||
|
try {
|
||||||
|
const resp = await api.get(url, { responseType: 'blob' })
|
||||||
|
const blob = resp.data as Blob
|
||||||
|
console.debug('[Camera] got blob', blob.type, blob.size, 'bytes')
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
setBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return objectUrl })
|
||||||
|
} catch (err: any) {
|
||||||
|
const status = err?.response?.status
|
||||||
|
const detail = err?.response?.data?.detail ?? err?.message ?? 'Неизвестная ошибка'
|
||||||
|
const msg = status ? `HTTP ${status}: ${detail}` : String(detail)
|
||||||
|
console.error('[Camera] error', status, detail, err)
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobUrlRef = useRef(blobUrl)
|
||||||
|
blobUrlRef.current = blobUrl
|
||||||
|
useEffect(() => {
|
||||||
|
return () => { if (blobUrlRef.current) URL.revokeObjectURL(blobUrlRef.current) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { blobUrl, loading, error, reload: load }
|
||||||
|
}
|
||||||
|
|
||||||
function CameraTab({
|
function CameraTab({
|
||||||
device,
|
device,
|
||||||
objId,
|
objId,
|
||||||
|
|
@ -636,27 +673,25 @@ function CameraTab({
|
||||||
tasks: Task[] | undefined
|
tasks: Task[] | undefined
|
||||||
creds: DeviceCredentials | undefined
|
creds: DeviceCredentials | undefined
|
||||||
}) {
|
}) {
|
||||||
const [snapshotKey, setSnapshotKey] = useState(0)
|
|
||||||
const [snapshotError, setSnapshotError] = useState<string | null>(null)
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('snapshot')
|
const [viewMode, setViewMode] = useState<ViewMode>('snapshot')
|
||||||
const [streamError, setStreamError] = useState<string | null>(null)
|
|
||||||
const createTask = useCreateTask()
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
|
const snapshotUrl = `/api/v1/objects/${objId}/devices/${device.id}/camera/snapshot`
|
||||||
|
const streamUrl = `/api/v1/objects/${objId}/devices/${device.id}/camera/stream`
|
||||||
|
|
||||||
|
const snapshot = useCameraBlob(snapshotUrl, viewMode === 'snapshot')
|
||||||
|
const stream = useCameraBlob(streamUrl, viewMode === 'stream')
|
||||||
|
|
||||||
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 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
|
||||||
|
|
||||||
// RTSP URL for display/copy (with creds if available)
|
|
||||||
const rtspBase = (params.stream_url as string) ?? `rtsp://${device.ip}:554/Streaming/Channels/101`
|
const rtspBase = (params.stream_url as string) ?? `rtsp://${device.ip}:554/Streaming/Channels/101`
|
||||||
const rtspWithCreds = creds?.connection_password && webUser
|
const rtspWithCreds = creds?.connection_password && webUser
|
||||||
? rtspBase.replace('rtsp://', `rtsp://${webUser}:${creds.connection_password}@`)
|
? rtspBase.replace('rtsp://', `rtsp://${encodeURIComponent(webUser)}:${encodeURIComponent(creds.connection_password)}@`)
|
||||||
: rtspBase
|
: rtspBase
|
||||||
|
|
||||||
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 {
|
||||||
const task = await createTask.mutateAsync({
|
const task = await createTask.mutateAsync({
|
||||||
|
|
@ -673,6 +708,12 @@ function CameraTab({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSwitchMode = (v: ViewMode) => {
|
||||||
|
setViewMode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeBlob = viewMode === 'snapshot' ? snapshot : stream
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={24}>
|
<Row gutter={24}>
|
||||||
{/* Left: params + status */}
|
{/* Left: params + status */}
|
||||||
|
|
@ -712,11 +753,7 @@ function CameraTab({
|
||||||
|
|
||||||
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
||||||
|
|
||||||
<Button
|
<Button icon={<InfoCircleOutlined />} loading={createTask.isPending} onClick={handleGetStatus}>
|
||||||
icon={<InfoCircleOutlined />}
|
|
||||||
loading={createTask.isPending}
|
|
||||||
onClick={handleGetStatus}
|
|
||||||
>
|
|
||||||
Обновить статус камеры
|
Обновить статус камеры
|
||||||
</Button>
|
</Button>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
@ -731,70 +768,57 @@ function CameraTab({
|
||||||
<Select
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
value={viewMode}
|
value={viewMode}
|
||||||
onChange={(v) => { setViewMode(v); setSnapshotError(null); setStreamError(null) }}
|
onChange={handleSwitchMode}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'snapshot', label: 'Снимок' },
|
{ value: 'snapshot', label: 'Снимок' },
|
||||||
{ value: 'stream', label: 'MJPEG поток' },
|
{ value: 'stream', label: 'MJPEG поток' },
|
||||||
]}
|
]}
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
/>
|
/>
|
||||||
{viewMode === 'snapshot' && (
|
<Button
|
||||||
<Button
|
size="small"
|
||||||
size="small"
|
icon={<ReloadOutlined />}
|
||||||
icon={<ReloadOutlined />}
|
loading={activeBlob.loading}
|
||||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
onClick={activeBlob.reload}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{viewMode === 'snapshot' && (
|
{activeBlob.error ? (
|
||||||
snapshotError ? (
|
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
<Typography.Text type="danger" style={{ fontSize: 12 }}>{activeBlob.error}</Typography.Text>
|
||||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>{snapshotError}</Typography.Text>
|
<br />
|
||||||
<br />
|
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
|
||||||
<Button size="small" style={{ marginTop: 8 }} onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}>
|
Подробности в консоли браузера (F12)
|
||||||
Повторить
|
</Typography.Text>
|
||||||
</Button>
|
<Button size="small" style={{ marginTop: 8 }} loading={activeBlob.loading} onClick={activeBlob.reload}>
|
||||||
</div>
|
Повторить
|
||||||
) : (
|
</Button>
|
||||||
<img
|
</div>
|
||||||
key={snapshotKey}
|
) : activeBlob.loading ? (
|
||||||
src={snapshotSrc}
|
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||||
alt="Camera snapshot"
|
<Spin />
|
||||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||||
onError={() => setSnapshotError('Не удалось загрузить снимок — проверьте подключение и кредиалы камеры.')}
|
Загрузка...
|
||||||
onLoad={() => setSnapshotError(null)}
|
</Typography.Text>
|
||||||
/>
|
</div>
|
||||||
)
|
) : activeBlob.blobUrl ? (
|
||||||
|
<img
|
||||||
|
src={activeBlob.blobUrl}
|
||||||
|
alt={viewMode === 'stream' ? 'MJPEG stream' : 'Camera snapshot'}
|
||||||
|
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||||
|
<Button type="primary" icon={<ReloadOutlined />} onClick={activeBlob.reload}>
|
||||||
|
{viewMode === 'stream' ? 'Открыть поток' : 'Загрузить снимок'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{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 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
|
||||||
{viewMode === 'stream'
|
{viewMode === 'stream'
|
||||||
? 'MJPEG поток через сервер (требует поддержки ISAPI httppreview)'
|
? 'MJPEG через сервер (ISAPI httppreview). Нажмите обновить чтобы загрузить.'
|
||||||
: 'Снимок через сервер — прямой доступ к камере из браузера не нужен'}
|
: 'Снимок через сервер с JWT токеном'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue