From e8b795503ffcaea3d3ba417400381749fcedcf01 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 5 Aug 2026 11:59:54 +0100 Subject: [PATCH] =?UTF-8?q?chore:=20v0.3.0=20=E2=80=94=20greenfield-first,?= =?UTF-8?q?=20auto-wizard,=20ONVIF=20hybrid=20probe,=20empty=20default=20c?= =?UTF-8?q?onfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.yaml | 101 +-------------------- onvif.go | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++ public/app.js | 117 ++++++++++++++++-------- 3 files changed, 327 insertions(+), 134 deletions(-) create mode 100644 onvif.go diff --git a/config.yaml b/config.yaml index d59496a..bf01732 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,5 @@ # NextNVR Configuration -# Generated by NextNVR setup +# Edit this file or use the web UI (Settings tab) to configure cameras. server: port: ":8080" @@ -11,103 +11,8 @@ storage: cleanup_interval_mins: 60 go2rtc: - enabled: true + enabled: false port: ":1984" binary: "go2rtc" -cameras: - - id: "cam_201" - name: "Camera 201" - ip: "192.168.1.201" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_202" - name: "Camera 202" - ip: "192.168.1.202" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_203" - name: "Camera 203" - ip: "192.168.1.203" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_205" - name: "Camera 205" - ip: "192.168.1.205" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_206" - name: "Camera 206" - ip: "192.168.1.206" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_207" - name: "Camera 207" - ip: "192.168.1.207" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_208" - name: "Camera 208" - ip: "192.168.1.208" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true - - - id: "cam_209" - name: "Camera 209" - ip: "192.168.1.209" - username: "admin" - password: "6HtAW3UkkNxC" - onvif_port: 80 - rtsp_main: "" - rtsp_sub: "" - description: "" - enabled: true - record: true +cameras: [] diff --git a/onvif.go b/onvif.go new file mode 100644 index 0000000..f8cb35b --- /dev/null +++ b/onvif.go @@ -0,0 +1,243 @@ +// NextNVR v0.3.0 — ONVIF device discovery (hybrid approach) +// +// Strategy: +// 1. TCP-probe port 80 — if reachable, try ONVIF GetDeviceInformation. +// 2. TCP-probe port 554 — if reachable, camera supports RTSP. +// 3. Build RTSP URLs from known brand patterns, falling back to generic URLs. +// +// No external dependencies beyond the Go standard library. +package main + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" +) + +// ONVIFDiscovery represents a discovered camera. +type ONVIFDiscovery struct { + IP string `json:"ip"` + Reachable bool `json:"reachable"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Firmware string `json:"firmware"` + Serial string `json:"serial"` + RTSPMain string `json:"rtsp_main"` + RTSPSub string `json:"rtsp_sub"` + ONVIFPort int `json:"onvif_port"` + RTSPPort int `json:"rtsp_port"` +} + +// ScanRequest is the JSON body for /api/scan. +type ScanRequest struct { + Method string `json:"method"` // "range" | "discovery" + From string `json:"from"` // starting IP for range scan + To string `json:"to"` // ending IP for range scan + Username string `json:"username"` // camera credentials + Password string `json:"password"` +} + +// probeDevice attempts to detect a camera at the given IP. +// Returns an ONVIFDiscovery with as much info as it can gather. +func probeDevice(ip, username, password string) ONVIFDiscovery { + d := ONVIFDiscovery{ + IP: ip, + Reachable: false, + ONVIFPort: 80, + RTSPPort: 554, + } + + // 1. Check ONVIF port (80). + onvifOK := tcpProbe(ip, 80, 2*time.Second) + + // 2. Check RTSP port (554). + rtspOK := tcpProbe(ip, 554, 2*time.Second) + + if !onvifOK && !rtspOK { + return d + } + d.Reachable = true + + // 3. Try ONVIF GetDeviceInformation if ONVIF port is open. + if onvifOK { + info, err := getDeviceInfo(ip) + if err == nil { + d.Manufacturer = info.Manufacturer + d.Model = info.Model + d.Firmware = info.FirmwareVersion + d.Serial = info.SerialNumber + } + } + + // 4. Build RTSP URLs from known patterns or generic fallback. + if rtspOK { + d.RTSPMain, d.RTSPSub = buildRTSPURLs(ip, username, password, d.Manufacturer) + } + + return d +} + +// tcpProbe checks if a TCP port is open on the given IP. +func tcpProbe(ip string, port int, timeout time.Duration) bool { + addr := net.JoinHostPort(ip, itoa(port)) + conn, err := net.DialTimeout("tcp", addr, timeout) + if err != nil { + return false + } + conn.Close() + return true +} + +// ── Minimal ONVIF SOAP Client ── + +const ( + onvifDeviceService = "/onvif/device_service" + onvifXMLNS = "http://www.onvif.org/ver10/device/wsdl" + onvifSchema = "http://www.w3.org/2003/05/soap-envelope" + onvifAddrSpace = "http://www.onvif.org/ver10/device/wsdl" +) + +// getDeviceInfo sends a GetDeviceInformation SOAP request to the camera. +func getDeviceInfo(ip string) (OnvifDeviceInfo, error) { + body := fmt.Sprintf(` + + + + +`, onvifSchema, onvifAddrSpace) + + url := "http://" + ip + onvifDeviceService + req, err := http.NewRequest("POST", url, bytes.NewBufferString(body)) + if err != nil { + return OnvifDeviceInfo{}, err + } + req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return OnvifDeviceInfo{}, err + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + return parseDeviceInfo(respBody) +} + +// OnvifDeviceInfo holds the parsed GetDeviceInformation response. +type OnvifDeviceInfo struct { + Manufacturer string + Model string + FirmwareVersion string + SerialNumber string + HardwareID string +} + +// parseDeviceInfo extracts fields from the SOAP XML response. +func parseDeviceInfo(data []byte) (OnvifDeviceInfo, error) { + var info OnvifDeviceInfo + + // Simple XML string extraction — avoids importing encoding/xml for complex SOAP. + info.Manufacturer = extractXMLTag(data, "Manufacturer") + info.Model = extractXMLTag(data, "Model") + info.FirmwareVersion = extractXMLTag(data, "FirmwareVersion") + info.SerialNumber = extractXMLTag(data, "SerialNumber") + info.HardwareID = extractXMLTag(data, "HardwareId") + + if info.Manufacturer == "" && info.Model == "" { + return info, fmt.Errorf("no device info found") + } + return info, nil +} + +// extractXMLTag extracts the content between and . +func extractXMLTag(data []byte, tag string) string { + open := "<" + tag + ">" + close := "" + + start := bytes.Index(data, []byte(open)) + if start < 0 { + // Try with namespace prefix. + start = bytes.Index(data, []byte(":"+tag+">")) + if start < 0 { + return "" + } + // Find the actual tag content after the namespace prefix. + start = bytes.Index(data[start:], []byte(">")) + start + 1 + end := bytes.Index(data[start:], []byte(close)) + if end < 0 { + // Try + endClose := bytes.Index(data[start:], []byte(" { renderLiveGrid(); renderPlaybackCameras(); renderCameraCards(); + + // First-run experience: auto-switch to Settings if no cameras configured. + if (cameras.length === 0) { + switchTab('settings'); + } }); // ── Tabs ── function setupTabs() { document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { - document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); - document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - btn.classList.add('active'); - document.getElementById('tab-' + btn.dataset.tab).classList.add('active'); + switchTab(btn.dataset.tab); }); }); } +function switchTab(tabName) { + document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); + document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); + const tabBtn = document.querySelector(`.tab[data-tab="${tabName}"]`); + if (tabBtn) tabBtn.classList.add('active'); + const panel = document.getElementById('tab-' + tabName); + if (panel) panel.classList.add('active'); +} // ── Status ── async function loadStatus() { @@ -189,11 +199,30 @@ async function renderCameraCards() { const container = document.getElementById('camera-cards'); if (cameras.length === 0) { container.innerHTML = ` -
-

