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
|
# NextNVR Configuration
|
||||||
# Generated by NextNVR setup
|
# Edit this file or use the web UI (Settings tab) to configure cameras.
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: ":8080"
|
port: ":8080"
|
||||||
|
|
@ -11,103 +11,8 @@ storage:
|
||||||
cleanup_interval_mins: 60
|
cleanup_interval_mins: 60
|
||||||
|
|
||||||
go2rtc:
|
go2rtc:
|
||||||
enabled: true
|
enabled: false
|
||||||
port: ":1984"
|
port: ":1984"
|
||||||
binary: "go2rtc"
|
binary: "go2rtc"
|
||||||
|
|
||||||
cameras:
|
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
|
|
||||||
|
|
|
||||||
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();
|
renderLiveGrid();
|
||||||
renderPlaybackCameras();
|
renderPlaybackCameras();
|
||||||
renderCameraCards();
|
renderCameraCards();
|
||||||
|
|
||||||
|
// First-run experience: auto-switch to Settings if no cameras configured.
|
||||||
|
if (cameras.length === 0) {
|
||||||
|
switchTab('settings');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Tabs ──
|
// ── Tabs ──
|
||||||
function setupTabs() {
|
function setupTabs() {
|
||||||
document.querySelectorAll('.tab').forEach(btn => {
|
document.querySelectorAll('.tab').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
|
switchTab(btn.dataset.tab);
|
||||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
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 ──
|
// ── Status ──
|
||||||
async function loadStatus() {
|
async function loadStatus() {
|
||||||
|
|
@ -189,11 +199,30 @@ async function renderCameraCards() {
|
||||||
const container = document.getElementById('camera-cards');
|
const container = document.getElementById('camera-cards');
|
||||||
if (cameras.length === 0) {
|
if (cameras.length === 0) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
|
<div style="grid-column:1/-1;text-align:center;padding:60px 20px;color:var(--text-muted)">
|
||||||
<p style="font-size:18px">🎥 Welcome to NextNVR</p>
|
<p style="font-size:48px;margin-bottom:12px">🎥</p>
|
||||||
<p style="margin-top:8px">No cameras configured yet. Scan your network to get started.</p>
|
<p style="font-size:20px;font-weight:600;color:var(--text)">Welcome to NextNVR</p>
|
||||||
<button class="btn-primary" style="margin-top:16px" onclick="document.getElementById('btn-scan').click()">
|
<p style="margin-top:8px;max-width:500px;margin-left:auto;margin-right:auto">
|
||||||
🔍 Scan Network (ONVIF)
|
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>
|
</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
|
|
@ -216,40 +245,56 @@ async function renderCameraCards() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Settings: Scan ──
|
// ── Settings: Scan ──
|
||||||
document.getElementById('btn-scan').addEventListener('click', async () => {
|
async function runWizard() {
|
||||||
const btn = document.getElementById('btn-scan');
|
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');
|
const status = document.getElementById('settings-status');
|
||||||
btn.disabled = true;
|
status.textContent = '⏳ Scanning ' + from + ' to ' + to + '...';
|
||||||
btn.textContent = '⏳ Scanning...';
|
|
||||||
status.textContent = 'Probing 192.168.1.201–209...';
|
|
||||||
try {
|
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();
|
const j = await r.json();
|
||||||
if (j.success) {
|
if (j.success) {
|
||||||
status.textContent = `Found ${j.data.length} devices. Fill in names and save.`;
|
const found = j.data.filter(d => d.reachable);
|
||||||
// Pre-fill camera cards with discovered data.
|
status.textContent = 'Found ' + found.length + ' cameras.';
|
||||||
cameras = j.data.map((d, i) => ({
|
if (found.length > 0) {
|
||||||
id: 'cam_' + d.ip.split('.').pop(),
|
cameras = found.map(d => ({
|
||||||
name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i),
|
id: 'cam_' + d.ip.split('.').pop(),
|
||||||
ip: d.ip,
|
name: (d.manufacturer || 'Camera') + ' ' + (d.model || ''),
|
||||||
username: 'admin', password: '',
|
ip: d.ip,
|
||||||
onvif_port: 80,
|
username: user,
|
||||||
rtsp_main: d.rtsp_main || '',
|
password: pass,
|
||||||
rtsp_sub: d.rtsp_sub || '',
|
onvif_port: d.onvif_port || 80,
|
||||||
description: '',
|
rtsp_main: d.rtsp_main || '',
|
||||||
enabled: true, record: true,
|
rtsp_sub: d.rtsp_sub || '',
|
||||||
online: d.found
|
description: '',
|
||||||
}));
|
enabled: true,
|
||||||
renderCameraCards();
|
record: true,
|
||||||
renderLiveGrid();
|
online: d.reachable
|
||||||
renderPlaybackCameras();
|
}));
|
||||||
|
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) {
|
} 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 ──
|
// ── Settings: Save ──
|
||||||
document.getElementById('btn-save').addEventListener('click', async () => {
|
document.getElementById('btn-save').addEventListener('click', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue