├── src-tauri ├── resources │ └── gstreamer │ │ ├── .gitkeep │ │ ├── .download-skipped │ │ └── README.txt ├── icons │ ├── 32x32.png │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ ├── 128x128.png │ ├── 512x512.png │ ├── 128x128@2x.png │ ├── StoreLogo.png │ ├── Square30x30Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ └── Square310x310Logo.png ├── .gitignore ├── python_src │ ├── anpr │ │ ├── test_result.txt │ │ ├── easyocr_cli.py │ │ └── pytesseract_ocr.py │ └── .gitignore ├── src │ ├── main.rs │ ├── bin │ │ ├── ffmpeg_silent_launcher.rs │ │ └── ffmpeg_silent.rs │ ├── crypto │ │ └── mod.rs │ └── analytics │ │ └── anpr_config.rs ├── capabilities │ └── default.json ├── cameras.json ├── archive_server.py ├── examples │ ├── debug_onvif.rs │ ├── check_directml.rs │ └── test_onvif.rs ├── Cargo.toml ├── build.rs └── tauri.conf.json ├── src ├── components │ ├── ArchiveImproved.tsx.backup │ ├── FileManager_broken_backup.tsx │ ├── analytics │ │ └── designer │ │ │ ├── RegionDesignerTypes.ts │ │ │ └── index.ts │ ├── PlateDatabase.tsx │ ├── Toast.tsx │ ├── CellOverlays.tsx │ ├── CellInfo.tsx │ ├── LogViewerModal.tsx │ ├── TauriContextMenu.tsx │ ├── VideoPlayer.tsx │ ├── SideNavigation.tsx │ ├── BatchAddCameraDialog.tsx │ ├── EnsureHttpServer.tsx │ ├── LoginScreen.tsx │ ├── AnalyticsModal.tsx │ └── Layout.tsx ├── services │ ├── updaterLogger.ts │ ├── streamBridge.ts │ └── rtsp.ts ├── main.tsx ├── hooks │ ├── useAuth.ts │ ├── useAppState.ts │ ├── useAnalytics.ts │ ├── useLocalization.ts │ ├── useCameraContextMenu.ts │ └── useToast.ts ├── utils │ ├── hls.ts │ ├── tauri.ts │ ├── cameraStatus.ts │ └── cameraStreams.ts ├── contexts │ ├── AuthContextData.ts │ ├── LocalizationContextData.ts │ ├── LoggerUiContext.tsx │ ├── CameraContextMenuContextData.ts │ ├── AnalyticsContextData.ts │ ├── AppStateContextData.ts │ ├── LoggerContext.tsx │ ├── WebRTCStatsContext.tsx │ ├── CameraContextMenuContext.tsx │ ├── LocalizationContext.tsx │ └── AuthContext.tsx ├── App.css ├── index.css ├── global.d.ts ├── assets │ └── react.svg └── types │ └── index.ts ├── .cargo └── config.toml ├── tsconfig.json ├── requirements.txt ├── request.xml ├── index.html ├── .gitignore ├── tsconfig.node.json ├── tools ├── anpr │ └── export_models.md ├── convert_yolo_pt_to_onnx.py ├── inspect_apk.py ├── download-onnxruntime-directml.py ├── generate_updater_json.js ├── download-ffmpeg.py ├── install-onnxruntime-system.ps1 ├── download-go2rtc.py └── build.sh ├── tsconfig.app.json ├── docs ├── anpr │ ├── integration-plan.md │ └── CHANGELOG.md ├── analytics-overview.md ├── rtsp_url_test_cases.md ├── rtsp_auth_fix.md ├── RELEASE.md └── webrtc-stats-integration.md ├── eslint.config.js ├── MANUAL_BUILD_SYSTEM.md ├── public └── vite.svg ├── cleanup_mediamtx.py ├── .github ├── copilot-instructions.md └── workflows │ └── prepare-release.yml ├── vite.config.ts ├── package.json ├── debug_onvif.py ├── CAMERA_DISCOVERY_TEST.md ├── RTSP_AUTHENTICATION_FIX.md ├── GROUP_MANAGEMENT.md ├── LOCALIZATION.md └── UPLOAD_INSTRUCTIONS.md /src-tauri/resources/gstreamer/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ArchiveImproved.tsx.backup: -------------------------------------------------------------------------------- 1 | // Backup of broken file -------------------------------------------------------------------------------- /src-tauri/resources/gstreamer/.download-skipped: -------------------------------------------------------------------------------- 1 | Skipped: no bundle available 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-lAdvapi32"] 3 | -------------------------------------------------------------------------------- /src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /src-tauri/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/512x512.png -------------------------------------------------------------------------------- /src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /src/components/FileManager_broken_backup.tsx: -------------------------------------------------------------------------------- 1 | // Legacy backup placeholder removed from build 2 | export {}; 3 | -------------------------------------------------------------------------------- /src/components/analytics/designer/RegionDesignerTypes.ts: -------------------------------------------------------------------------------- 1 | export type RegionDesignerMode = 'line' | 'zone'; 2 | -------------------------------------------------------------------------------- /src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | /gen/schemas 5 | -------------------------------------------------------------------------------- /src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /src/components/analytics/designer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as RegionDesignerDialog } from './RegionDesignerDialog'; 2 | -------------------------------------------------------------------------------- /src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /src-tauri/python_src/anpr/test_result.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenIPC/dashboard/main/src-tauri/python_src/anpr/test_result.txt -------------------------------------------------------------------------------- /src/components/PlateDatabase.tsx: -------------------------------------------------------------------------------- 1 | import LicensePlatePanel from './analytics/LicensePlatePanel'; 2 | 3 | export default LicensePlatePanel; 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # General dependencies 2 | requests>=2.25.0 3 | 4 | # ANPR OCR dependencies (for anpr_ocr.py) 5 | opencv-python>=4.8.0 6 | torch>=2.0.0 7 | torchvision>=0.15.0 8 | numpy>=1.24.0 9 | -------------------------------------------------------------------------------- /src-tauri/resources/gstreamer/README.txt: -------------------------------------------------------------------------------- 1 | This directory optionally contains a redistributable GStreamer runtime for Linux bundles. 2 | If no runtime is staged, leave the folder empty or remove this placeholder. 3 | -------------------------------------------------------------------------------- /src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | mod discovery; 5 | 6 | fn main() { 7 | app_lib::run(); 8 | } 9 | -------------------------------------------------------------------------------- /request.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | All 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/services/updaterLogger.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | 3 | export async function appendUpdateLog(message: string): Promise { 4 | try { 5 | await invoke('append_update_log', { message }); 6 | } catch (error) { 7 | console.warn('Failed to write update log', error); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client' 2 | import './index.css' 3 | import App from './App.tsx' 4 | 5 | // StrictMode отключен для избежания двойного монтирования компонентов 6 | // и дублирования WebRTC соединений в dev режиме 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | ) 10 | -------------------------------------------------------------------------------- /src-tauri/python_src/.gitignore: -------------------------------------------------------------------------------- 1 | # Python scripts for ANPR are now downloaded on-demand from GitHub releases 2 | # Do not commit large files here 3 | 4 | # Main OCR script (downloaded) 5 | anpr_ocr.py 6 | 7 | # ANPR subdirectory models 8 | anpr/*.onnx 9 | anpr/*.pth 10 | anpr/*.pt 11 | 12 | # Python cache 13 | __pycache__/ 14 | *.pyc 15 | -------------------------------------------------------------------------------- /src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AuthContext } from '../contexts/AuthContextData'; 3 | 4 | export const useAuth = () => { 5 | const context = useContext(AuthContext); 6 | if (!context) { 7 | throw new Error('useAuth must be used within an AuthProvider'); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useAppState.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AppStateContext } from '../contexts/AppStateContextData'; 3 | 4 | export const useAppState = () => { 5 | const context = useContext(AppStateContext); 6 | if (!context) { 7 | throw new Error('useAppState must be used within an AppStateProvider'); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useAnalytics.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { AnalyticsContext } from '../contexts/AnalyticsContextData'; 3 | 4 | export const useAnalytics = () => { 5 | const context = useContext(AnalyticsContext); 6 | if (!context) { 7 | throw new Error('useAnalytics must be used within an AnalyticsProvider'); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /src/hooks/useLocalization.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LocalizationContext } from '../contexts/LocalizationContextData'; 3 | 4 | export const useLocalization = () => { 5 | const context = useContext(LocalizationContext); 6 | if (!context) { 7 | throw new Error('useLocalization must be used within a LocalizationProvider'); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/hls.ts: -------------------------------------------------------------------------------- 1 | export interface HlsErrorData { 2 | type?: string; 3 | fatal?: boolean; 4 | } 5 | 6 | export const isHlsErrorData = (value: unknown): value is HlsErrorData => { 7 | if (!value || typeof value !== 'object') { 8 | return false; 9 | } 10 | 11 | const candidate = value as Partial; 12 | return typeof candidate.fatal === 'boolean' || candidate.fatal === undefined; 13 | }; 14 | -------------------------------------------------------------------------------- /src/hooks/useCameraContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { CameraContextMenuContext } from '../contexts/CameraContextMenuContextData'; 3 | 4 | export const useCameraContextMenu = () => { 5 | const context = useContext(CameraContextMenuContext); 6 | if (!context) { 7 | throw new Error('useCameraContextMenu must be used within a CameraContextMenuProvider'); 8 | } 9 | return context; 10 | }; 11 | -------------------------------------------------------------------------------- /src-tauri/capabilities/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../gen/schemas/desktop-schema.json", 3 | "identifier": "default", 4 | "description": "enables the default permissions", 5 | "windows": [ 6 | "main" 7 | ], 8 | "permissions": [ 9 | "core:default", 10 | "dialog:default", 11 | "dialog:allow-open", 12 | "dialog:allow-save", 13 | "shell:default", 14 | "shell:allow-open", 15 | "updater:default", 16 | "process:default" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | dashboard 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/contexts/AuthContextData.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { AuthUser, UserPermissions } from '../types'; 3 | 4 | export interface AuthContextValue { 5 | user: AuthUser | null; 6 | initializing: boolean; 7 | authenticating: boolean; 8 | error: string | null; 9 | login: (username: string, password: string, rememberMe: boolean) => Promise; 10 | logout: () => Promise; 11 | clearError: () => void; 12 | hasPermission: (permission: keyof UserPermissions) => boolean; 13 | } 14 | 15 | export const AuthContext = createContext(undefined); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Local Development & Secrets 27 | .venv-anpr/ 28 | artifacts/ 29 | playground/ 30 | release-anpr/ 31 | test-hd-stream.html 32 | check-go2rtc-debug.ps1 33 | src-tauri/cameras.json 34 | 35 | # Binaries (downloaded by tools) 36 | src-tauri/binaries/ 37 | 38 | *.key 39 | *.key.pub 40 | -------------------------------------------------------------------------------- /src-tauri/cameras.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "name": "1", 5 | "ip": "192.168.0.157", 6 | "protocol": "openipc", 7 | "port": 554, 8 | "user": "root", 9 | "pass_enc": "yy6Y6CeB2PmVMntZlrc82wDiIqPaOc7Y5KA=", 10 | "onvif_auth": true, 11 | "path_hd": "/stream0", 12 | "path_sd": "/stream1", 13 | "status": "offline" 14 | }, 15 | { 16 | "id": 2, 17 | "name": "ONVIF Camera", 18 | "ip": "172.22.80.46", 19 | "protocol": "onvif", 20 | "port": 80, 21 | "user": "", 22 | "pass_enc": "", 23 | "onvif_auth": false, 24 | "path_hd": "", 25 | "path_sd": "", 26 | "status": "offline" 27 | } 28 | ] -------------------------------------------------------------------------------- /src/contexts/LocalizationContextData.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | export type SupportedLanguage = 'en' | 'ru'; 4 | 5 | export type TranslationValue = string | { [key: string]: TranslationValue }; 6 | 7 | export type Translations = { [key: string]: TranslationValue }; 8 | 9 | export interface LocalizationContextType { 10 | currentLanguage: SupportedLanguage; 11 | setLanguage: (language: SupportedLanguage) => void; 12 | t: (key: string, params?: Record) => string; 13 | translations: Translations; 14 | } 15 | 16 | export const LocalizationContext = createContext(undefined); 17 | -------------------------------------------------------------------------------- /src/utils/tauri.ts: -------------------------------------------------------------------------------- 1 | export const isTauriAvailable = (): boolean => { 2 | if (typeof window === 'undefined') { 3 | return false; 4 | } 5 | 6 | const globalWindow = window as typeof window & { 7 | __TAURI__?: unknown; 8 | __TAURI_INTERNALS__?: unknown; 9 | __TAURI_METADATA__?: unknown; 10 | }; 11 | 12 | if ( 13 | typeof globalWindow.__TAURI__ !== 'undefined' || 14 | typeof globalWindow.__TAURI_INTERNALS__ !== 'undefined' || 15 | typeof globalWindow.__TAURI_METADATA__ !== 'undefined' 16 | ) { 17 | return true; 18 | } 19 | 20 | if (typeof navigator !== 'undefined' && /tauri/i.test(navigator.userAgent || '')) { 21 | return true; 22 | } 23 | 24 | return false; 25 | }; 26 | -------------------------------------------------------------------------------- /src/services/streamBridge.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import type { StreamPathStatus } from '../types'; 3 | 4 | /** 5 | * Получает список статусов потоков из go2rtc. 6 | */ 7 | export async function fetchStreamPathStatuses(): Promise { 8 | try { 9 | const result = await invoke('list_go2rtc_paths'); 10 | return result; 11 | } catch (error) { 12 | console.error('[streamBridge] Failed to fetch stream path statuses', error); 13 | if (error instanceof Error) { 14 | throw error; 15 | } 16 | 17 | throw new Error( 18 | typeof error === 'string' && error.trim().length > 0 19 | ? error 20 | : 'Не удалось получить статус потоков' 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "types": ["node"], 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "verbatimModuleSyntax": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "erasableSyntaxOnly": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["vite.config.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/hooks/useToast.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export type ToastSeverity = 'success' | 'error' | 'warning' | 'info'; 4 | 5 | export interface ToastState { 6 | open: boolean; 7 | message: string; 8 | severity: ToastSeverity; 9 | } 10 | 11 | export const useToast = () => { 12 | const [toast, setToast] = useState({ 13 | open: false, 14 | message: '', 15 | severity: 'success', 16 | }); 17 | 18 | const showToast = (message: string, severity: ToastSeverity = 'success') => { 19 | setToast({ 20 | open: true, 21 | message, 22 | severity, 23 | }); 24 | }; 25 | 26 | const hideToast = () => { 27 | setToast(prev => ({ ...prev, open: false })); 28 | }; 29 | 30 | return { 31 | toast, 32 | showToast, 33 | hideToast, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/cameraStatus.ts: -------------------------------------------------------------------------------- 1 | import type { CameraHealthStatus } from '../contexts/AppStateContextData'; 2 | 3 | export const CAMERA_STATUS_COLORS: Record = { 4 | online: '#4caf50', 5 | lagging: '#ffb74d', 6 | offline: '#f44336', 7 | }; 8 | 9 | const CAMERA_STATUS_DEFAULT_LABELS: Record = { 10 | online: 'Online', 11 | lagging: 'Lagging', 12 | offline: 'Offline', 13 | }; 14 | 15 | export const resolveCameraStatusLabel = ( 16 | status: CameraHealthStatus, 17 | translate?: (key: string) => string, 18 | ): string => { 19 | if (typeof translate === 'function') { 20 | const key = `camera_status_${status}`; 21 | const localized = translate(key); 22 | if (localized && localized !== key) { 23 | return localized; 24 | } 25 | } 26 | return CAMERA_STATUS_DEFAULT_LABELS[status]; 27 | }; 28 | -------------------------------------------------------------------------------- /tools/anpr/export_models.md: -------------------------------------------------------------------------------- 1 | # Экспорт моделей ANPR в ONNX 2 | 3 | Заготовка Python-скрипта лежит в `tools/anpr/export_models.py`. Чтобы выполнить экспорт: 4 | 5 | 1. Склонируйте репозиторий Runoi/ANPR-System во внешнюю директорию, например `external/anpr-system`. 6 | 2. Создайте Python-окружение: 7 | ```powershell 8 | python -m venv .venv 9 | .\.venv\Scripts\Activate.ps1 10 | pip install torch torchvision ultralytics 11 | ``` 12 | 3. Запустите скрипт экспорта с указанием путей к моделям и выходной директории для ONNX-файлов: 13 | ```powershell 14 | python tools/anpr/export_models.py --repo-path external/anpr-system --out-dir artifacts/anpr 15 | ``` 16 | 4. В результате появятся два файла: 17 | - `artifacts/anpr/anpr_yolov8.onnx` 18 | - `artifacts/anpr/anpr_crnn.onnx` 19 | 20 | Эти ONNX-модели будут использоваться в ONNX Runtime рантайме модуля `License Plate`. 21 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2022", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "types": ["vite/client"], 9 | "skipLibCheck": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "verbatimModuleSyntax": true, 16 | "moduleDetection": "force", 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | 20 | /* Linting */ 21 | "strict": true, 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": false, 24 | "erasableSyntaxOnly": true, 25 | "noFallthroughCasesInSwitch": true, 26 | "noUncheckedSideEffectImports": true 27 | }, 28 | "include": ["src"] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Snackbar } from '@mui/material'; 3 | import type { ToastSeverity } from '../hooks/useToast'; 4 | 5 | interface ToastProps { 6 | message: string; 7 | severity?: ToastSeverity; 8 | open: boolean; 9 | onClose: () => void; 10 | autoHideDuration?: number; 11 | } 12 | 13 | export const Toast: React.FC = ({ 14 | message, 15 | severity = 'success', 16 | open, 17 | onClose, 18 | autoHideDuration = 3000 19 | }) => { 20 | return ( 21 | 27 | 32 | {message} 33 | 34 | 35 | ); 36 | }; -------------------------------------------------------------------------------- /src/contexts/LoggerUiContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useMemo, useState } from 'react'; 2 | 3 | interface LoggerUiContextValue { 4 | isOpen: boolean; 5 | openViewer: () => void; 6 | closeViewer: () => void; 7 | toggleViewer: () => void; 8 | } 9 | 10 | const LoggerUiContext = createContext(undefined); 11 | 12 | export const LoggerUiProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 13 | const [isOpen, setIsOpen] = useState(false); 14 | 15 | const value = useMemo(() => ({ 16 | isOpen, 17 | openViewer: () => setIsOpen(true), 18 | closeViewer: () => setIsOpen(false), 19 | toggleViewer: () => setIsOpen(prev => !prev), 20 | }), [isOpen]); 21 | 22 | return {children}; 23 | }; 24 | 25 | export const useLoggerUi = (): LoggerUiContextValue => { 26 | const ctx = useContext(LoggerUiContext); 27 | if (!ctx) { 28 | throw new Error('useLoggerUi must be used within LoggerUiProvider'); 29 | } 30 | return ctx; 31 | }; 32 | -------------------------------------------------------------------------------- /tools/convert_yolo_pt_to_onnx.py: -------------------------------------------------------------------------------- 1 | import os 2 | from ultralytics import YOLO 3 | 4 | # Define paths 5 | script_dir = os.path.dirname(os.path.abspath(__file__)) 6 | project_root = os.path.dirname(script_dir) 7 | input_path = os.path.join(project_root, "external", "yolo11n_ object .pt") 8 | output_path = os.path.join(project_root, "external", "yolo11n.onnx") 9 | 10 | # Check if input file exists 11 | if not os.path.exists(input_path): 12 | print(f"Error: Input file not found at {input_path}") 13 | exit(1) 14 | 15 | print(f"Loading model from {input_path}...") 16 | try: 17 | model = YOLO(input_path) 18 | print("Model loaded successfully.") 19 | 20 | print("Exporting to ONNX...") 21 | # Export the model 22 | # opset=12 is usually a safe bet for compatibility, or 11. 23 | # dynamic=False is often better for specific hardware acceleration like DirectML if input size is fixed. 24 | # The current code uses 640x640. 25 | path = model.export(format="onnx", imgsz=640, opset=12) 26 | print(f"Model exported successfully to {path}") 27 | 28 | # Rename if necessary (YOLO export usually names it same as input but with .onnx) 29 | # The export method returns the path to the exported file. 30 | 31 | except Exception as e: 32 | print(f"An error occurred: {e}") 33 | exit(1) 34 | -------------------------------------------------------------------------------- /src/contexts/CameraContextMenuContextData.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { Camera, CameraGroup } from '../types'; 3 | 4 | export type CameraContextMenuAction = (camera: Camera) => void; 5 | export type MoveToGroupAction = (camera: Camera, groupId: number | null) => void; 6 | 7 | export type CameraContextMenuHandlers = { 8 | onArchive?: CameraContextMenuAction; 9 | onEdit?: CameraContextMenuAction; 10 | onDelete?: CameraContextMenuAction; 11 | onOpenInBrowser?: CameraContextMenuAction; 12 | onFileManager?: CameraContextMenuAction; 13 | onSSH?: CameraContextMenuAction; 14 | onMoveToGroup?: MoveToGroupAction; 15 | }; 16 | 17 | export interface OpenCameraContextMenuPayload { 18 | camera: Camera; 19 | anchorPosition: { left: number; top: number }; 20 | handlers?: CameraContextMenuHandlers; 21 | groups?: CameraGroup[]; 22 | } 23 | 24 | export interface CameraContextMenuContextValue { 25 | openCameraContextMenu: (payload: OpenCameraContextMenuPayload) => void; 26 | closeCameraContextMenu: () => void; 27 | registerDefaultCameraContextMenuHandlers: (handlers: CameraContextMenuHandlers) => void; 28 | getDefaultCameraContextMenuHandlers: () => CameraContextMenuHandlers; 29 | } 30 | 31 | export const CameraContextMenuContext = createContext< 32 | CameraContextMenuContextValue | undefined 33 | >(undefined); 34 | -------------------------------------------------------------------------------- /docs/anpr/integration-plan.md: -------------------------------------------------------------------------------- 1 | # License Plate Module Integration Plan 2 | 3 | ## Цели 4 | 5 | - Использовать модели из проекта [Runoi/ANPR-System](https://github.com/Runoi/ANPR-System) 6 | - Подготовить их к выполнению в ONNX Runtime (CPU / DirectML) 7 | - Добавить отдельный модуль `license-plate` в Dashboard 8 | 9 | ## Экспорт моделей 10 | 11 | Скрипт: `tools/anpr/export_models.py` 12 | 13 | 1. Подготовить локальную копию репозитория ANPR-System 14 | 2. Выполнить экспорт `YOLO -> anpr_yolov8.onnx`, `CRNN -> anpr_crnn.onnx` 15 | 3. Сохранить файлы в `artifacts/anpr` 16 | 4. Скопировать готовые ONNX модели в `src-tauri/python_src/anpr` для упаковки рантайма (`anpr_yolov8.onnx`, `anpr_crnn.onnx`) 17 | 18 | ## Рантайм 19 | 20 | Промежуточный прототип лежит в `src-tauri/python_src/anpr/license_plate_runtime.py`. Он: 21 | - Загружает ONNX модели через ONNX Runtime 22 | - Подключается к RTSP 23 | - Сейчас выводит заглушку `detections: []` 24 | 25 | Дальше нужно: 26 | 1. Реализовать пост-обработку YOLO (bbox, confidence, фильтрация) 27 | 2. Реализовать cropping, препроцессинг, OCR, стабилизацию текста 28 | 3. Вернуть JSON интерфейс, совместимый с менеджером аналитики 29 | 30 | ## Следующие шаги 31 | 32 | - Дописать пост-обработку / OCR в рантайме 33 | - Создать PyInstaller spec и собрать бинарники 34 | - Подготовить архив модуля + обновить `module-registry.json` 35 | - Сделать UI/локализацию для параметров модуля 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { defineConfig, globalIgnores } from 'eslint/config' 7 | 8 | export default defineConfig([ 9 | globalIgnores(['dist', 'target', 'src-tauri/target', '**/tauri-codegen-assets/**']), 10 | { 11 | files: ['src/**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | rules: { 23 | // TypeScript rules - make some warnings instead of errors 24 | '@typescript-eslint/no-explicit-any': 'warn', 25 | '@typescript-eslint/no-unused-vars': 'warn', 26 | '@typescript-eslint/ban-ts-comment': 'warn', 27 | 28 | // React rules - make warnings instead of errors 29 | 'react-hooks/exhaustive-deps': 'warn', 30 | 'react-refresh/only-export-components': 'warn', 31 | 32 | // Allow some common patterns 33 | 'prefer-const': 'warn', 34 | 35 | // Disable problematic rule 36 | 'react/no-array-index-key': 'off', 37 | }, 38 | }, 39 | ]) 40 | -------------------------------------------------------------------------------- /MANUAL_BUILD_SYSTEM.md: -------------------------------------------------------------------------------- 1 | # Система ручной сборки VMS Dashboard 2 | 3 | ## ✅ Что настроено: 4 | 5 | ### 🔧 Локальная сборка (остается как раньше): 6 | ```bash 7 | npm run build-windows # Windows MSI 8 | npm run build-linux # Linux DEB + AppImage 9 | npm run build-macos # macOS DMG 10 | npm run build-release # Текущая платформа 11 | ``` 12 | 13 | ### 🚀 Ручная сборка через GitHub Actions: 14 | 15 | #### 1. Тестовая сборка (без коммитов): 16 | 1. Идите на https://github.com/OpenIPC/dashboard/actions 17 | 2. Выберите "Build and Test" 18 | 3. Нажмите "Run workflow" 19 | 4. Выберите платформу: 20 | - `all` - Все платформы 21 | - `windows` - Только Windows 22 | - `linux` - Только Linux 23 | - `macos` - Только macOS 24 | 25 | #### 2. Релизная сборка (автоматическая при тегах): 26 | ```bash 27 | git tag v1.0.0 28 | git push origin v1.0.0 29 | ``` 30 | 31 | ## 🎯 Что изменилось: 32 | 33 | ### ❌ Больше НЕ запускается автоматически: 34 | - При каждом push в main/develop 35 | - При создании pull request 36 | 37 | ### ✅ Запускается ТОЛЬКО: 38 | - **Вручную** через GitHub Actions UI 39 | - **Автоматически** при создании version тегов (v1.0.0, v2.1.3, etc.) 40 | 41 | ## 🔄 Workflow состояния: 42 | 43 | - **build.yml**: Только ручной запуск + выбор платформы 44 | - **release.yml**: Автоматический релиз при тегах + ручной запуск 45 | 46 | Теперь вы полностью контролируете, когда запускаются сборки! 🎉 -------------------------------------------------------------------------------- /src/contexts/AnalyticsContextData.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { 3 | AnalyticsModuleStatus, 4 | AnalyticsDetectionResponse, 5 | AnalyticsProcessFrameRequest, 6 | UpdateAnalyticsModuleConfigOptions, 7 | } from '../services/analytics'; 8 | 9 | export interface AnalyticsDetectionEvent extends AnalyticsDetectionResponse { 10 | id: string; 11 | receivedAt: string; 12 | } 13 | 14 | export interface AnalyticsContextValue { 15 | modules: AnalyticsModuleStatus[]; 16 | isLoadingModules: boolean; 17 | moduleOperationId: string | null; 18 | processingModuleIds: string[]; 19 | lastError: string | null; 20 | lastUpdatedAt: string | null; 21 | detections: AnalyticsDetectionEvent[]; 22 | refreshModules: () => Promise; 23 | toggleModule: (moduleId: string, enabled: boolean, pendingMessage?: string) => Promise; 24 | processFrame: (request: AnalyticsProcessFrameRequest) => Promise; 25 | updateModuleSnapshotsDir: ( 26 | moduleId: string, 27 | snapshotsDir?: string, 28 | ) => Promise; 29 | updateModuleConfig: ( 30 | moduleId: string, 31 | options: UpdateAnalyticsModuleConfigOptions, 32 | ) => Promise; 33 | clearDetections: () => void; 34 | } 35 | 36 | export const AnalyticsContext = createContext(undefined); 37 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/CellOverlays.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLocalization } from '../hooks/useLocalization'; 3 | 4 | interface LoadingOverlayProps { 5 | isVisible: boolean; 6 | message?: string; 7 | } 8 | 9 | export const LoadingOverlay: React.FC = ({ 10 | isVisible, 11 | message 12 | }) => { 13 | const { t } = useLocalization(); 14 | 15 | if (!isVisible) return null; 16 | 17 | return ( 18 |
19 |
20 |
{message || t('loading_text')}
21 |
22 | ); 23 | }; 24 | 25 | interface ErrorOverlayProps { 26 | isVisible: boolean; 27 | message: string; 28 | onRetry: () => void; 29 | onClose: () => void; 30 | } 31 | 32 | export const ErrorOverlay: React.FC = ({ 33 | isVisible, 34 | message, 35 | onRetry, 36 | onClose 37 | }) => { 38 | const { t } = useLocalization(); 39 | 40 | if (!isVisible) return null; 41 | 42 | return ( 43 |
44 | error_outline 45 |

{message}

46 |
47 | 50 | 53 |
54 |
55 | ); 56 | }; -------------------------------------------------------------------------------- /cleanup_mediamtx.py: -------------------------------------------------------------------------------- 1 | # Script to remove all MediaMTX-related code from lib.rs 2 | import re 3 | 4 | # Read the file 5 | with open('e:/dashboard/src-tauri/src/lib.rs', 'r', encoding='utf-8') as f: 6 | content = f.read() 7 | 8 | # Remove all functions with mediamtx in name 9 | patterns_to_remove = [ 10 | r'async fn list_mediamtx_paths\(.*?\n\}', 11 | r'async fn fetch_mediamtx_paths\(.*?\n\}', 12 | r'fn collect_mediamtx_paths\(.*?\n\}', 13 | r'fn map_mediamtx_path\(.*?\n\}', 14 | r'fn load_mediamtx_api_bases\(.*?\n\}', 15 | r'fn ensure_mediamtx_files\(.*?\n\}', 16 | r'fn load_mediamtx_config\(.*?\n\}', 17 | r'fn save_mediamtx_config\(.*?\n\}', 18 | r'fn extract_mediamtx_source\(.*?\n\}', 19 | r'fn set_mediamtx_transport\(.*?\n\}', 20 | r'fn spawn_mediamtx_process\(.*?\n\}', 21 | r'fn restart_mediamtx\(.*?\n\}', 22 | r'fn restart_if_running\(.*?\n\}', 23 | r'#\[tauri::command\]\s*async fn mediamtx_start\(.*?\n\}', 24 | r'#\[tauri::command\]\s*async fn mediamtx_stop\(.*?\n\}', 25 | r'async fn add_camera_to_mediamtx\(.*?\n\}', 26 | r'#\[tauri::command\]\s*async fn mediamtx_add_camera\(.*?\n\}', 27 | r'#\[tauri::command\]\s*async fn get_mediamtx_config\(.*?\n\}', 28 | r'#\[tauri::command\]\s*async fn check_mediamtx_path_ready\(.*?\n\}', 29 | r'fn load_mediamtx_whep_base_urls\(.*?\n\}', 30 | r'#\[tauri::command\]\s*async fn check_mediamtx_status\(.*?\n\}', 31 | ] 32 | 33 | print(f'Original file size: {len(content)} chars') 34 | print('Removing MediaMTX functions...') 35 | 36 | # Write the modified content 37 | with open('e:/dashboard/src-tauri/src/lib.rs', 'w', encoding='utf-8') as f: 38 | f.write(content) 39 | 40 | print('Done!') 41 | -------------------------------------------------------------------------------- /src/components/CellInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface CellInfoProps { 4 | cameraName: string; 5 | streamId: number; 6 | stats?: { 7 | codec?: string; 8 | resolution?: string; 9 | bitrate?: string; 10 | }; 11 | } 12 | 13 | const CellInfo: React.FC = ({ cameraName, streamId, stats }) => { 14 | const qualityLabel = streamId === 0 ? 'HD' : 'SD'; 15 | 16 | return ( 17 | <> 18 | {/* Статистика потока - слева внизу */} 19 | {stats && ( 20 |
35 | {stats.codec && stats.bitrate && `${stats.codec} | ${stats.bitrate}`} 36 | {stats.resolution && ` | ${stats.resolution}`} 37 |
38 | )} 39 | 40 | {/* Название камеры и качество - справа внизу */} 41 |
56 | {cameraName} ({qualityLabel}) 57 |
58 | 59 | ); 60 | }; 61 | 62 | export default CellInfo; -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | 2 | - [x] Verify that the copilot-instructions.md file in the .github directory is created. 3 | 4 | - [x] Clarify Project Requirements 5 | 6 | 7 | - [x] Scaffold the Project 8 | 9 | 10 | - [x] Customize the Project 11 | 12 | 13 | - [x] Install Required Extensions 14 | 15 | 16 | - [x] Compile the Project 17 | 18 | 19 | - [x] Create and Run Task 20 | 21 | 22 | - [x] Launch the Project 23 | 24 | 25 | - [x] Ensure Documentation is Complete 26 | 27 | 28 | - [x] Implement PTZ Control 29 | 30 | 31 | - [x] Improve Object Counter 32 | 33 | 34 | - [x] Implement Auto Updater 35 | 36 | 37 | # VMS Dashboard Copilot Instructions 38 | 39 | - Work through each checklist item systematically. 40 | - Keep communication concise and focused. 41 | - Follow development best practices. -------------------------------------------------------------------------------- /src-tauri/archive_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Simple HTTP server with CORS support for serving archive video files 4 | """ 5 | import http.server 6 | import socketserver 7 | import sys 8 | import os 9 | from urllib.parse import unquote 10 | 11 | class CORSHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): 12 | def end_headers(self): 13 | self.send_header('Access-Control-Allow-Origin', '*') 14 | self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 15 | self.send_header('Access-Control-Allow-Headers', 'Content-Type') 16 | self.send_header('Cache-Control', 'no-cache') 17 | super().end_headers() 18 | 19 | def do_OPTIONS(self): 20 | self.send_response(200) 21 | self.end_headers() 22 | 23 | def guess_type(self, path): 24 | # Ensure video files are served with correct MIME type 25 | if path.endswith('.mp4'): 26 | return 'video/mp4' 27 | elif path.endswith('.avi'): 28 | return 'video/x-msvideo' 29 | elif path.endswith('.mkv'): 30 | return 'video/x-matroska' 31 | elif path.endswith('.mov'): 32 | return 'video/quicktime' 33 | return super().guess_type(path) 34 | 35 | if __name__ == "__main__": 36 | port = int(sys.argv[1]) if len(sys.argv) > 1 else 8081 37 | directory = sys.argv[2] if len(sys.argv) > 2 else os.getcwd() 38 | 39 | # Change to the specified directory 40 | os.chdir(directory) 41 | 42 | with socketserver.TCPServer(("127.0.0.1", port), CORSHTTPRequestHandler) as httpd: 43 | print(f"Archive HTTP Server running on http://127.0.0.1:{port}") 44 | print(f"Serving files from: {directory}") 45 | try: 46 | httpd.serve_forever() 47 | except KeyboardInterrupt: 48 | print("\nServer stopped.") -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | /* Отключаем стандартное контекстное меню в Tauri */ 17 | body { 18 | -webkit-user-select: none; 19 | -webkit-context-menu: none; 20 | user-select: none; 21 | } 22 | 23 | /* Разрешаем выделение текста в инпутах */ 24 | input, textarea, [contenteditable] { 25 | -webkit-user-select: text; 26 | user-select: text; 27 | } 28 | 29 | a { 30 | font-weight: 500; 31 | color: #646cff; 32 | text-decoration: inherit; 33 | } 34 | a:hover { 35 | color: #535bf2; 36 | } 37 | 38 | body { 39 | margin: 0; 40 | min-width: 320px; 41 | min-height: 100vh; 42 | width: 100vw; 43 | height: 100vh; 44 | } 45 | 46 | h1 { 47 | font-size: 3.2em; 48 | line-height: 1.1; 49 | } 50 | 51 | #root { 52 | width: 100%; 53 | height: 100%; 54 | } 55 | 56 | button { 57 | border-radius: 8px; 58 | border: 1px solid transparent; 59 | padding: 0.6em 1.2em; 60 | font-size: 1em; 61 | font-weight: 500; 62 | font-family: inherit; 63 | background-color: #1a1a1a; 64 | cursor: pointer; 65 | transition: border-color 0.25s; 66 | } 67 | button:hover { 68 | border-color: #646cff; 69 | } 70 | button:focus, 71 | button:focus-visible { 72 | outline: 4px auto -webkit-focus-ring-color; 73 | } 74 | 75 | @media (prefers-color-scheme: light) { 76 | :root { 77 | color: #213547; 78 | background-color: #ffffff; 79 | } 80 | a:hover { 81 | color: #747bff; 82 | } 83 | button { 84 | background-color: #f9f9f9; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'node:path' 4 | import { fileURLToPath } from 'node:url' 5 | 6 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) 7 | 8 | // https://vite.dev/config/ 9 | export default defineConfig({ 10 | base: './', 11 | plugins: [react()], 12 | resolve: { 13 | alias: { 14 | '@emotion/react': path.resolve(__dirname, 'node_modules/@emotion/react'), 15 | '@emotion/styled': path.resolve(__dirname, 'node_modules/@emotion/styled') 16 | }, 17 | dedupe: ['react', 'react-dom', '@emotion/react', '@emotion/styled'] 18 | }, 19 | optimizeDeps: { 20 | entries: ['src/main.tsx'], 21 | exclude: [], 22 | esbuildOptions: { 23 | absWorkingDir: __dirname 24 | } 25 | }, 26 | build: { 27 | outDir: 'dist', 28 | assetsDir: 'assets', 29 | sourcemap: true, 30 | // Use Terser instead of the default esbuild minifier to avoid identifier 31 | // redeclaration collisions that surfaced in the production bundle. 32 | minify: 'terser', 33 | terserOptions: { 34 | module: true, 35 | mangle: false, 36 | compress: { 37 | passes: 2 38 | }, 39 | format: { 40 | comments: false 41 | } 42 | }, 43 | rollupOptions: { 44 | output: { 45 | manualChunks: undefined 46 | } 47 | } 48 | }, 49 | server: { 50 | strictPort: true, 51 | port: 5173, 52 | watch: { 53 | ignored: [ 54 | '**/playground/**', 55 | '**/release-anpr/**', 56 | '**/src-tauri/target/**', 57 | '**/artifacts/**' 58 | ] 59 | } 60 | }, 61 | // Обеспечиваем совместимость с Tauri 62 | clearScreen: false, 63 | envPrefix: ['VITE_', 'TAURI_PLATFORM', 'TAURI_ARCH', 'TAURI_FAMILY', 'TAURI_PLATFORM_VERSION', 'TAURI_PLATFORM_TYPE'] 64 | }) 65 | -------------------------------------------------------------------------------- /.github/workflows/prepare-release.yml: -------------------------------------------------------------------------------- 1 | name: Prepare Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'New version (e.g., 1.0.0 without v prefix)' 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | prepare-release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | 27 | - name: Update package.json version 28 | run: | 29 | npm version ${{ github.event.inputs.version }} --no-git-tag-version 30 | 31 | - name: Update Cargo.toml version 32 | run: | 33 | sed -i 's/^version = ".*"/version = "${{ github.event.inputs.version }}"/' src-tauri/Cargo.toml 34 | 35 | - name: Update tauri.conf.json version 36 | run: | 37 | node -e " 38 | const fs = require('fs'); 39 | const config = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json', 'utf8')); 40 | config.version = '${{ github.event.inputs.version }}'; 41 | fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(config, null, 2)); 42 | " 43 | 44 | - name: Commit version updates 45 | run: | 46 | git config --local user.email "action@github.com" 47 | git config --local user.name "GitHub Action" 48 | git add package.json src-tauri/Cargo.toml src-tauri/tauri.conf.json 49 | git commit -m "chore: bump version to ${{ github.event.inputs.version }}" || exit 0 50 | git push 51 | 52 | - name: Create and push tag 53 | run: | 54 | git tag v${{ github.event.inputs.version }} 55 | git push origin v${{ github.event.inputs.version }} -------------------------------------------------------------------------------- /src-tauri/examples/debug_onvif.rs: -------------------------------------------------------------------------------- 1 | use reqwest::header::{ 2 | HeaderMap, HeaderValue, ACCEPT, CONNECTION, CONTENT_TYPE, EXPECT, USER_AGENT, 3 | }; 4 | use std::time::Duration; 5 | 6 | #[tokio::main] 7 | async fn main() -> Result<(), Box> { 8 | let url = "http://192.168.3.11:8899/onvif/device_service"; 9 | let soap_xml = r#"All"#; 10 | 11 | println!("Sending request to: {}", url); 12 | 13 | let mut headers = HeaderMap::new(); 14 | headers.insert( 15 | CONTENT_TYPE, 16 | HeaderValue::from_static("application/x-www-form-urlencoded"), 17 | ); 18 | headers.insert(USER_AGENT, HeaderValue::from_static("curl/8.14.1")); 19 | headers.insert(ACCEPT, HeaderValue::from_static("*/*")); 20 | headers.insert(CONNECTION, HeaderValue::from_static("close")); 21 | // Remove Expect header if it exists (reqwest might add it) 22 | headers.insert(EXPECT, HeaderValue::from_static("")); 23 | 24 | let client = reqwest::Client::builder() 25 | .timeout(Duration::from_secs(5)) 26 | .danger_accept_invalid_certs(true) 27 | .http1_only() 28 | .build()?; 29 | 30 | let response = client 31 | .post(url) 32 | .headers(headers) 33 | .body(soap_xml.to_string()) // Use String to ensure Content-Length is calculated 34 | .send() 35 | .await; 36 | 37 | match response { 38 | Ok(resp) => { 39 | println!("Status: {}", resp.status()); 40 | println!("Headers: {:#?}", resp.headers()); 41 | let text = resp.text().await?; 42 | println!("Body: {}", text); 43 | } 44 | Err(e) => { 45 | println!("Error: {:?}", e); 46 | } 47 | } 48 | 49 | Ok(()) 50 | } 51 | -------------------------------------------------------------------------------- /src-tauri/src/bin/ffmpeg_silent_launcher.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | 3 | // ffmpeg-silent-launcher 4 | // Small helper that spawns ffmpeg-silent.exe hiding the console window. 5 | // Used as the command go2rtc invokes for audio transcoding on Windows. 6 | 7 | use std::env; 8 | use std::path::PathBuf; 9 | use std::process::{Command, Stdio}; 10 | 11 | #[cfg(windows)] 12 | use std::os::windows::process::CommandExt; 13 | 14 | #[cfg(windows)] 15 | extern "system" { 16 | fn FreeConsole() -> i32; 17 | } 18 | 19 | fn locate_wrapper() -> Option { 20 | let current_exe = env::current_exe().ok()?; 21 | let exe_dir = current_exe.parent()?; 22 | 23 | let candidates = [ 24 | exe_dir.join("ffmpeg-silent.exe"), 25 | exe_dir.join("binaries").join("ffmpeg-silent.exe"), 26 | ]; 27 | 28 | for candidate in candidates { 29 | if candidate.exists() { 30 | return Some(candidate); 31 | } 32 | } 33 | 34 | None 35 | } 36 | 37 | fn main() { 38 | #[cfg(windows)] 39 | unsafe { 40 | FreeConsole(); 41 | } 42 | 43 | let Some(wrapper_path) = locate_wrapper() else { 44 | eprintln!("ffmpeg-silent.exe not found next to dashboard app"); 45 | std::process::exit(1); 46 | }; 47 | 48 | let mut cmd = Command::new(&wrapper_path); 49 | cmd.args(env::args().skip(1)); 50 | cmd.stdin(Stdio::inherit()) 51 | .stdout(Stdio::inherit()) 52 | .stderr(Stdio::inherit()); 53 | 54 | #[cfg(windows)] 55 | { 56 | const CREATE_NO_WINDOW: u32 = 0x08000000; 57 | cmd.creation_flags(CREATE_NO_WINDOW); 58 | } 59 | 60 | match cmd.spawn() { 61 | Ok(mut child) => { 62 | if let Err(err) = child.wait() { 63 | eprintln!("ffmpeg-silent.exe wait failed: {}", err); 64 | std::process::exit(1); 65 | } 66 | } 67 | Err(err) => { 68 | eprintln!("ffmpeg-silent.exe spawn failed: {}", err); 69 | std::process::exit(1); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dashboard-for-openipc", 3 | "private": true, 4 | "version": "0.1.6", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "lint:fix": "eslint . --fix", 11 | "type-check": "tsc --noEmit", 12 | "tauri": "tauri dev", 13 | "tauri-build": "tauri build", 14 | "download-go2rtc": "python tools/download-go2rtc.py", 15 | "download-ffmpeg": "python tools/download-ffmpeg.py", 16 | "download-gstreamer": "python tools/download-gstreamer-runtime.py", 17 | "build-release": "python tools/build.py", 18 | "build-debug": "python tools/build.py --debug", 19 | "build-windows": "python tools/build.py --platform windows", 20 | "build-linux": "python tools/build.py --platform linux", 21 | "build-macos": "python tools/build.py --platform macos" 22 | }, 23 | "dependencies": { 24 | "@emotion/react": "^11.14.0", 25 | "@emotion/styled": "^11.14.1", 26 | "@mui/icons-material": "^7.3.2", 27 | "@mui/material": "^7.3.2", 28 | "@tauri-apps/api": "^2.9.1", 29 | "@tauri-apps/plugin-dialog": "^2.4.0", 30 | "@tauri-apps/plugin-process": "^2.3.1", 31 | "@tauri-apps/plugin-shell": "^2.3.1", 32 | "@tauri-apps/plugin-updater": "^2.9.0", 33 | "@videojs/themes": "^1.0.1", 34 | "ansi_up": "^6.0.6", 35 | "hls.js": "^1.6.13", 36 | "react": "^19.1.1", 37 | "react-dom": "^19.1.1", 38 | "react-router-dom": "^7.9.3", 39 | "recharts": "^3.2.1", 40 | "video.js": "^8.23.4" 41 | }, 42 | "devDependencies": { 43 | "@eslint/js": "^9.36.0", 44 | "@tauri-apps/cli": "^2.9.6", 45 | "@types/hls.js": "^0.13.3", 46 | "@types/node": "^22.10.1", 47 | "@types/react": "^19.1.13", 48 | "@types/react-dom": "^19.1.9", 49 | "@types/video.js": "^7.3.58", 50 | "@vitejs/plugin-react": "^5.0.3", 51 | "eslint": "^9.36.0", 52 | "eslint-plugin-react-hooks": "^5.2.0", 53 | "eslint-plugin-react-refresh": "^0.4.20", 54 | "globals": "^16.4.0", 55 | "terser": "^5.44.0", 56 | "typescript": "~5.8.3", 57 | "typescript-eslint": "^8.44.0", 58 | "vite": "^5.0.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /debug_onvif.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import time 3 | 4 | def debug_camera(ip, port, user, passw, token): 5 | # 1. Check Imaging Service Options 6 | print(f"\n--- Checking Imaging Service Options ---") 7 | # We need to find the Imaging Service URL first, but we'll guess or use the one from logs 8 | # Logs said: http://192.168.3.11:8899/onvif/imaging 9 | imaging_url = f"http://{ip}:{port}/onvif/imaging" 10 | 11 | body_opts = f""" 12 | 13 | 14 | 000 15 | 16 | 17 | """ 18 | 19 | try: 20 | resp = requests.post(imaging_url, data=body_opts, auth=(user, passw), timeout=5, proxies={"http": None, "https": None}) 21 | print(f"GetOptions Status: {resp.status_code}") 22 | print(f"GetOptions Body: {resp.text}") 23 | except Exception as e: 24 | print(f"GetOptions Error: {e}") 25 | 26 | # 2. Try Relative Move (Imaging) 27 | print(f"\n--- Testing Relative Move (Imaging) ---") 28 | body_rel = f""" 29 | 30 | 31 | 000 32 | 33 | 34 | 0.1 35 | 1.0 36 | 37 | 38 | 39 | 40 | """ 41 | 42 | try: 43 | resp = requests.post(imaging_url, data=body_rel, auth=(user, passw), timeout=5, proxies={"http": None, "https": None}) 44 | print(f"Relative Move Status: {resp.status_code}") 45 | print(f"Relative Move Body: {resp.text}") 46 | except Exception as e: 47 | print(f"Relative Move Error: {e}") 48 | 49 | debug_camera("192.168.3.11", 8899, "admin", "USSKot125@", "000") 50 | -------------------------------------------------------------------------------- /src/components/LogViewerModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Box, IconButton, Paper, Typography } from '@mui/material'; 3 | import CloseIcon from '@mui/icons-material/Close'; 4 | import { useLoggerUi } from '../contexts/LoggerUiContext'; 5 | import { useLocalization } from '../hooks/useLocalization'; 6 | import LogViewer from './LogViewer'; 7 | 8 | const LogViewerModal: React.FC = () => { 9 | const { isOpen, closeViewer } = useLoggerUi(); 10 | const { t } = useLocalization(); 11 | 12 | if (!isOpen) { 13 | return null; 14 | } 15 | 16 | return ( 17 | 30 | 43 | 53 | 54 | {t('log_viewer.title')} 55 | 56 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default LogViewerModal; 74 | -------------------------------------------------------------------------------- /src/contexts/AppStateContextData.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { Camera, CameraGroup, DashboardState } from '../types'; 3 | 4 | export type StreamingProvider = 'go2rtc'; 5 | 6 | export interface AppStateSettings { 7 | language: string; 8 | recordingsFolder: string; 9 | screenshotsFolder: string; 10 | hardwareAcceleration: string; 11 | analyticsProvider: string; 12 | enableNotifications: boolean; 13 | qscale: number; 14 | fps: number; 15 | analytics_resize_width: number; 16 | analytics_frame_skip: number; 17 | analytics_record_duration: number; 18 | anpr_detection_confidence: number; 19 | anpr_crop_expansion: number; 20 | anpr_crnn_confidence: number; 21 | anpr_python_confidence: number; 22 | anpr_enable_python: boolean; 23 | } 24 | 25 | export type CameraHealthStatus = 'online' | 'lagging' | 'offline'; 26 | 27 | export interface CameraStatusEntry { 28 | status: CameraHealthStatus; 29 | lastUpdated: number | null; 30 | bitrateKbps?: number; 31 | frameRate?: number; 32 | } 33 | 34 | export interface AppStateContextType { 35 | cameras: Camera[]; 36 | setCameras: (cameras: Camera[]) => void; 37 | addCamera: (camera: Camera) => Promise; 38 | updateCamera: (camera: Camera) => Promise; 39 | removeCamera: (cameraId: number) => Promise; 40 | groups: CameraGroup[]; 41 | setGroups: (groups: CameraGroup[]) => void; 42 | addGroup: (group: CameraGroup) => Promise; 43 | updateGroup: (group: CameraGroup) => Promise; 44 | removeGroup: (groupId: number) => Promise; 45 | settings: AppStateSettings; 46 | updateSettings: (newSettings: Partial) => Promise; 47 | streamingProvider: StreamingProvider; 48 | ensureStreamingBackendStarted: () => Promise; 49 | prewarmCameraStreams: (camera: Camera) => Promise; 50 | isLoading: boolean; 51 | loadAppState: () => Promise; 52 | saveAppState: () => Promise; 53 | dashboardState: DashboardState; 54 | updateDashboardState: ( 55 | updater: DashboardState | ((prev: DashboardState) => DashboardState) 56 | ) => void; 57 | cameraStatuses: Record; 58 | updateCameraStatus: (cameraId: number, status: CameraStatusEntry) => void; 59 | } 60 | 61 | export const AppStateContext = createContext(undefined); 62 | 63 | -------------------------------------------------------------------------------- /docs/analytics-overview.md: -------------------------------------------------------------------------------- 1 | # Video Analytics Implementation Overview 2 | 3 | ## Current Capabilities 4 | - `src-tauri/src/analytics.rs` provides module lifecycle management (enable/disable, manifest persistence) and a placeholder `process_frame` command. 5 | - Built-in module descriptors: face-detector, license-plate-detector, object-counter. Status data travels via `analytics_list_modules`. 6 | - Frontend settings (`src/components/SettingsModal.tsx`) already surface module metadata and toggles, but they operate on mock state only. 7 | - The Analytics page (`src/components/Analytics.tsx`) renders static charts with hard-coded sample data. 8 | 9 | ## Gaps to Close 10 | - Wire Tauri analytics commands into the React application (list modules, enable/disable, request detections). 11 | - Persist module enablement in shared state (context) so that module status is reflected across UI. 12 | - Implement ingestion of camera frames for analytics processing (decide on source: live video tiles, RTSP snapshots, or server-side captures). 13 | - Replace placeholder analytics visuals with live data (detections, event counts, heatmaps, etc.). 14 | - Surface detections in real-time UI components (overlays, notifications, archive tagging). 15 | - Add robust error handling for module downloads, runtime failures, and resource constraints. 16 | 17 | ## Open Questions 18 | - Where will the analytics runtime binaries live (local bundle vs. on-demand download)? 19 | - What is the desired cadence for frame sampling and processing per module/camera? 20 | - Should detections be stored in the existing archive system or in a dedicated store for analytics events? 21 | - What minimum viable set of visualizations is required for the first release? 22 | 23 | ## Next Steps 24 | 1. Define frontend service wrappers around the available Tauri commands with strict typing. 25 | 2. Introduce shared state (context or tanstack query) to cache module statuses and expose operations to UI/components. 26 | 3. Prototype frame capture pipeline (e.g., capture single frame from active player, encode to Base64, call `analytics_process_frame`). 27 | 4. Replace mock Analytics page with widgets sourced from real detection/event data. 28 | 5. Extend camera tiles/overlays to reflect detection results in real time (highlight bounding boxes, counters, etc.). 29 | 6. Document operational workflows (module installation, fallback paths) for end users. 30 | -------------------------------------------------------------------------------- /src/contexts/LoggerContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; 2 | import { logger } from '../services/logger'; 3 | import type { LogEntry, LogLevel, LogCategory } from '../services/logger'; 4 | 5 | interface LoggerContextType { 6 | logs: LogEntry[]; 7 | addLog: (level: LogLevel, category: LogCategory, message: string, details?: unknown) => void; 8 | clearLogs: () => void; 9 | exportLogs: () => string; 10 | getFilteredLogs: (filter?: { level?: LogLevel; category?: LogCategory; search?: string }) => LogEntry[]; 11 | stats: { total: number; byLevel: Record; byCategory: Record }; 12 | } 13 | 14 | const LoggerContext = createContext(undefined); 15 | 16 | export const LoggerProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 17 | const [logs, setLogs] = useState([]); 18 | const [stats, setStats] = useState(logger.getStats()); 19 | 20 | // Подписываемся на новые логи 21 | useEffect(() => { 22 | // Загружаем существующие логи 23 | setLogs(logger.getLogs()); 24 | 25 | // Подписываемся на обновления 26 | const unsubscribe = logger.subscribe((entry) => { 27 | setLogs(prev => [...prev, entry]); 28 | setStats(logger.getStats()); 29 | }); 30 | 31 | return unsubscribe; 32 | }, []); 33 | 34 | const addLog = useCallback((level: LogLevel, category: LogCategory, message: string, details?: unknown) => { 35 | logger.log(level, category, message, details); 36 | }, []); 37 | 38 | const clearLogs = useCallback(() => { 39 | logger.clear(); 40 | setLogs([]); 41 | setStats(logger.getStats()); 42 | }, []); 43 | 44 | const exportLogs = useCallback(() => { 45 | return logger.exportToFile(); 46 | }, []); 47 | 48 | const getFilteredLogs = useCallback((filter?: { level?: LogLevel; category?: LogCategory; search?: string }) => { 49 | return logger.getLogs(filter); 50 | }, []); 51 | 52 | return ( 53 | 54 | {children} 55 | 56 | ); 57 | }; 58 | 59 | export const useLogger = () => { 60 | const context = useContext(LoggerContext); 61 | if (!context) { 62 | throw new Error('useLogger must be used within LoggerProvider'); 63 | } 64 | return context; 65 | }; 66 | -------------------------------------------------------------------------------- /docs/rtsp_url_test_cases.md: -------------------------------------------------------------------------------- 1 | # RTSP URL Test Cases 2 | 3 | This document contains a list of test cases for validating RTSP URL handling with special characters in usernames and passwords. 4 | 5 | ## Test Cases 6 | 7 | | Case | Description | URL | Expected Result | 8 | |------|-------------|-----|----------------| 9 | | 1 | Basic URL (no special chars) | `rtsp://admin:password@192.168.1.100:554/stream` | Works without modification | 10 | | 2 | @ Symbol in password | `rtsp://admin:pass@word@192.168.1.100:554/stream` | Fixed and works correctly | 11 | | 3 | Space in password | `rtsp://admin:my password@192.168.1.100:554/stream` | Spaces encoded and works | 12 | | 4 | Multiple @ symbols | `rtsp://admin:p@ss@w@rd@192.168.1.100:554/stream` | All @ in password encoded, works | 13 | | 5 | Forward slash in password | `rtsp://admin:pass/word@192.168.1.100:554/stream` | / encoded and works | 14 | | 6 | Special chars in username | `rtsp://admin@home:password@192.168.1.100:554/stream` | @ in username encoded, works | 15 | | 7 | Multiple special chars | `rtsp://admin:p@ss$#%^&@192.168.1.100:554/stream` | All special chars encoded, works | 16 | | 8 | Pre-encoded characters | `rtsp://admin:pass%40word@192.168.1.100:554/stream` | No double encoding, works | 17 | 18 | ## Testing Instructions 19 | 20 | 1. Open the DirectRTSPPlayer component with each test URL 21 | 2. Check browser console for any errors 22 | 3. Verify that the stream plays correctly 23 | 4. Check backend logs for URL processing details 24 | 5. Confirm no 401 authentication errors occur 25 | 26 | ## Troubleshooting 27 | 28 | If a test fails: 29 | 30 | 1. Check the backend logs to see how the URL was processed 31 | 2. Look for "Fixed RTSP URL" entries to see the final URL that was sent 32 | 3. Verify that special characters are properly encoded in the URL 33 | 4. Check for any double-encoding of percent symbols (% becoming %25) 34 | 5. Ensure the authentication information is correctly separated from the host 35 | 36 | ## Expected Backend Log Format 37 | 38 | ``` 39 | Processing RTSP URL for direct playback 40 | URL contains special characters, decoding first to avoid double-encoding 41 | Successfully decoded URL 42 | Fixing RTSP URL: Input URL starts with rtsp:// 43 | Password contains special characters, encoding them 44 | Fixed RTSP URL: rtsp://***:***@192.168.1.100:554/stream 45 | Decoded URL for FFmpeg (credentials masked) 46 | Returning URL for processing (credentials masked) 47 | ``` -------------------------------------------------------------------------------- /src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dashboard" 3 | version = "0.1.6" 4 | description = "A Tauri App" 5 | authors = ["Rinibr"] 6 | license = "" 7 | repository = "" 8 | edition = "2021" 9 | rust-version = "1.77.2" 10 | default-run = "dashboard" 11 | 12 | [features] 13 | default = [] 14 | device-discovery = [] 15 | hevc-export = ["minimp4", "scuffle-h265"] 16 | 17 | [lib] 18 | name = "app_lib" 19 | crate-type = ["staticlib", "cdylib", "rlib"] 20 | path = "src/lib.rs" 21 | 22 | [[bin]] 23 | name = "dashboard" 24 | path = "src/main.rs" 25 | 26 | [[bin]] 27 | name = "ffmpeg-silent" 28 | path = "src/bin/ffmpeg_silent.rs" 29 | 30 | [[bin]] 31 | name = "ffmpeg-silent-launcher" 32 | path = "src/bin/ffmpeg_silent_launcher.rs" 33 | 34 | [build-dependencies] 35 | tauri-build = { version = "2.4.1", features = [] } 36 | 37 | [dependencies] 38 | serde_json = "1.0" 39 | serde = { version = "1.0", features = ["derive"] } 40 | tauri = { version = "2.8.5", features = ["protocol-asset"] } 41 | tauri-plugin-dialog = "2.4.0" 42 | tauri-plugin-shell = "2.3.1" 43 | tauri-plugin-updater = "2.9.0" 44 | tauri-plugin-process = "2.3.1" 45 | tokio = { version = "1", features = ["full"] } 46 | hyper = { version = "0.14", features = ["full"] } 47 | aes-gcm = "0.10.3" 48 | base64 = "0.22.1" 49 | sysinfo = "0.28" 50 | pnet_datalink = "0.35.0" 51 | get_if_addrs = "0.5.3" 52 | dirs-next = "2.0.0" 53 | chrono = { version = "0.4", features = ["serde"] } 54 | yaserde = "0.8" 55 | url = "2.3.1" 56 | regex = "1.10" 57 | reqwest = { version = "0.11", features = ["blocking", "json"] } 58 | urlencoding = "2.1" 59 | futures-util = "0.3" 60 | webrtc = "0.9" 61 | once_cell = "1.0" 62 | rtp = "0.9" 63 | serde_yaml = "0.9" 64 | pbkdf2 = "0.12" 65 | sha2 = "0.10" 66 | rand = "0.8" 67 | zip = "0.6" 68 | minimp4 = { version = "0.1.2", optional = true } 69 | scuffle-h265 = { version = "0.2.2", optional = true } 70 | ssh2 = { version = "0.9", default-features = false, features = ["vendored-openssl"] } 71 | libc = "0.2" 72 | parking_lot = "0.12" 73 | ort = { version = "2.0.0-rc.10", default-features = false, features = ["load-dynamic", "directml"] } 74 | image = { version = "0.24", default-features = false, features = ["jpeg", "png"] } 75 | ndarray = "0.15" 76 | thiserror = "1.0" 77 | itertools = "0.12" 78 | flate2 = { version = "1.0", features = ["miniz_oxide"] } 79 | tar = "0.4" 80 | rusqlite = { version = "0.31", features = ["bundled"] } 81 | lazy_static = "1.4" 82 | hex = "0.4" 83 | 84 | [target.'cfg(windows)'.dependencies] 85 | ipconfig = "0.3" 86 | dirs = "5.0" 87 | sha1 = "0.10" 88 | -------------------------------------------------------------------------------- /src-tauri/examples/check_directml.rs: -------------------------------------------------------------------------------- 1 | use ort::execution_providers::{DirectMLExecutionProvider, ExecutionProvider}; 2 | 3 | fn main() { 4 | // Configure PATH to use bundled ONNX Runtime DLL 5 | #[cfg(windows)] 6 | { 7 | use std::env; 8 | if let Ok(exe_path) = env::current_exe() { 9 | if let Some(exe_dir) = exe_path.parent() { 10 | let binaries_dir = exe_dir.join("binaries"); 11 | if binaries_dir.exists() { 12 | let onnxruntime_dll = binaries_dir.join("onnxruntime.dll"); 13 | if onnxruntime_dll.exists() { 14 | println!("📦 Using ONNX Runtime from: {}", onnxruntime_dll.display()); 15 | env::set_var("ORT_DYLIB_PATH", onnxruntime_dll); 16 | } 17 | } else { 18 | println!( 19 | "⚠ Binaries directory not found at: {}", 20 | binaries_dir.display() 21 | ); 22 | } 23 | } 24 | } 25 | } 26 | 27 | println!("=== DirectML Diagnostic Tool ===\n"); 28 | 29 | // Check if DirectML is available 30 | println!("1. Checking DirectML availability..."); 31 | let dml = DirectMLExecutionProvider::default(); 32 | 33 | match dml.is_available() { 34 | Ok(true) => { 35 | println!(" ✓ DirectML is AVAILABLE"); 36 | println!(" → Your system supports GPU acceleration!"); 37 | } 38 | Ok(false) => { 39 | println!(" ✗ DirectML is NOT available"); 40 | println!(" → Possible reasons:"); 41 | println!(" - DirectML.dll not found in system"); 42 | println!(" - GPU drivers outdated"); 43 | println!(" - Windows version too old (need 1903+)"); 44 | println!(" - DirectX 12 not supported by GPU"); 45 | } 46 | Err(e) => { 47 | println!(" ✗ Error checking DirectML: {}", e); 48 | println!(" → ONNX Runtime may not be properly initialized"); 49 | } 50 | } 51 | 52 | println!("\n2. System Information:"); 53 | println!(" OS: {}", std::env::consts::OS); 54 | println!(" Architecture: {}", std::env::consts::ARCH); 55 | 56 | println!("\n3. Recommendations:"); 57 | println!(" - Update GPU drivers to latest version"); 58 | println!(" - Ensure Windows 10 1903+ or Windows 11"); 59 | println!(" - Install DirectX 12 runtime"); 60 | println!(" - Try setting analytics_provider to 'dml' instead of 'auto'"); 61 | 62 | println!("\n=== End of Diagnostic ==="); 63 | } 64 | -------------------------------------------------------------------------------- /src-tauri/python_src/anpr/easyocr_cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | EasyOCR for license plates - command-line interface 4 | Usage: python easyocr_cli.py 5 | """ 6 | 7 | import sys 8 | import os 9 | from pathlib import Path 10 | 11 | try: 12 | import easyocr 13 | import cv2 14 | import numpy as np 15 | except ImportError as e: 16 | print(f"ERROR: Missing dependency: {e}", file=sys.stderr) 17 | print("Install with: pip install easyocr opencv-python", file=sys.stderr) 18 | sys.exit(1) 19 | 20 | # Initialize EasyOCR reader (cache for future calls) 21 | _READER_CACHE = None 22 | 23 | def get_reader(): 24 | """Get or create EasyOCR reader""" 25 | global _READER_CACHE 26 | if _READER_CACHE is None: 27 | _READER_CACHE = easyocr.Reader(['ru', 'en'], gpu=False) 28 | return _READER_CACHE 29 | 30 | def preprocess_for_ocr(image_path): 31 | """Preprocess image for better OCR - minimal processing""" 32 | img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE) 33 | if img is None: 34 | raise ValueError(f"Cannot read image: {image_path}") 35 | 36 | # Minimal preprocessing - only slight contrast enhancement 37 | img = cv2.convertScaleAbs(img, alpha=1.2, beta=10) 38 | 39 | return img 40 | 41 | def recognize_plate(image_path): 42 | """Recognize license plate using EasyOCR""" 43 | try: 44 | # Preprocess 45 | processed = preprocess_for_ocr(image_path) 46 | 47 | # Get reader 48 | reader = get_reader() 49 | 50 | # Run OCR 51 | results = reader.readtext( 52 | processed, 53 | detail=0, # Only text, no bounding boxes 54 | paragraph=False, 55 | allowlist='0123456789АВЕКМНОРСТУХ', 56 | width_ths=0.7, # Merge close words 57 | height_ths=0.7 58 | ) 59 | 60 | # Join all results 61 | text = ''.join(results).upper() 62 | 63 | # Clean result 64 | cleaned = ''.join(c for c in text if c.isalnum()) 65 | 66 | return cleaned 67 | 68 | except Exception as e: 69 | print(f"ERROR: {e}", file=sys.stderr) 70 | return "" 71 | 72 | def main(): 73 | if len(sys.argv) != 2: 74 | print("Usage: python easyocr_cli.py ", file=sys.stderr) 75 | sys.exit(1) 76 | 77 | image_path = Path(sys.argv[1]) 78 | if not image_path.exists(): 79 | print(f"ERROR: Image not found: {image_path}", file=sys.stderr) 80 | sys.exit(1) 81 | 82 | result = recognize_plate(image_path) 83 | print(result) # Output to stdout 84 | sys.exit(0) 85 | 86 | if __name__ == "__main__": 87 | main() 88 | -------------------------------------------------------------------------------- /CAMERA_DISCOVERY_TEST.md: -------------------------------------------------------------------------------- 1 | # Тестирование функционала поиска камер 2 | 3 | ## 🔍 Как проверить работу поиска камер: 4 | 5 | ### **1. Запуск поиска:** 6 | - Нажмите кнопку 🔍 "Поиск камер" в панели инструментов DevicePanel 7 | - Откроется диалог "Найденные камеры" 8 | - Поиск запустится автоматически 9 | 10 | ### **2. Что происходит под капотом:** 11 | - **Frontend** вызывает `invoke('discover_cameras')` 12 | - **Rust backend** запускает высокопараллельный TCP-сканер по каждому сетевому интерфейсу: 13 | - Пул из 96 асинхронных задач проверяет подсети `/24` 14 | - Cканируются типовые порты видеокамер: 554, 8554, 7447, 80, 8000, 8080, 8899, 2020 15 | - На успешной TCP-сессии дополнительно пробуем опросить HTTP-заголовки для определения модели 16 | - Найденные устройства отправляются через события `device-found` 17 | - Прогресс сканирования передаётся событиями `device-discovery-progress` 18 | - Завершение (или ошибка) помечается событием `device-discovery-finished` 19 | - **Frontend** прослушивает события и обновляет диалог "Найденные камеры" 20 | 21 | ### **3. Логи для отладки:** 22 | В консоли DevTools должны появиться сообщения: 23 | ``` 24 | Starting camera discovery... 25 | [VMS] discover_cameras: start 26 | [VMS] discover_cameras: scanning 254 addresses 27 | Received device-found event: {ip: "192.168.1.100", name: "ONVIF Camera", protocol: "onvif", detectedPort: 554} 28 | [VMS] discover_cameras: scan completed 29 | ``` 30 | 31 | В консоли Rust (терминал): 32 | ``` 33 | [VMS] discover_cameras: scanning 254 addresses 34 | [VMS] Found camera candidate 192.168.1.100 on port 554 35 | [VMS] discover_cameras: scan completed (found 3) 36 | ``` 37 | 38 | ### **4. Возможные проблемы:** 39 | 40 | **Проблема:** Список камер пустой 41 | - **Причина:** Нет ONVIF камер в сети или они недоступны 42 | - **Решение:** Проверить сеть, убедиться что камеры поддерживают ONVIF 43 | 44 | **Проблема:** События не приходят 45 | - **Причина:** Не работает подписка на события 46 | - **Решение:** Проверить `listen('device-found')` в DevicePanel 47 | 48 | **Проблема:** Сканирование зависает 49 | - **Причина:** Таймауты сети слишком большие 50 | - **Решение:** Уменьшить таймауты в discovery.rs (сейчас 200ms) 51 | 52 | ### **5. Тестирование в локальной сети:** 53 | - Убедитесь, что камеры включены и подключены к той же сети 54 | - Проверьте настройки файрвола (могут блокировать UDP/TCP сканирование) 55 | - Попробуйте найти камеры вручную по IP 56 | 57 | ### **6. Симуляция для тестирования:** 58 | Можно добавить тестовые данные, временно изменив `discoverCameras`: 59 | ```typescript 60 | const discoverCameras = async () => { 61 | // Симуляция найденных камер для тестирования 62 | setFoundCameras([ 63 | { name: "Test Camera 1", ip: "192.168.1.100", protocol: "onvif" }, 64 | { name: "Test Camera 2", ip: "192.168.1.101", protocol: "onvif" } 65 | ]); 66 | }; 67 | ``` -------------------------------------------------------------------------------- /src/contexts/WebRTCStatsContext.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * WebRTC Stats Context 3 | * Глобальное хранилище статистики WebRTC соединений для всех активных потоков 4 | */ 5 | 6 | import React, { createContext, useContext, useState, useCallback } from 'react'; 7 | import type { ReactNode } from 'react'; 8 | import type { WebRTCStats } from '../services/webrtcStats'; 9 | 10 | interface WebRTCStatsEntry { 11 | streamName: string; 12 | stats: WebRTCStats; 13 | quality: 'excellent' | 'good' | 'fair' | 'poor' | 'unknown'; 14 | lastUpdated: number; 15 | } 16 | 17 | interface WebRTCStatsContextValue { 18 | getAllStats: () => WebRTCStatsEntry[]; // Функция-getter вместо прямого массива 19 | registerStream: (streamName: string, stats: WebRTCStats, quality: string) => void; 20 | unregisterStream: (streamName: string) => void; 21 | clearAll: () => void; 22 | } 23 | 24 | const WebRTCStatsContext = createContext(undefined); 25 | 26 | export const WebRTCStatsProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 27 | // КРИТИЧНО: Используем ref вместо state чтобы избежать re-render всего дерева компонентов 28 | // Stats обновляются каждые 500ms - это вызывало бы тысячи ненужных re-renders 29 | const allStatsRef = React.useRef([]); 30 | 31 | // Getter для безопасного доступа к current stats 32 | const getAllStats = useCallback(() => allStatsRef.current, []); 33 | 34 | const registerStream = useCallback((streamName: string, stats: WebRTCStats, quality: string) => { 35 | const prev = allStatsRef.current; 36 | const existing = prev.findIndex(entry => entry.streamName === streamName); 37 | const newEntry: WebRTCStatsEntry = { 38 | streamName, 39 | stats, 40 | quality: quality as WebRTCStatsEntry['quality'], 41 | lastUpdated: Date.now(), 42 | }; 43 | 44 | if (existing >= 0) { 45 | const updated = [...prev]; 46 | updated[existing] = newEntry; 47 | allStatsRef.current = updated; 48 | } else { 49 | allStatsRef.current = [...prev, newEntry]; 50 | } 51 | }, []); 52 | 53 | const unregisterStream = useCallback((streamName: string) => { 54 | allStatsRef.current = allStatsRef.current.filter(entry => entry.streamName !== streamName); 55 | }, []); 56 | 57 | const clearAll = useCallback(() => { 58 | allStatsRef.current = []; 59 | }, []); 60 | 61 | return ( 62 | 63 | {children} 64 | 65 | ); 66 | }; 67 | 68 | export const useWebRTCStatsContext = () => { 69 | const context = useContext(WebRTCStatsContext); 70 | if (!context) { 71 | throw new Error('useWebRTCStatsContext must be used within WebRTCStatsProvider'); 72 | } 73 | return context; 74 | }; 75 | -------------------------------------------------------------------------------- /src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs; 3 | use std::path::Path; 4 | 5 | fn main() { 6 | let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); 7 | 8 | if target_os == "windows" { 9 | println!("cargo:rustc-link-lib=Advapi32"); 10 | } 11 | 12 | println!("cargo:rerun-if-changed=binaries/"); 13 | 14 | // Ensure the binaries directory exists 15 | let binaries_dir = Path::new("binaries"); 16 | if !binaries_dir.exists() { 17 | fs::create_dir_all(binaries_dir).expect("Failed to create binaries directory"); 18 | } 19 | 20 | // Guarantee the gstreamer resources folder has at least one visible file so the 21 | // Tauri resource glob does not fail on platforms where we skip bundling. 22 | let gstreamer_dir = Path::new("resources/gstreamer"); 23 | if !gstreamer_dir.exists() { 24 | fs::create_dir_all(gstreamer_dir).expect("Failed to create gstreamer resources directory"); 25 | } 26 | 27 | let mut has_visible_files = false; 28 | if let Ok(entries) = fs::read_dir(gstreamer_dir) { 29 | for entry in entries.flatten() { 30 | let name = entry.file_name(); 31 | if let Ok(metadata) = entry.metadata() { 32 | if metadata.is_file() { 33 | if let Some(name_str) = name.to_str() { 34 | if !name_str.starts_with('.') { 35 | has_visible_files = true; 36 | break; 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | if !has_visible_files { 45 | let placeholder = gstreamer_dir.join("README.txt"); 46 | if !placeholder.exists() { 47 | fs::write(&placeholder, "Optional GStreamer runtime placeholder.\n") 48 | .expect("Failed to create gstreamer placeholder file"); 49 | } 50 | } 51 | 52 | // Copy the go2rtc binary matching the current target so runtime helpers can locate it. 53 | let (src_binary, dst_binary) = match target_os.as_str() { 54 | "windows" => ("binaries/windows/go2rtc.exe", "binaries/go2rtc.exe"), 55 | "linux" => ("binaries/linux/go2rtc", "binaries/go2rtc"), 56 | "macos" => ("binaries/macos/go2rtc", "binaries/go2rtc"), 57 | other => { 58 | println!( 59 | "cargo:warning=Unknown target OS: {}, skipping go2rtc bundling", 60 | other 61 | ); 62 | tauri_build::build(); 63 | return; 64 | } 65 | }; 66 | 67 | if let Err(err) = fs::copy(src_binary, dst_binary) { 68 | println!( 69 | "cargo:warning=Failed to copy go2rtc binary from {} to {}: {}", 70 | src_binary, dst_binary, err 71 | ); 72 | } 73 | 74 | tauri_build::build() 75 | } 76 | -------------------------------------------------------------------------------- /src-tauri/src/bin/ffmpeg_silent.rs: -------------------------------------------------------------------------------- 1 | #![windows_subsystem = "windows"] 2 | 3 | // FFmpeg Silent Wrapper 4 | // Launches ffmpeg without showing a console window on Windows. 5 | 6 | use std::env; 7 | use std::path::PathBuf; 8 | use std::process::{Command, Stdio}; 9 | 10 | #[cfg(windows)] 11 | use std::os::windows::process::CommandExt; 12 | 13 | #[cfg(windows)] 14 | extern "system" { 15 | fn FreeConsole() -> i32; 16 | } 17 | 18 | fn main() { 19 | #[cfg(windows)] 20 | unsafe { 21 | FreeConsole(); 22 | } 23 | 24 | let real_ffmpeg = resolve_ffmpeg_path().unwrap_or_else(|| { 25 | eprintln!("ffmpeg.exe not found in bundled locations"); 26 | std::process::exit(1); 27 | }); 28 | 29 | let mut cmd = Command::new(&real_ffmpeg); 30 | cmd.args(env::args().skip(1)) 31 | .stdin(Stdio::inherit()) 32 | .stdout(Stdio::inherit()) 33 | .stderr(Stdio::inherit()); 34 | 35 | #[cfg(windows)] 36 | { 37 | const CREATE_NO_WINDOW: u32 = 0x08000000; 38 | cmd.creation_flags(CREATE_NO_WINDOW); 39 | } 40 | 41 | match cmd.status() { 42 | Ok(status) => { 43 | if !status.success() { 44 | std::process::exit(status.code().unwrap_or(1)); 45 | } 46 | } 47 | Err(err) => { 48 | eprintln!("ffmpeg.exe execution failed: {}", err); 49 | std::process::exit(1); 50 | } 51 | } 52 | } 53 | 54 | fn resolve_ffmpeg_path() -> Option { 55 | if let Ok(path) = env::var("REAL_FFMPEG_PATH") { 56 | let buf = PathBuf::from(path); 57 | if buf.exists() { 58 | return Some(buf); 59 | } 60 | } 61 | 62 | let current_exe = env::current_exe().ok()?; 63 | let exe_dir = current_exe.parent()?; 64 | 65 | let local_candidates = [ 66 | exe_dir.join("ffmpeg.exe"), 67 | exe_dir.join("binaries").join("ffmpeg.exe"), 68 | ]; 69 | 70 | for path in &local_candidates { 71 | if path.exists() { 72 | return Some(path.clone()); 73 | } 74 | } 75 | 76 | #[cfg(windows)] 77 | { 78 | const CREATE_NO_WINDOW: u32 = 0x08000000; 79 | let mut where_cmd = Command::new("where"); 80 | where_cmd 81 | .arg("ffmpeg.exe") 82 | .stdout(Stdio::piped()) 83 | .stderr(Stdio::null()) 84 | .creation_flags(CREATE_NO_WINDOW); 85 | 86 | if let Ok(output) = where_cmd.output() { 87 | if output.status.success() { 88 | if let Some(first_line) = String::from_utf8_lossy(&output.stdout).lines().next() { 89 | let trimmed = first_line.trim(); 90 | if !trimmed.is_empty() { 91 | return Some(PathBuf::from(trimmed)); 92 | } 93 | } 94 | } 95 | } 96 | } 97 | 98 | None 99 | } 100 | -------------------------------------------------------------------------------- /src/components/TauriContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect } from 'react'; 2 | import { invoke } from '@tauri-apps/api/core'; 3 | import type { Camera } from '../types'; 4 | 5 | interface TauriContextMenuProps { 6 | camera: Camera | null; 7 | anchorPosition: { left: number; top: number } | null; 8 | onClose: () => void; 9 | onArchive: () => void; 10 | onEdit: (camera: Camera) => void; 11 | onDelete: (camera: Camera) => void; 12 | onOpenInBrowser: (camera: Camera) => void; 13 | onFileManager: (camera: Camera) => void; 14 | onSSH: (camera: Camera) => void; 15 | } 16 | 17 | export const TauriContextMenu: React.FC = ({ 18 | camera, 19 | anchorPosition, 20 | onClose, 21 | onArchive, 22 | onEdit, 23 | onDelete, 24 | onOpenInBrowser, 25 | onFileManager, 26 | onSSH, 27 | }) => { 28 | const handleMenuAction = useCallback((action: string) => { 29 | if (!camera) return; 30 | 31 | switch (action) { 32 | case 'archive': 33 | onArchive(); 34 | break; 35 | case 'edit': 36 | onEdit(camera); 37 | break; 38 | case 'delete': 39 | onDelete(camera); 40 | break; 41 | case 'browser': 42 | onOpenInBrowser(camera); 43 | break; 44 | case 'filemanager': 45 | onFileManager(camera); 46 | break; 47 | case 'ssh': 48 | onSSH(camera); 49 | break; 50 | } 51 | onClose(); 52 | }, [camera, onArchive, onClose, onDelete, onEdit, onFileManager, onOpenInBrowser, onSSH]); 53 | 54 | const showNativeContextMenu = useCallback(async () => { 55 | if (!camera) return; 56 | 57 | try { 58 | // Создаем меню с помощью Tauri API 59 | const menuItems = [ 60 | { id: 'archive', label: '📁 Архив' }, 61 | { id: 'edit', label: '✏️ Редактировать камеру' }, 62 | { id: 'delete', label: '🗑️ Удалить камеру' }, 63 | { id: 'separator1', label: '-' }, 64 | { id: 'browser', label: '🌐 Открыть в браузере' }, 65 | { id: 'filemanager', label: '📂 Файловый менеджер' }, 66 | { id: 'ssh', label: '🖥️ SSH терминал' }, 67 | ]; 68 | 69 | // Вызываем Tauri команду для показа контекстного меню 70 | const result = await invoke('show_context_menu', { 71 | items: menuItems, 72 | position: anchorPosition 73 | }); 74 | 75 | // Обрабатываем результат 76 | handleMenuAction(result as string); 77 | } catch (error) { 78 | console.error('Failed to show context menu:', error); 79 | // Fallback - закрываем меню 80 | onClose(); 81 | } 82 | }, [anchorPosition, camera, handleMenuAction, onClose]); 83 | 84 | useEffect(() => { 85 | if (camera && anchorPosition) { 86 | // Показываем нативное контекстное меню Tauri 87 | void showNativeContextMenu(); 88 | } 89 | }, [anchorPosition, camera, showNativeContextMenu]); 90 | 91 | // Этот компонент не рендерит ничего визуально, так как использует нативное меню 92 | return null; 93 | }; -------------------------------------------------------------------------------- /src/contexts/CameraContextMenuContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useMemo, useRef, useState } from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { CameraContextMenu } from '../components/CameraContextMenu'; 4 | import type { Camera, CameraGroup } from '../types'; 5 | import { CameraContextMenuContext } from './CameraContextMenuContextData'; 6 | import type { 7 | CameraContextMenuContextValue, 8 | CameraContextMenuHandlers, 9 | OpenCameraContextMenuPayload, 10 | } from './CameraContextMenuContextData'; 11 | 12 | interface CameraContextMenuState { 13 | camera: Camera; 14 | anchorPosition: { left: number; top: number }; 15 | handlers: CameraContextMenuHandlers; 16 | groups: CameraGroup[]; 17 | } 18 | 19 | export const CameraContextMenuProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 20 | const [state, setState] = useState(null); 21 | const defaultHandlersRef = useRef({}); 22 | 23 | const closeCameraContextMenu = useCallback(() => setState(null), []); 24 | 25 | const openCameraContextMenu = useCallback((payload: OpenCameraContextMenuPayload) => { 26 | setState({ 27 | camera: payload.camera, 28 | anchorPosition: payload.anchorPosition, 29 | handlers: { 30 | ...defaultHandlersRef.current, 31 | ...(payload.handlers ?? {}), 32 | }, 33 | groups: payload.groups ?? [], 34 | }); 35 | }, []); 36 | 37 | const registerDefaultCameraContextMenuHandlers = useCallback((handlers: CameraContextMenuHandlers) => { 38 | defaultHandlersRef.current = handlers; 39 | }, []); 40 | 41 | const getDefaultCameraContextMenuHandlers = useCallback(() => { 42 | return defaultHandlersRef.current; 43 | }, []); 44 | 45 | const contextValue = useMemo(() => ({ 46 | openCameraContextMenu, 47 | closeCameraContextMenu, 48 | registerDefaultCameraContextMenuHandlers, 49 | getDefaultCameraContextMenuHandlers, 50 | }), [closeCameraContextMenu, getDefaultCameraContextMenuHandlers, openCameraContextMenu, registerDefaultCameraContextMenuHandlers]); 51 | 52 | return ( 53 | 54 | {children} 55 | state?.handlers.onArchive?.(camera)} 60 | onEdit={(camera) => state?.handlers.onEdit?.(camera)} 61 | onDelete={(camera) => state?.handlers.onDelete?.(camera)} 62 | onOpenInBrowser={(camera) => state?.handlers.onOpenInBrowser?.(camera)} 63 | onFileManager={(camera) => state?.handlers.onFileManager?.(camera)} 64 | onSSH={(camera) => state?.handlers.onSSH?.(camera)} 65 | groups={state?.groups ?? []} 66 | onMoveToGroup={(camera, groupId) => state?.handlers.onMoveToGroup?.(camera, groupId)} 67 | /> 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /tools/inspect_apk.py: -------------------------------------------------------------------------------- 1 | from androguard.misc import AnalyzeAPK 2 | 3 | apk_path = r"e:/dashboard/decoder_v1.2_20250903.apk" 4 | 5 | a, d, dx = AnalyzeAPK(apk_path) 6 | print("Package:", a.get_package()) 7 | print("Activities:") 8 | for act in a.get_activities(): 9 | print(" ", act) 10 | print("Libraries:", a.get_libraries()) 11 | print("Permissions:") 12 | for perm in a.get_permissions(): 13 | print(" ", perm) 14 | print("Receivers:") 15 | for recv in a.get_receivers(): 16 | print(" ", recv) 17 | print("Providers:") 18 | for prov in a.get_providers(): 19 | print(" ", prov) 20 | 21 | interesting = [] 22 | interesting_methods = {} 23 | for string_analysis in dx.get_strings(): 24 | value = string_analysis.get_value() 25 | if not isinstance(value, str): 26 | continue 27 | lowered = value.lower() 28 | if not any(key in lowered for key in ("rtsp", "rtmp", "webrtc", "hevc", "h265", "mediacodec", "ffmpeg", "gst")): 29 | continue 30 | for ref in string_analysis.get_xref_from(): 31 | if not isinstance(ref, tuple) or len(ref) < 2: 32 | continue 33 | 34 | ref_class = None 35 | ref_method = None 36 | 37 | if len(ref) == 3: 38 | ref_class, ref_method, _ = ref 39 | else: 40 | ref_class, ref_method = ref 41 | 42 | # Determine class name 43 | class_name = None 44 | if ref_class is not None: 45 | if hasattr(ref_class, "get_name"): 46 | class_name = ref_class.get_name() 47 | elif hasattr(ref_class, "get_class_name"): 48 | class_name = ref_class.get_class_name() 49 | 50 | # Resolve method definition 51 | method_def = None 52 | if ref_method is not None: 53 | if hasattr(ref_method, "get_method"): 54 | method_def = ref_method.get_method() 55 | elif hasattr(ref_method, "get_descriptor"): 56 | method_def = ref_method 57 | 58 | if method_def is None: 59 | continue 60 | 61 | if class_name is None and hasattr(method_def, "get_class_name"): 62 | class_name = method_def.get_class_name() 63 | 64 | interesting.append((class_name or "", method_def.get_name(), method_def.get_descriptor(), value)) 65 | key = (method_def.get_class_name(), method_def.get_name(), method_def.get_descriptor()) 66 | interesting_methods.setdefault(key, method_def) 67 | 68 | print("\nInteresting string hits:") 69 | for cls, name, desc, value in interesting: 70 | print(f"[{cls} -> {name}{desc}] {value}") 71 | 72 | if interesting_methods: 73 | print("\nDisassembly of relevant methods:") 74 | for (cls, name, desc), method in interesting_methods.items(): 75 | print(f"\n== {cls}->{name}{desc} ==") 76 | code = method.get_code() 77 | if code is None: 78 | print(" ") 79 | continue 80 | for ins in code.get_bc().get_instructions(): 81 | print(" ", ins.get_name(), ins.get_output()) 82 | -------------------------------------------------------------------------------- /RTSP_AUTHENTICATION_FIX.md: -------------------------------------------------------------------------------- 1 | # RTSP Authentication Fix 2 | 3 | ## Problem Description 4 | The application was encountering an error when trying to play RTSP streams with credentials containing special characters: 5 | 6 | ``` 7 | Error: invalid args `payload` for command `play_direct_rtsp`: command play_direct_rtsp missing required key payload 8 | ``` 9 | 10 | Additionally, even after fixing this error, the application failed to properly handle RTSP URLs with special characters (like '@', '/', spaces) in usernames or passwords. 11 | 12 | ## Root Causes 13 | 14 | ### Issue 1: Parameter Mismatch 15 | The first issue was a parameter mismatch between the frontend and backend: 16 | 17 | 1. In `DirectRTSPPlayer.tsx`, the function was being called with: 18 | ```typescript 19 | fixedUrl = await invoke('play_direct_rtsp', { sdp: src }) as string; 20 | ``` 21 | 22 | 2. While in `lib.rs`, the function was expecting a structure named `payload`: 23 | ```rust 24 | async fn play_direct_rtsp(payload: PlayDirectRtspPayload) -> Result { 25 | let url = payload.sdp; 26 | // ... 27 | } 28 | ``` 29 | 30 | ### Issue 2: Incorrect URL Parsing with Special Characters 31 | The second issue was that the `fix_rtsp_url` function didn't correctly handle URLs with multiple '@' symbols or other special characters in the credentials. 32 | 33 | ## Solutions 34 | 35 | ### Solution 1: Fix Parameter Mismatch 36 | The backend function signature was changed to directly accept the `sdp` parameter: 37 | 38 | ```rust 39 | #[tauri::command] 40 | async fn play_direct_rtsp(sdp: String) -> Result { 41 | let url = sdp; 42 | // ... 43 | } 44 | ``` 45 | 46 | ### Solution 2: Improve URL Parsing and Encoding 47 | The `fix_rtsp_url` function in `rtsp_utils.rs` was rewritten to: 48 | 49 | 1. Correctly identify the last '@' symbol as the separator between credentials and host 50 | 2. Properly URL-encode usernames and passwords with special characters 51 | 3. Validate the final URL to ensure it's correctly formatted 52 | 53 | ```rust 54 | pub fn fix_rtsp_url(input_url: &str) -> Result { 55 | // ... implementation details ... 56 | 57 | // Find the last @ to separate auth from host 58 | // This handles cases where there are @ symbols in the username or password 59 | let mut last_at_pos = None; 60 | 61 | // Find the rightmost @ symbol (should be the auth/host delimiter) 62 | for (i, c) in remainder.chars().enumerate() { 63 | if c == '@' { 64 | last_at_pos = Some(i); 65 | } 66 | } 67 | 68 | // ... URL encoding and validation ... 69 | } 70 | ``` 71 | 72 | ## Expected Results 73 | The fix ensures: 74 | 1. Proper parameter passing between frontend and backend 75 | 2. Correct handling of special characters in RTSP credentials 76 | 3. Consistent URL formatting for all RTSP streams 77 | 78 | ## Notes 79 | - URLs with special characters in credentials are now correctly URL-encoded 80 | - The solution maintains backward compatibility with existing code 81 | - Error handling is improved with more descriptive error messages -------------------------------------------------------------------------------- /src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", 3 | "productName": "Dashboard for OpenIPC", 4 | "version": "0.1.6", 5 | "identifier": "com.openipc.dashboard", 6 | "build": { 7 | "frontendDist": "../dist", 8 | "beforeBuildCommand": "npm run build", 9 | "devUrl": "http://localhost:5173", 10 | "beforeDevCommand": "npm run dev" 11 | }, 12 | "app": { 13 | "windows": [ 14 | { 15 | "title": "Dashboard for OpenIPC", 16 | "width": 1280, 17 | "height": 800, 18 | "resizable": true, 19 | "fullscreen": false, 20 | "maximized": true, 21 | "decorations": true, 22 | "theme": "Dark", 23 | "devtools": true, 24 | "visible": true, 25 | "transparent": false, 26 | "additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required" 27 | } 28 | ], 29 | "security": { 30 | "csp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob: asset: asset://localhost http://asset.localhost; media-src 'self' data: blob: asset: asset://localhost http://asset.localhost; connect-src 'self' ipc: asset: asset://localhost http://asset.localhost http://ipc.localhost ws: wss: http: https:;", 31 | "assetProtocol": { 32 | "enable": true, 33 | "scope": ["**"] 34 | } 35 | } 36 | }, 37 | "bundle": { 38 | "active": true, 39 | "targets": "all", 40 | "icon": [ 41 | "icons/32x32.png", 42 | "icons/128x128.png", 43 | "icons/128x128@2x.png", 44 | "icons/512x512.png", 45 | "icons/icon.icns", 46 | "icons/icon.ico" 47 | ], 48 | "resources": [ 49 | "binaries/go2rtc*", 50 | "binaries/ffmpeg-silent-launcher.exe", 51 | "binaries/ffmpeg-silent.exe", 52 | "binaries/ffmpeg.exe", 53 | "binaries/ffplay.exe", 54 | "resources/gstreamer" 55 | ], 56 | "windows": { 57 | "certificateThumbprint": null, 58 | "digestAlgorithm": "sha256", 59 | "timestampUrl": "", 60 | "webviewInstallMode": { 61 | "type": "downloadBootstrapper" 62 | }, 63 | "nsis": { 64 | "displayLanguageSelector": false 65 | }, 66 | "allowDowngrades": true 67 | }, 68 | "linux": { 69 | "deb": { 70 | "depends": [] 71 | } 72 | }, 73 | "macOS": { 74 | "frameworks": [], 75 | "minimumSystemVersion": "10.13", 76 | "entitlements": null, 77 | "exceptionDomain": "", 78 | "signingIdentity": null, 79 | "providerShortName": null, 80 | "hardenedRuntime": true 81 | } 82 | }, 83 | "plugins": { 84 | "updater": { 85 | "endpoints": [ 86 | "https://github.com/OpenIPC/dashboard/releases/latest/download/latest.json" 87 | ], 88 | "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkxRkY1MkJCM0I2Q0E3MTYKUldRV3Aydzd1MUwva2JMcEdvazc0Nit1ZWVCMmpvOU40SnhNNm8zeGZvK1M1QXNINVdvcjZ5REMK" 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/contexts/LocalizationContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { 4 | LocalizationContext, 5 | type LocalizationContextType, 6 | type SupportedLanguage, 7 | type Translations, 8 | type TranslationValue, 9 | } from './LocalizationContextData'; 10 | import enTranslations from '../locales/en.json'; 11 | import ruTranslations from '../locales/ru.json'; 12 | 13 | const isTranslationObject = ( 14 | value: TranslationValue, 15 | ): value is Exclude => typeof value === 'object' && value !== null; 16 | 17 | const STORAGE_KEY = 'vms_dashboard_language'; 18 | const STATIC_TRANSLATIONS: Record = { 19 | en: enTranslations, 20 | ru: ruTranslations, 21 | }; 22 | 23 | export const LocalizationProvider: React.FC<{ children: ReactNode }> = ({ children }) => { 24 | const [currentLanguage, setCurrentLanguage] = useState(() => { 25 | const saved = localStorage.getItem(STORAGE_KEY); 26 | return (saved as SupportedLanguage) || 'en'; 27 | }); 28 | 29 | const [translations, setTranslations] = useState( 30 | STATIC_TRANSLATIONS[currentLanguage] ?? STATIC_TRANSLATIONS.en, 31 | ); 32 | 33 | // Load translations 34 | useEffect(() => { 35 | const nextTranslations = STATIC_TRANSLATIONS[currentLanguage]; 36 | if (nextTranslations) { 37 | setTranslations(nextTranslations); 38 | } else { 39 | console.warn(`Missing translations for ${currentLanguage}, falling back to English.`); 40 | setTranslations(STATIC_TRANSLATIONS.en); 41 | } 42 | }, [currentLanguage]); 43 | 44 | const setLanguage = (language: SupportedLanguage) => { 45 | setCurrentLanguage(language); 46 | localStorage.setItem(STORAGE_KEY, language); 47 | }; 48 | 49 | // Translation function with parameter substitution 50 | const t = (key: string, params?: Record): string => { 51 | const keys = key.split('.'); 52 | let current: TranslationValue = translations; 53 | 54 | for (const k of keys) { 55 | if (!isTranslationObject(current)) { 56 | return `[${key}]`; 57 | } 58 | 59 | const next: TranslationValue | undefined = current[k]; 60 | if (next === undefined) { 61 | // Return key if translation not found (development mode indicator) 62 | return `[${key}]`; 63 | } 64 | 65 | current = next; 66 | } 67 | 68 | if (typeof current !== 'string') { 69 | return `[${key}]`; 70 | } 71 | 72 | // Replace parameters in the format {{param}} 73 | if (params) { 74 | return current.replace(/\{\{(\w+)\}\}/g, (match, paramKey) => { 75 | return params[paramKey]?.toString() || match; 76 | }); 77 | } 78 | 79 | return current; 80 | }; 81 | 82 | const contextValue: LocalizationContextType = { 83 | currentLanguage, 84 | setLanguage, 85 | t, 86 | translations, 87 | }; 88 | 89 | return ( 90 | 91 | {children} 92 | 93 | ); 94 | }; 95 | 96 | export default LocalizationContext; -------------------------------------------------------------------------------- /src-tauri/python_src/anpr/pytesseract_ocr.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Pytesseract OCR for license plates - command-line interface 4 | Usage: python pytesseract_ocr.py 5 | """ 6 | 7 | import sys 8 | import os 9 | from pathlib import Path 10 | 11 | try: 12 | import pytesseract 13 | from PIL import Image 14 | import cv2 15 | import numpy as np 16 | except ImportError as e: 17 | print(f"ERROR: Missing dependency: {e}", file=sys.stderr) 18 | print("Install with: pip install pytesseract pillow opencv-python", file=sys.stderr) 19 | sys.exit(1) 20 | 21 | # Configure Tesseract path for Windows 22 | pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe' 23 | 24 | # Russian license plate characters: digits + Cyrillic letters 25 | # АВЕКМНОРСТУХ - Cyrillic letters used on Russian plates 26 | PLATE_WHITELIST = "0123456789АВЕКМНОРСТУХ" 27 | 28 | def preprocess_for_ocr(image_path): 29 | """Preprocess image for better OCR - optimized for license plates""" 30 | # Read image 31 | img = cv2.imread(str(image_path)) 32 | if img is None: 33 | raise ValueError(f"Cannot read image: {image_path}") 34 | 35 | # Convert to grayscale 36 | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 37 | 38 | # Slightly increase contrast 39 | gray = cv2.convertScaleAbs(gray, alpha=1.3, beta=10) 40 | 41 | # Slight blur to reduce noise 42 | gray = cv2.GaussianBlur(gray, (3, 3), 0) 43 | 44 | return gray 45 | 46 | def recognize_plate(image_path): 47 | """Recognize license plate text using Pytesseract""" 48 | try: 49 | # Preprocess 50 | processed = preprocess_for_ocr(image_path) 51 | 52 | # Convert to PIL Image 53 | pil_img = Image.fromarray(processed) 54 | 55 | # Configure Tesseract for single-line Russian text 56 | config = ( 57 | '--psm 7 ' # Single text line 58 | '--oem 3 ' # LSTM + Legacy engine 59 | f'-c tessedit_char_whitelist={PLATE_WHITELIST} ' 60 | '-c language_model_penalty_non_dict_word=0.8 ' 61 | '-c language_model_penalty_non_freq_dict_word=0.8' 62 | ) 63 | 64 | # Run OCR with Russian language 65 | text = pytesseract.image_to_string( 66 | pil_img, 67 | lang='rus', 68 | config=config 69 | ) 70 | 71 | # Clean result - keep only alphanumeric 72 | cleaned = ''.join(c for c in text if c.isalnum()).upper() 73 | 74 | return cleaned 75 | 76 | except Exception as e: 77 | print(f"ERROR: {e}", file=sys.stderr) 78 | return "" 79 | 80 | def main(): 81 | if len(sys.argv) != 2: 82 | print("Usage: python pytesseract_ocr.py ", file=sys.stderr) 83 | sys.exit(1) 84 | 85 | image_path = Path(sys.argv[1]) 86 | if not image_path.exists(): 87 | print(f"ERROR: Image not found: {image_path}", file=sys.stderr) 88 | sys.exit(1) 89 | 90 | result = recognize_plate(image_path) 91 | print(result) # Output to stdout 92 | sys.exit(0) 93 | 94 | if __name__ == "__main__": 95 | main() 96 | -------------------------------------------------------------------------------- /docs/anpr/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # ANPR Module - Changelog 2 | 3 | ## [Unreleased] - 2025-11-11 4 | 5 | ### 🎉 Added 6 | - **Python subprocess integration** for ANPR OCR with perspective correction 7 | - `anpr_ocr.py` - Standalone Python OCR script (292 lines) 8 | - `recognize_with_python()` method in Rust CrnnRecognizer 9 | - Automatic fallback to Rust ONNX if Python fails 10 | - `USE_PYTHON_OCR` toggle constant for easy switching 11 | - Comprehensive integration guide (`docs/anpr/INTEGRATION.md`) 12 | - Implementation summary (`docs/anpr/IMPLEMENTATION_SUMMARY.md`) 13 | - Automated test suite (`test_anpr_integration.py`) 14 | - Python dependencies in `requirements.txt` (torch, opencv, numpy) 15 | 16 | ### 🔧 Changed 17 | - Updated `license_plate.rs` to support dual OCR modes (Python + Rust) 18 | - Enhanced error handling with graceful fallback 19 | - Improved subprocess execution with JSON response parsing 20 | 21 | ### 🧹 Removed 22 | - All test scripts: `test_*.py` (7 files) 23 | - Test images: `*.jpg` (10 files) 24 | - Old Rust backups: `lib_*.rs`, `old_lib_*.rs` (6 files) 25 | - Patch files and temporary directories 26 | - Obsolete preprocessing scripts 27 | 28 | ### 📦 Dependencies 29 | - **Added**: `opencv-python>=4.8.0` - Perspective correction 30 | - **Added**: `torch>=2.0.0` - PyTorch CRNN model 31 | - **Added**: `torchvision>=0.15.0` - Image transforms 32 | - **Added**: `numpy>=1.24.0` - Array operations 33 | 34 | ### 🎯 Performance 35 | - Python OCR: ~50-150ms per plate (includes subprocess overhead) 36 | - Rust ONNX fallback: ~5-10ms per plate 37 | - Cache hit rate: High (5-second TTL with IOU matching) 38 | 39 | ### ✅ Testing 40 | - ✅ All integration tests passing (4/4) 41 | - ✅ Rust compilation successful 42 | - ⏳ Awaiting real video validation 43 | 44 | ### 📝 Documentation 45 | - Created `INTEGRATION.md` with full setup guide 46 | - Created `IMPLEMENTATION_SUMMARY.md` with project overview 47 | - Updated main `README.md` with ANPR section 48 | 49 | --- 50 | 51 | ## Implementation Details 52 | 53 | ### Variant 2: Python Subprocess 54 | **Rationale**: Provides best accuracy with proven perspective correction while maintaining production readiness. 55 | 56 | **Architecture**: 57 | ``` 58 | Rust (YOLO detection) 59 | ↓ 60 | Python subprocess (perspective + OCR) 61 | ↓ 62 | Rust ONNX fallback (if Python fails) 63 | ``` 64 | 65 | **Key Features**: 66 | 1. 4-point perspective transform 67 | 2. Otsu binarization 68 | 3. CTC decoding 69 | 4. Latin → Cyrillic transliteration 70 | 5. JSON response format 71 | 6. Graceful error handling 72 | 73 | --- 74 | 75 | ## Future Work 76 | 77 | ### High Priority 78 | - [ ] Test on real RTSP video streams 79 | - [ ] Add subprocess timeout (prevent hangs) 80 | - [ ] Implement result stabilization (voting across frames) 81 | - [ ] Optimize temp file usage (in-memory buffers) 82 | 83 | ### Medium Priority 84 | - [ ] Python sidecar (persistent process) 85 | - [ ] Metrics and logging 86 | - [ ] Better model (fix recognition accuracy) 87 | 88 | ### Low Priority 89 | - [ ] GPU acceleration 90 | - [ ] Model quantization 91 | - [ ] Port perspective correction to Rust 92 | 93 | --- 94 | 95 | **Status**: 🟢 Ready for testing 96 | **Date**: 2025-11-11 97 | **Contributors**: AI Assistant + User 98 | -------------------------------------------------------------------------------- /src/services/rtsp.ts: -------------------------------------------------------------------------------- 1 | import { invoke } from '@tauri-apps/api/core'; 2 | import { isTauriAvailable } from '../utils/tauri'; 3 | 4 | export type RtspTransportPreference = 'tcp' | 'udp'; 5 | 6 | export interface RtspHandshakeOptions { 7 | url: string; 8 | username?: string; 9 | password?: string; 10 | transport?: RtspTransportPreference; 11 | includeAudio?: boolean; 12 | timeoutMs?: number; 13 | } 14 | 15 | interface RawRtspHandshakeTrack { 16 | control_uri: string; 17 | response_headers: Record; 18 | } 19 | 20 | interface RawRtspHandshakeResponse { 21 | base_uri: string; 22 | session?: string; 23 | sdp?: string; 24 | video: RawRtspHandshakeTrack; 25 | audio?: RawRtspHandshakeTrack; 26 | log: string[]; 27 | } 28 | 29 | export interface RtspHandshakeTrack { 30 | controlUri: string; 31 | responseHeaders: Record; 32 | } 33 | 34 | export interface RtspHandshakeResponse { 35 | baseUri: string; 36 | session?: string; 37 | sdp?: string; 38 | video: RtspHandshakeTrack; 39 | audio?: RtspHandshakeTrack; 40 | log: string[]; 41 | } 42 | 43 | const normalizeTrack = (track: RawRtspHandshakeTrack): RtspHandshakeTrack => ({ 44 | controlUri: track.control_uri, 45 | responseHeaders: track.response_headers, 46 | }); 47 | 48 | export const performRtspHandshake = async ( 49 | options: RtspHandshakeOptions, 50 | ): Promise => { 51 | if (!isTauriAvailable()) { 52 | console.warn('[RTSP] Tauri runtime unavailable, skipping handshake'); 53 | return null; 54 | } 55 | 56 | const transport = options.transport ?? 'tcp'; 57 | const includeAudio = options.includeAudio ?? true; 58 | const timeoutMs = options.timeoutMs ?? 3000; 59 | 60 | try { 61 | const raw = await invoke('rtsp_handshake', { 62 | request: { 63 | url: options.url, 64 | username: options.username, 65 | password: options.password, 66 | transport, 67 | include_audio: includeAudio, 68 | timeout_ms: timeoutMs, 69 | }, 70 | }); 71 | 72 | const response: RtspHandshakeResponse = { 73 | baseUri: raw.base_uri, 74 | session: raw.session, 75 | sdp: raw.sdp, 76 | video: normalizeTrack(raw.video), 77 | audio: raw.audio ? normalizeTrack(raw.audio) : undefined, 78 | log: raw.log, 79 | }; 80 | 81 | return response; 82 | } catch (error) { 83 | const message = error instanceof Error ? error.message : String(error); 84 | if (import.meta.env.DEV) { 85 | console.debug('[RTSP] Handshake attempt failed (non-fatal):', message); 86 | } 87 | return null; 88 | } 89 | }; 90 | 91 | export const resolveStreamSource = async (path: string): Promise => { 92 | if (!isTauriAvailable()) { 93 | return null; 94 | } 95 | 96 | try { 97 | const result = await invoke('resolve_stream_source', { path }); 98 | if (typeof result === 'string' && result.length > 0) { 99 | return result; 100 | } 101 | return null; 102 | } catch (error) { 103 | console.warn('[RTSP] Failed to resolve stream source:', error); 104 | return null; 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /src-tauri/examples/test_onvif.rs: -------------------------------------------------------------------------------- 1 | use base64::Engine; 2 | use chrono::Utc; 3 | use rand::Rng; 4 | use reqwest::Client; 5 | use sha1::{Digest, Sha1}; 6 | use std::time::Duration; 7 | 8 | fn create_security_header(user: &str, pass: &str) -> String { 9 | let nonce_raw: [u8; 16] = rand::thread_rng().gen(); 10 | let nonce = base64::engine::general_purpose::STANDARD.encode(&nonce_raw); 11 | let created = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); 12 | 13 | let mut hasher = Sha1::new(); 14 | hasher.update(&nonce_raw); 15 | hasher.update(created.as_bytes()); 16 | hasher.update(pass.as_bytes()); 17 | let password_digest = base64::engine::general_purpose::STANDARD.encode(hasher.finalize()); 18 | 19 | format!( 20 | r#"{}{}{}{}"#, 21 | user, password_digest, nonce, created 22 | ) 23 | } 24 | 25 | #[tokio::main] 26 | async fn main() { 27 | let ip = "192.168.3.11"; 28 | let port = 8899; 29 | let user = "admin"; 30 | let pass = "123456"; 31 | let url = format!("http://{}:{}/onvif/PTZ", ip, port); 32 | 33 | println!("Testing connection to {}", url); 34 | 35 | let client = Client::builder() 36 | .timeout(Duration::from_secs(5)) 37 | .no_proxy() 38 | .http1_only() 39 | .tcp_nodelay(true) 40 | .user_agent("curl/8.14.1") // Mimic curl 41 | .build() 42 | .unwrap(); 43 | 44 | let security_header = create_security_header(user, pass); 45 | 46 | let body = format!( 47 | r#"{}Profile_000PT10S"#, 48 | security_header 49 | ); 50 | 51 | println!("Sending request..."); 52 | let res = client.post(&url) 53 | .header("Content-Type", "application/soap+xml; charset=utf-8; action=\"http://www.onvif.org/ver20/ptz/wsdl/ContinuousMove\"") 54 | .body(body) 55 | .send() 56 | .await; 57 | 58 | match res { 59 | Ok(response) => { 60 | println!("Status: {}", response.status()); 61 | let text = response.text().await.unwrap_or_default(); 62 | println!("Body: {}", text); 63 | } 64 | Err(e) => { 65 | println!("Error: {}", e); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/components/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react'; 2 | import Hls, { type ErrorData } from 'hls.js'; 3 | 4 | interface VideoPlayerProps { 5 | src: string; 6 | autoPlay?: boolean; 7 | controls?: boolean; 8 | muted?: boolean; 9 | width?: string | number; 10 | height?: string | number; 11 | className?: string; 12 | style?: React.CSSProperties; 13 | } 14 | 15 | const VideoPlayer: React.FC = ({ 16 | src, 17 | autoPlay = true, 18 | controls = true, 19 | muted = true, 20 | width = '100%', 21 | height = 'auto', 22 | className = '', 23 | style = {}, 24 | }) => { 25 | const videoRef = useRef(null); 26 | 27 | useEffect(() => { 28 | if (!src) return; 29 | 30 | let hls: Hls | null = null; 31 | const video = videoRef.current; 32 | 33 | if (!video) return; 34 | 35 | const initPlayer = () => { 36 | if (Hls.isSupported()) { 37 | hls = new Hls({ 38 | enableWorker: true, 39 | lowLatencyMode: true, 40 | backBufferLength: 60 41 | }); 42 | 43 | hls.loadSource(src); 44 | hls.attachMedia(video); 45 | 46 | hls.on(Hls.Events.MANIFEST_PARSED, () => { 47 | if (autoPlay) { 48 | video.play().catch(err => { 49 | console.error('Error playing video:', err); 50 | }); 51 | } 52 | }); 53 | 54 | hls.on(Hls.Events.ERROR, (_event, payload) => { 55 | const data = payload as ErrorData; 56 | if (data?.fatal) { 57 | switch (data.type) { 58 | case Hls.ErrorTypes.NETWORK_ERROR: 59 | console.error('Network error', data); 60 | hls?.startLoad(); 61 | break; 62 | case Hls.ErrorTypes.MEDIA_ERROR: 63 | console.error('Media error', data); 64 | hls?.recoverMediaError(); 65 | break; 66 | default: 67 | console.error('Unrecoverable error', data); 68 | hls?.destroy(); 69 | break; 70 | } 71 | } 72 | }); 73 | } else if (video.canPlayType('application/vnd.apple.mpegurl')) { 74 | // For Safari, which has native HLS support 75 | video.src = src; 76 | video.addEventListener('loadedmetadata', () => { 77 | if (autoPlay) { 78 | video.play().catch(err => { 79 | console.error('Error playing video:', err); 80 | }); 81 | } 82 | }); 83 | } 84 | }; 85 | 86 | initPlayer(); 87 | 88 | return () => { 89 | if (hls) { 90 | hls.destroy(); 91 | } 92 | if (video) { 93 | video.removeAttribute('src'); 94 | video.load(); 95 | } 96 | }; 97 | }, [src, autoPlay]); 98 | 99 | return ( 100 |