фикс камеры 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/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncIterator, Optional
|
||||
|
|
@ -18,6 +19,8 @@ from app.models.device import Device
|
|||
from app.models.object import Object
|
||||
from app.services.crypto import decrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraResult:
|
||||
|
|
@ -50,13 +53,21 @@ def _resolve_camera_credentials(device: Device, obj: Object) -> tuple[str, str,
|
|||
|
||||
if cp.get("password"):
|
||||
password = str(cp["password"]) # plaintext from connection_params
|
||||
pwd_source = "connection_params.password"
|
||||
elif device.ssh_pass_override_enc:
|
||||
password = decrypt(device.ssh_pass_override_enc)
|
||||
pwd_source = "ssh_pass_override_enc"
|
||||
elif obj.ssh_pass_enc:
|
||||
password = decrypt(obj.ssh_pass_enc)
|
||||
pwd_source = "obj.ssh_pass_enc"
|
||||
else:
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -129,6 +140,7 @@ async def isapi_snapshot(
|
|||
ch = f"{channel:02d}1"
|
||||
url = f"http://{device.ip}:{port}/ISAPI/Streaming/channels/{ch}/picture"
|
||||
|
||||
log.info("[camera] snapshot GET %s (user=%r)", url, user)
|
||||
try:
|
||||
async with httpx.AsyncClient(auth=(user, password), timeout=timeout) as client:
|
||||
resp = await client.get(url)
|
||||
|
|
@ -137,10 +149,16 @@ async def isapi_snapshot(
|
|||
except Exception as 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:
|
||||
raise RuntimeError("Ошибка авторизации (401) — проверьте логин/пароль камеры")
|
||||
raise RuntimeError(
|
||||
f"Ошибка авторизации (401) — user={user!r}, url={url}. "
|
||||
"Проверьте логин/пароль в настройках камеры."
|
||||
)
|
||||
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")
|
||||
return resp.content, content_type
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@ import {
|
|||
} from 'antd'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useAlerts, useAcknowledgeAlert, useResolveAlert, type AlertItem } from '../api/alerts'
|
||||
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 { elapsedAgo } from '../utils/time'
|
||||
import { useSnapshots, useSnapshotDiff } from '../api/snapshots'
|
||||
|
|
@ -625,6 +626,42 @@ function CameraStatusFromTask({ taskId, deviceId, taskDate }: { taskId: string;
|
|||
|
||||
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({
|
||||
device,
|
||||
objId,
|
||||
|
|
@ -636,27 +673,25 @@ function CameraTab({
|
|||
tasks: Task[] | undefined
|
||||
creds: DeviceCredentials | undefined
|
||||
}) {
|
||||
const [snapshotKey, setSnapshotKey] = useState(0)
|
||||
const [snapshotError, setSnapshotError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('snapshot')
|
||||
const [streamError, setStreamError] = useState<string | null>(null)
|
||||
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 meta = device.device_meta ?? {}
|
||||
const httpPort = meta.http_port ?? 80
|
||||
const authType: string | null = (params.auth_type as string) ?? 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 rtspWithCreds = creds?.connection_password && webUser
|
||||
? rtspBase.replace('rtsp://', `rtsp://${webUser}:${creds.connection_password}@`)
|
||||
? rtspBase.replace('rtsp://', `rtsp://${encodeURIComponent(webUser)}:${encodeURIComponent(creds.connection_password)}@`)
|
||||
: 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 () => {
|
||||
try {
|
||||
const task = await createTask.mutateAsync({
|
||||
|
|
@ -673,6 +708,12 @@ function CameraTab({
|
|||
}
|
||||
}
|
||||
|
||||
const handleSwitchMode = (v: ViewMode) => {
|
||||
setViewMode(v)
|
||||
}
|
||||
|
||||
const activeBlob = viewMode === 'snapshot' ? snapshot : stream
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
{/* Left: params + status */}
|
||||
|
|
@ -712,11 +753,7 @@ function CameraTab({
|
|||
|
||||
<CameraStatusInfo deviceId={device.id} tasks={tasks} />
|
||||
|
||||
<Button
|
||||
icon={<InfoCircleOutlined />}
|
||||
loading={createTask.isPending}
|
||||
onClick={handleGetStatus}
|
||||
>
|
||||
<Button icon={<InfoCircleOutlined />} loading={createTask.isPending} onClick={handleGetStatus}>
|
||||
Обновить статус камеры
|
||||
</Button>
|
||||
</Col>
|
||||
|
|
@ -731,70 +768,57 @@ function CameraTab({
|
|||
<Select
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={(v) => { setViewMode(v); setSnapshotError(null); setStreamError(null) }}
|
||||
onChange={handleSwitchMode}
|
||||
options={[
|
||||
{ value: 'snapshot', label: 'Снимок' },
|
||||
{ value: 'stream', label: 'MJPEG поток' },
|
||||
]}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
{viewMode === 'snapshot' && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { setSnapshotError(null); setSnapshotKey((k) => k + 1) }}
|
||||
loading={activeBlob.loading}
|
||||
onClick={activeBlob.reload}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{viewMode === 'snapshot' && (
|
||||
snapshotError ? (
|
||||
{activeBlob.error ? (
|
||||
<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)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{viewMode === 'stream' && (
|
||||
streamError ? (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>{streamError}</Typography.Text>
|
||||
<Typography.Text type="danger" style={{ fontSize: 12 }}>{activeBlob.error}</Typography.Text>
|
||||
<br />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
|
||||
Камера должна поддерживать MJPEG (ISAPI httppreview)
|
||||
Подробности в консоли браузера (F12)
|
||||
</Typography.Text>
|
||||
<Button size="small" style={{ marginTop: 8 }} onClick={() => setStreamError(null)}>
|
||||
<Button size="small" style={{ marginTop: 8 }} loading={activeBlob.loading} onClick={activeBlob.reload}>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
) : activeBlob.loading ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center' }}>
|
||||
<Spin />
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
Загрузка...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : activeBlob.blobUrl ? (
|
||||
<img
|
||||
src={streamSrc}
|
||||
alt="Camera MJPEG stream"
|
||||
src={activeBlob.blobUrl}
|
||||
alt={viewMode === 'stream' ? 'MJPEG stream' : 'Camera snapshot'}
|
||||
style={{ width: '100%', borderRadius: 4, display: 'block' }}
|
||||
onError={() => setStreamError('Не удалось открыть поток. Камера может не поддерживать MJPEG через ISAPI.')}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div style={{ padding: '24px 0', textAlign: 'center' }}>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={activeBlob.reload}>
|
||||
{viewMode === 'stream' ? 'Открыть поток' : 'Загрузить снимок'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
|
||||
{viewMode === 'stream'
|
||||
? 'MJPEG поток через сервер (требует поддержки ISAPI httppreview)'
|
||||
: 'Снимок через сервер — прямой доступ к камере из браузера не нужен'}
|
||||
? 'MJPEG через сервер (ISAPI httppreview). Нажмите обновить чтобы загрузить.'
|
||||
: 'Снимок через сервер с JWT токеном'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue