chore: v0.3.0 — greenfield-first, auto-wizard, ONVIF hybrid probe, empty default config

This commit is contained in:
Claus Lohmar 2026-08-05 11:59:54 +01:00
parent 7359357a0b
commit e8b795503f
3 changed files with 327 additions and 134 deletions

View file

@ -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: []

243
onvif.go Normal file
View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="%s">
<s:Body>
<GetDeviceInformation xmlns="%s"/>
</s:Body>
</s:Envelope>`, 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 <tag> and </tag>.
func extractXMLTag(data []byte, tag string) string {
open := "<" + tag + ">"
close := "</" + tag + ">"
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 </ns:tag>
endClose := bytes.Index(data[start:], []byte("</"))
if endClose < 0 {
return ""
}
return string(data[start : start+endClose])
}
return string(data[start : start+end])
}
start += len(open)
end := bytes.Index(data[start:], []byte(close))
if end < 0 {
return ""
}
return string(data[start : start+end])
}
// ── RTSP URL Construction ──
// rtspPatterns maps manufacturer names to known RTSP URL patterns.
// {user}, {pass}, {ip}, {port} are substituted at runtime.
var rtspPatterns = map[string]struct{ main, sub string }{
"hikvision": {
main: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/101",
sub: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/102",
},
"dahua": {
main: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=0",
sub: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=1",
},
"axis": {
main: "rtsp://{user}:{pass}@{ip}:{port}/axis-media/media.amp",
sub: "rtsp://{user}:{pass}@{ip}:{port}/axis-media/media.amp?videocodec=h264&resolution=640x480",
},
"reolink": {
main: "rtsp://{user}:{pass}@{ip}:{port}/h264Preview_01_main",
sub: "rtsp://{user}:{pass}@{ip}:{port}/h264Preview_01_sub",
},
"amcrest": {
main: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=0",
sub: "rtsp://{user}:{pass}@{ip}:{port}/cam/realmonitor?channel=1&subtype=1",
},
}
// generic pattern used when manufacturer is unknown.
var genericRTSP = struct{ main, sub string }{
main: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/101",
sub: "rtsp://{user}:{pass}@{ip}:{port}/Streaming/Channels/102",
}
// buildRTSPURLs returns main and sub stream RTSP URLs for a camera.
func buildRTSPURLs(ip, user, pass, manufacturer string) (string, string) {
patterns, ok := rtspPatterns[strings.ToLower(manufacturer)]
if !ok {
patterns = genericRTSP
}
replacer := strings.NewReplacer(
"{user}", user,
"{pass}", pass,
"{ip}", ip,
"{port}", "554",
)
return replacer.Replace(patterns.main), replacer.Replace(patterns.sub)
}
// suppress unused import warning for encoding/xml.
var _ = xml.Unmarshal

View file

@ -15,18 +15,28 @@ document.addEventListener('DOMContentLoaded', async () => {
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', () => {
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'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).classList.add('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 ──
@ -189,11 +199,30 @@ async function renderCameraCards() {
const container = document.getElementById('camera-cards');
if (cameras.length === 0) {
container.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
<p style="font-size:18px">🎥 Welcome to NextNVR</p>
<p style="margin-top:8px">No cameras configured yet. Scan your network to get started.</p>
<button class="btn-primary" style="margin-top:16px" onclick="document.getElementById('btn-scan').click()">
🔍 Scan Network (ONVIF)
<div style="grid-column:1/-1;text-align:center;padding:60px 20px;color:var(--text-muted)">
<p style="font-size:48px;margin-bottom:12px">🎥</p>
<p style="font-size:20px;font-weight:600;color:var(--text)">Welcome to NextNVR</p>
<p style="margin-top:8px;max-width:500px;margin-left:auto;margin-right:auto">
No cameras configured yet. Enter your camera's IP range and credentials to scan your network.
</p>
<div style="margin-top:24px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<label style="color:var(--text-muted);font-size:13px">IP Range:
<input type="text" id="wiz-from" value="192.168.1.200" style="width:130px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
<label style="color:var(--text-muted);font-size:13px">to
<input type="text" id="wiz-to" value="192.168.1.210" style="width:130px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
</div>
<div style="margin-top:12px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<label style="color:var(--text-muted);font-size:13px">Username:
<input type="text" id="wiz-user" value="admin" style="width:120px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
<label style="color:var(--text-muted);font-size:13px">Password:
<input type="password" id="wiz-pass" value="" style="width:120px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
</div>
<button class="btn-primary" style="margin-top:20px;font-size:16px;padding:10px 32px" onclick="runWizard()">
🔍 Scan Network
</button>
</div>`;
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.201209...';
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) => ({
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.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i),
name: (d.manufacturer || 'Camera') + ' ' + (d.model || ''),
ip: d.ip,
username: 'admin', password: '',
onvif_port: 80,
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.found
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 () => {