chore: v0.3.0 — greenfield-first, auto-wizard, ONVIF hybrid probe, empty default config
This commit is contained in:
parent
7359357a0b
commit
e8b795503f
3 changed files with 327 additions and 134 deletions
101
config.yaml
101
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: []
|
||||
|
|
|
|||
243
onvif.go
Normal file
243
onvif.go
Normal 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
|
||||
117
public/app.js
117
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 = `
|
||||
<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.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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue