chore: v0.3.0 — real ONVIF TCP probe in /api/scan, greenfield wizard with IP range

This commit is contained in:
Claus Lohmar 2026-08-05 12:03:52 +01:00
parent e8b795503f
commit e94d49253e

66
api.go
View file

@ -4,6 +4,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@ -119,14 +120,50 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
// handleScan triggers ONVIF network discovery. // handleScan triggers ONVIF network discovery.
// POST /api/scan // POST /api/scan
// Body: {"method":"range","from":"192.168.1.200","to":"192.168.1.210","username":"admin","password":"..."}
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) { func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"}) jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"})
return return
} }
// TODO: M3 — real ONVIF WS-Discovery. var req ScanRequest
// For now, return a stub response with the expected IP range. if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Fall back to legacy stub scan if no body provided.
s.legacyScan(w)
return
}
if req.From == "" || req.To == "" {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "from and to IP addresses required"})
return
}
fromIP := net.ParseIP(req.From)
toIP := net.ParseIP(req.To)
if fromIP == nil || toIP == nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid IP address"})
return
}
results := make([]ONVIFDiscovery, 0)
// Inclusive scan from fromIP to toIP.
ip := make(net.IP, len(fromIP))
copy(ip, fromIP)
for {
d := probeDevice(ip.String(), req.Username, req.Password)
results = append(results, d)
if ip.Equal(toIP) {
break
}
ip = nextIP(ip)
}
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
}
// legacyScan is the old stub handler for backward compatibility.
func (s *Server) legacyScan(w http.ResponseWriter) {
type DiscoveredCamera struct { type DiscoveredCamera struct {
IP string `json:"ip"` IP string `json:"ip"`
Manufacturer string `json:"manufacturer"` Manufacturer string `json:"manufacturer"`
@ -135,7 +172,6 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
RTSPSub string `json:"rtsp_sub"` RTSPSub string `json:"rtsp_sub"`
Found bool `json:"found"` Found bool `json:"found"`
} }
results := make([]DiscoveredCamera, 0) results := make([]DiscoveredCamera, 0)
for ip := 201; ip <= 209; ip++ { for ip := 201; ip <= 209; ip++ {
if ip == 204 { if ip == 204 {
@ -143,18 +179,28 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
} }
ipStr := "192.168.1." + itoa(ip) ipStr := "192.168.1." + itoa(ip)
results = append(results, DiscoveredCamera{ results = append(results, DiscoveredCamera{
IP: ipStr, IP: ipStr,
Manufacturer: "Unknown", Found: false,
Model: "ONVIF Camera", RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101",
RTSPMain: "rtsp://" + ipStr + ":554/Streaming/Channels/101", RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102",
RTSPSub: "rtsp://" + ipStr + ":554/Streaming/Channels/102",
Found: false, // requires real ONVIF probe in M3
}) })
} }
jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results}) jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: results})
} }
// nextIP returns the next IP address in sequence.
func nextIP(ip net.IP) net.IP {
next := make(net.IP, len(ip))
copy(next, ip)
for i := len(next) - 1; i >= 0; i-- {
next[i]++
if next[i] != 0 {
break
}
}
return next
}
// handleStatus returns server health and runtime information. // handleStatus returns server health and runtime information.
// GET /api/status // GET /api/status
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {