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 := "" + 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
+ 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
diff --git a/public/app.js b/public/app.js
index 99c4f55..34c71ba 100644
--- a/public/app.js
+++ b/public/app.js
@@ -15,19 +15,29 @@ 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', () => {
- 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.
-