387f252b9d
- Update module path from github.com/strix-project/strix to github.com/eduard256/Strix - Update all Go imports to use new repository path - Update documentation links in README.md and CHANGELOG.md - Update GitHub URLs in .goreleaser.yaml - Fix placeholder documentation URL in DATABASE_FORMAT.md - Remove old log files
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package discovery
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/IOTechSystems/onvif"
|
|
"github.com/IOTechSystems/onvif/media"
|
|
xsdonvif "github.com/IOTechSystems/onvif/xsd/onvif"
|
|
"github.com/eduard256/Strix/internal/models"
|
|
)
|
|
|
|
// ONVIFDiscovery handles ONVIF device discovery and stream detection
|
|
type ONVIFDiscovery struct {
|
|
logger interface{ Debug(string, ...any); Error(string, error, ...any) }
|
|
}
|
|
|
|
// NewONVIFDiscovery creates a new ONVIF discovery instance
|
|
func NewONVIFDiscovery(logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *ONVIFDiscovery {
|
|
return &ONVIFDiscovery{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// DiscoverStreamsForIP discovers all possible streams for a given IP
|
|
func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username, password string) ([]models.DiscoveredStream, error) {
|
|
o.logger.Debug("=== ONVIF DiscoverStreamsForIP STARTED ===",
|
|
"ip", ip,
|
|
"username", username,
|
|
"password_len", len(password))
|
|
|
|
// Clean IP (remove port if present)
|
|
if idx := strings.IndexByte(ip, ':'); idx > 0 {
|
|
o.logger.Debug("cleaning IP address", "original", ip, "cleaned", ip[:idx])
|
|
ip = ip[:idx]
|
|
}
|
|
|
|
var allStreams []models.DiscoveredStream
|
|
|
|
// Try real ONVIF discovery first
|
|
o.logger.Debug(">>> Starting ONVIF device discovery", "ip", ip)
|
|
onvifStreams := o.discoverViaONVIF(ctx, ip, username, password)
|
|
o.logger.Debug("<<< ONVIF device discovery completed", "streams_found", len(onvifStreams))
|
|
|
|
if len(onvifStreams) > 0 {
|
|
o.logger.Debug("ONVIF streams details:")
|
|
for i, stream := range onvifStreams {
|
|
o.logger.Debug(" ONVIF stream found",
|
|
"index", i,
|
|
"url", stream.URL,
|
|
"protocol", stream.Protocol,
|
|
"port", stream.Port,
|
|
"type", stream.Type)
|
|
}
|
|
}
|
|
allStreams = append(allStreams, onvifStreams...)
|
|
|
|
// Add common RTSP streams
|
|
o.logger.Debug(">>> Adding common RTSP streams", "ip", ip)
|
|
commonStreams := o.getCommonRTSPStreams(ip, username, password)
|
|
o.logger.Debug("<<< Common RTSP streams added", "count", len(commonStreams))
|
|
allStreams = append(allStreams, commonStreams...)
|
|
|
|
o.logger.Debug("=== ONVIF DiscoverStreamsForIP COMPLETED ===",
|
|
"onvif_streams", len(onvifStreams),
|
|
"common_streams", len(commonStreams),
|
|
"total_streams", len(allStreams))
|
|
|
|
return allStreams, nil
|
|
}
|
|
|
|
// discoverViaONVIF performs real ONVIF discovery
|
|
func (o *ONVIFDiscovery) discoverViaONVIF(ctx context.Context, ip, username, password string) []models.DiscoveredStream {
|
|
o.logger.Debug(">>> discoverViaONVIF STARTED", "ip", ip)
|
|
var streams []models.DiscoveredStream
|
|
|
|
// Try standard ONVIF ports
|
|
ports := []int{80, 8080, 8000}
|
|
o.logger.Debug("Will try ONVIF ports", "ports", ports)
|
|
|
|
for portIdx, port := range ports {
|
|
o.logger.Debug("--- Trying ONVIF port ---",
|
|
"port_index", portIdx+1,
|
|
"total_ports", len(ports),
|
|
"port", port)
|
|
|
|
// Create timeout context for ONVIF connection
|
|
onvifCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
xaddr := fmt.Sprintf("%s:%d", ip, port)
|
|
o.logger.Debug("Creating ONVIF device",
|
|
"xaddr", xaddr,
|
|
"username", username,
|
|
"has_password", password != "")
|
|
|
|
// Create ONVIF device
|
|
startTime := time.Now()
|
|
dev, err := onvif.NewDevice(onvif.DeviceParams{
|
|
Xaddr: xaddr,
|
|
Username: username,
|
|
Password: password,
|
|
})
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ ONVIF device creation FAILED",
|
|
"xaddr", xaddr,
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
continue
|
|
}
|
|
|
|
o.logger.Debug("✅ ONVIF device created successfully",
|
|
"xaddr", xaddr,
|
|
"elapsed", elapsed.String())
|
|
|
|
// Try to get profiles with context
|
|
o.logger.Debug("Getting media profiles...", "xaddr", xaddr)
|
|
profileStreams := o.getProfileStreams(onvifCtx, dev, ip)
|
|
|
|
if len(profileStreams) > 0 {
|
|
// Add ONVIF device service endpoint
|
|
deviceServiceURL := fmt.Sprintf("http://%s/onvif/device_service", xaddr)
|
|
|
|
// Embed credentials in URL if provided
|
|
if username != "" && password != "" {
|
|
u, err := url.Parse(deviceServiceURL)
|
|
if err == nil {
|
|
u.User = url.UserPassword(username, password)
|
|
deviceServiceURL = u.String()
|
|
}
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: deviceServiceURL,
|
|
Type: "ONVIF",
|
|
Protocol: "http",
|
|
Port: port,
|
|
Working: true, // Mark as working since ONVIF connection succeeded
|
|
Metadata: map[string]interface{}{
|
|
"source": "onvif",
|
|
"description": "ONVIF Device Service - used for PTZ control and device management",
|
|
},
|
|
})
|
|
|
|
// Add profile streams
|
|
streams = append(streams, profileStreams...)
|
|
|
|
o.logger.Debug("🎉 ONVIF discovery SUCCESSFUL!",
|
|
"xaddr", xaddr,
|
|
"device_service", deviceServiceURL,
|
|
"profiles_found", len(profileStreams))
|
|
|
|
// Log device service
|
|
o.logger.Debug(" Device Service",
|
|
"url", deviceServiceURL)
|
|
|
|
// Log each profile
|
|
for i, stream := range profileStreams {
|
|
o.logger.Debug(" Profile stream",
|
|
"index", i+1,
|
|
"url", stream.URL,
|
|
"metadata", stream.Metadata)
|
|
}
|
|
break // Found working port, stop trying
|
|
} else {
|
|
o.logger.Debug("⚠️ No profiles returned from port", "xaddr", xaddr)
|
|
}
|
|
}
|
|
|
|
o.logger.Debug("<<< discoverViaONVIF COMPLETED",
|
|
"total_streams_found", len(streams))
|
|
|
|
return streams
|
|
}
|
|
|
|
// getProfileStreams gets stream URIs from media profiles
|
|
func (o *ONVIFDiscovery) getProfileStreams(ctx context.Context, dev *onvif.Device, ip string) []models.DiscoveredStream {
|
|
o.logger.Debug(">>> getProfileStreams STARTED", "ip", ip)
|
|
var streams []models.DiscoveredStream
|
|
|
|
// Get media profiles
|
|
o.logger.Debug("Calling GetProfiles ONVIF method...")
|
|
getProfilesReq := media.GetProfiles{}
|
|
startTime := time.Now()
|
|
profilesResp, err := dev.CallMethod(getProfilesReq)
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to call GetProfiles",
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
return streams
|
|
}
|
|
defer profilesResp.Body.Close()
|
|
|
|
o.logger.Debug("✅ GetProfiles call successful",
|
|
"elapsed", elapsed.String(),
|
|
"status_code", profilesResp.StatusCode)
|
|
|
|
// Read and parse XML response
|
|
o.logger.Debug("Reading response body...")
|
|
body, err := io.ReadAll(profilesResp.Body)
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to read profiles response",
|
|
"error", err.Error())
|
|
return streams
|
|
}
|
|
|
|
o.logger.Debug("Response body read",
|
|
"body_length", len(body),
|
|
"body_preview", string(body[:min(200, len(body))]))
|
|
|
|
// Parse SOAP envelope
|
|
o.logger.Debug("Parsing SOAP envelope...")
|
|
var envelope struct {
|
|
XMLName xml.Name `xml:"Envelope"`
|
|
Body struct {
|
|
GetProfilesResponse media.GetProfilesResponse `xml:"GetProfilesResponse"`
|
|
} `xml:"Body"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(body, &envelope); err != nil {
|
|
o.logger.Debug("❌ Failed to parse profiles response",
|
|
"error", err.Error())
|
|
return streams
|
|
}
|
|
|
|
profileCount := len(envelope.Body.GetProfilesResponse.Profiles)
|
|
o.logger.Debug("✅ SOAP envelope parsed successfully",
|
|
"profiles_count", profileCount)
|
|
|
|
// Get stream URI for each profile
|
|
for i, profile := range envelope.Body.GetProfilesResponse.Profiles {
|
|
o.logger.Debug("Processing profile",
|
|
"index", i+1,
|
|
"total", profileCount,
|
|
"token", string(profile.Token),
|
|
"name", string(profile.Name))
|
|
|
|
streamURI := o.getStreamURI(dev, string(profile.Token))
|
|
if streamURI != "" {
|
|
o.logger.Debug("✅ Got stream URI for profile",
|
|
"profile_token", string(profile.Token),
|
|
"stream_uri", streamURI)
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURI,
|
|
Type: "FFMPEG",
|
|
Protocol: "rtsp",
|
|
Port: 554,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "onvif",
|
|
"profile_token": string(profile.Token),
|
|
"profile_name": string(profile.Name),
|
|
},
|
|
})
|
|
} else {
|
|
o.logger.Debug("⚠️ Failed to get stream URI for profile",
|
|
"profile_token", string(profile.Token))
|
|
}
|
|
}
|
|
|
|
o.logger.Debug("<<< getProfileStreams COMPLETED",
|
|
"streams_collected", len(streams))
|
|
|
|
return streams
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// getStreamURI retrieves stream URI for a profile
|
|
func (o *ONVIFDiscovery) getStreamURI(dev *onvif.Device, profileToken string) string {
|
|
o.logger.Debug(">>> getStreamURI STARTED", "profile_token", profileToken)
|
|
|
|
stream := xsdonvif.StreamType("RTP-Unicast")
|
|
protocol := xsdonvif.TransportProtocol("RTSP")
|
|
token := xsdonvif.ReferenceToken(profileToken)
|
|
|
|
getStreamURIReq := media.GetStreamUri{
|
|
ProfileToken: &token,
|
|
StreamSetup: &xsdonvif.StreamSetup{
|
|
Stream: &stream,
|
|
Transport: &xsdonvif.Transport{
|
|
Protocol: &protocol,
|
|
},
|
|
},
|
|
}
|
|
|
|
o.logger.Debug("Calling GetStreamUri ONVIF method...", "profile_token", profileToken)
|
|
startTime := time.Now()
|
|
resp, err := dev.CallMethod(getStreamURIReq)
|
|
elapsed := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to get stream URI",
|
|
"profile", profileToken,
|
|
"error", err.Error(),
|
|
"elapsed", elapsed.String())
|
|
return ""
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
o.logger.Debug("✅ GetStreamUri call successful",
|
|
"profile", profileToken,
|
|
"elapsed", elapsed.String(),
|
|
"status_code", resp.StatusCode)
|
|
|
|
// Read and parse XML response
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
o.logger.Debug("❌ Failed to read stream URI response",
|
|
"error", err.Error())
|
|
return ""
|
|
}
|
|
|
|
o.logger.Debug("Response body read",
|
|
"body_length", len(body),
|
|
"body_preview", string(body[:min(200, len(body))]))
|
|
|
|
// Parse SOAP envelope
|
|
var envelope struct {
|
|
XMLName xml.Name `xml:"Envelope"`
|
|
Body struct {
|
|
GetStreamUriResponse media.GetStreamUriResponse `xml:"GetStreamUriResponse"`
|
|
} `xml:"Body"`
|
|
}
|
|
|
|
if err := xml.Unmarshal(body, &envelope); err != nil {
|
|
o.logger.Debug("❌ Failed to parse stream URI response",
|
|
"error", err.Error())
|
|
return ""
|
|
}
|
|
|
|
streamURI := string(envelope.Body.GetStreamUriResponse.MediaUri.Uri)
|
|
o.logger.Debug("<<< getStreamURI COMPLETED",
|
|
"stream_uri", streamURI)
|
|
|
|
return streamURI
|
|
}
|
|
|
|
// getCommonRTSPStreams returns common RTSP stream URLs
|
|
func (o *ONVIFDiscovery) getCommonRTSPStreams(ip, username, password string) []models.DiscoveredStream {
|
|
// Common RTSP paths that work with many cameras
|
|
commonPaths := []struct {
|
|
path string
|
|
notes string
|
|
}{
|
|
{"/stream1", "Common main stream"},
|
|
{"/stream2", "Common sub stream"},
|
|
{"/ch0", "Thingino main"},
|
|
{"/ch1", "Thingino sub"},
|
|
{"/live/main", "ONVIF standard main"},
|
|
{"/live/sub", "ONVIF standard sub"},
|
|
{"/Streaming/Channels/101", "Hikvision main"},
|
|
{"/Streaming/Channels/102", "Hikvision sub"},
|
|
{"/cam/realmonitor?channel=1&subtype=0", "Dahua main"},
|
|
{"/cam/realmonitor?channel=1&subtype=1", "Dahua sub"},
|
|
{"/h264/main", "Generic H264 main"},
|
|
{"/h264/sub", "Generic H264 sub"},
|
|
{"/media/video1", "Axis main"},
|
|
{"/media/video2", "Axis sub"},
|
|
{"/videoMain", "Foscam main"},
|
|
{"/videoSub", "Foscam sub"},
|
|
{"/11", "Simple numeric main"},
|
|
{"/12", "Simple numeric sub"},
|
|
{"/user=admin_password=tlJwpbo6_channel=1_stream=0.sdp", "Dahua alternative"},
|
|
{"/live.sdp", "Generic live"},
|
|
{"/stream", "Generic stream"},
|
|
{"/video.h264", "Generic H264"},
|
|
{"/live/0/MAIN", "Alternative main"},
|
|
{"/live/0/SUB", "Alternative sub"},
|
|
{"/MediaInput/h264", "Alternative H264"},
|
|
{"/0/video0", "Alternative video0"},
|
|
{"/0/video1", "Alternative video1"},
|
|
}
|
|
|
|
var streams []models.DiscoveredStream
|
|
|
|
for _, cp := range commonPaths {
|
|
var streamURL string
|
|
if username != "" && password != "" {
|
|
streamURL = fmt.Sprintf("rtsp://%s:%s@%s:554%s", url.QueryEscape(username), url.QueryEscape(password), ip, cp.path)
|
|
} else {
|
|
streamURL = fmt.Sprintf("rtsp://%s:554%s", ip, cp.path)
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURL,
|
|
Type: "FFMPEG",
|
|
Protocol: "rtsp",
|
|
Port: 554,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "common",
|
|
"notes": cp.notes,
|
|
},
|
|
})
|
|
}
|
|
|
|
// Add some HTTP snapshot URLs too
|
|
httpPaths := []struct {
|
|
path string
|
|
notes string
|
|
}{
|
|
{"/snapshot.jpg", "Common snapshot"},
|
|
{"/snap.jpg", "Alternative snapshot"},
|
|
{"/image/jpeg.cgi", "CGI snapshot"},
|
|
{"/cgi-bin/snapshot.cgi", "CGI bin snapshot"},
|
|
{"/jpg/image.jpg", "JPEG image"},
|
|
{"/tmpfs/auto.jpg", "Tmpfs snapshot"},
|
|
{"/axis-cgi/jpg/image.cgi", "Axis snapshot"},
|
|
{"/cgi-bin/viewer/video.jpg", "Viewer snapshot"},
|
|
{"/Streaming/channels/1/picture", "Hikvision snapshot"},
|
|
{"/onvif/snapshot", "ONVIF snapshot"},
|
|
}
|
|
|
|
for _, hp := range httpPaths {
|
|
var streamURL string
|
|
if username != "" && password != "" {
|
|
// For HTTP, we'll rely on Basic Auth instead of URL embedding
|
|
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
|
} else {
|
|
streamURL = fmt.Sprintf("http://%s%s", ip, hp.path)
|
|
}
|
|
|
|
streams = append(streams, models.DiscoveredStream{
|
|
URL: streamURL,
|
|
Type: "JPEG",
|
|
Protocol: "http",
|
|
Port: 80,
|
|
Working: false, // Will be tested later
|
|
Metadata: map[string]interface{}{
|
|
"source": "common",
|
|
"notes": hp.notes,
|
|
"username": username,
|
|
"password": password,
|
|
},
|
|
})
|
|
}
|
|
|
|
return streams
|
|
} |