фикс камеры 3

This commit is contained in:
dv 2026-04-14 15:30:02 +03:00
parent 0bbf06aeed
commit f788067d5a
2 changed files with 111 additions and 69 deletions

View file

@ -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

View file

@ -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>