🎥 Welcome to NextNVR

-

No cameras configured yet. Scan your network to get started.

-
`; return; @@ -216,40 +245,56 @@ async function renderCameraCards() { } // ── Settings: Scan ── -document.getElementById('btn-scan').addEventListener('click', async () => { - const btn = document.getElementById('btn-scan'); +async function runWizard() { + const from = document.getElementById('wiz-from')?.value || '192.168.1.200'; + const to = document.getElementById('wiz-to')?.value || '192.168.1.210'; + const user = document.getElementById('wiz-user')?.value || 'admin'; + const pass = document.getElementById('wiz-pass')?.value || ''; const status = document.getElementById('settings-status'); - btn.disabled = true; - btn.textContent = '⏳ Scanning...'; - status.textContent = 'Probing 192.168.1.201–209...'; + status.textContent = '⏳ Scanning ' + from + ' to ' + to + '...'; + try { - const r = await fetch(API + '/scan', { method: 'POST' }); + const r = await fetch(API + '/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: 'range', from, to, username: user, password: pass }) + }); const j = await r.json(); if (j.success) { - status.textContent = `Found ${j.data.length} devices. Fill in names and save.`; - // Pre-fill camera cards with discovered data. - cameras = j.data.map((d, i) => ({ - id: 'cam_' + d.ip.split('.').pop(), - name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i), - ip: d.ip, - username: 'admin', password: '', - onvif_port: 80, - rtsp_main: d.rtsp_main || '', - rtsp_sub: d.rtsp_sub || '', - description: '', - enabled: true, record: true, - online: d.found - })); - renderCameraCards(); - renderLiveGrid(); - renderPlaybackCameras(); + const found = j.data.filter(d => d.reachable); + status.textContent = 'Found ' + found.length + ' cameras.'; + if (found.length > 0) { + cameras = found.map(d => ({ + id: 'cam_' + d.ip.split('.').pop(), + name: (d.manufacturer || 'Camera') + ' ' + (d.model || ''), + ip: d.ip, + username: user, + password: pass, + onvif_port: d.onvif_port || 80, + rtsp_main: d.rtsp_main || '', + rtsp_sub: d.rtsp_sub || '', + description: '', + enabled: true, + record: true, + online: d.reachable + })); + renderCameraCards(); + renderLiveGrid(); + renderPlaybackCameras(); + status.textContent = '✅ Found ' + found.length + ' cameras. Name them and click Save.'; + status.style.color = 'var(--green)'; + } else { + status.textContent = '❌ No cameras found in that range.'; + status.style.color = 'var(--red)'; + } } } catch(e) { - status.textContent = 'Scan failed: ' + e.message; + status.textContent = '❌ Scan failed: ' + e.message; + status.style.color = 'var(--red)'; } - btn.disabled = false; - btn.textContent = '🔍 Scan Network (ONVIF)'; -}); +} + +document.getElementById('btn-scan').addEventListener('click', runWizard); // ── Settings: Save ── document.getElementById('btn-save').addEventListener('click', async () => {