Files
Strix/internal/camera/discovery/onvif_simple.go
T
eduard256 bfade99c99 Fix ONVIF library integration and improve stream discovery
- Fix ONVIF CallMethod response parsing (returns *http.Response, not structs)
- Add proper XML SOAP envelope parsing for GetProfiles and GetStreamUri
- Use correct types from xsd/onvif package (StreamType, TransportProtocol, ReferenceToken)
- Add strix binary to .gitignore
- Update configuration and API routes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 23:04:10 +03:00

293 lines
8.4 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/strix-project/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) {
// Clean IP (remove port if present)
if idx := strings.IndexByte(ip, ':'); idx > 0 {
ip = ip[:idx]
}
var allStreams []models.DiscoveredStream
// Try real ONVIF discovery first
onvifStreams := o.discoverViaONVIF(ctx, ip, username, password)
allStreams = append(allStreams, onvifStreams...)
// Add common RTSP streams
commonStreams := o.getCommonRTSPStreams(ip, username, password)
allStreams = append(allStreams, commonStreams...)
o.logger.Debug("collected streams", "onvif", len(onvifStreams), "common", len(commonStreams), "total", len(allStreams))
return allStreams, nil
}
// discoverViaONVIF performs real ONVIF discovery
func (o *ONVIFDiscovery) discoverViaONVIF(ctx context.Context, ip, username, password string) []models.DiscoveredStream {
var streams []models.DiscoveredStream
// Try standard ONVIF ports
ports := []int{80, 8080, 8000}
for _, port := range ports {
// 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("trying ONVIF connection", "xaddr", xaddr)
// Create ONVIF device
dev, err := onvif.NewDevice(onvif.DeviceParams{
Xaddr: xaddr,
Username: username,
Password: password,
})
if err != nil {
o.logger.Debug("ONVIF device creation failed", "xaddr", xaddr, "error", err.Error())
continue
}
// Try to get profiles with context
profileStreams := o.getProfileStreams(onvifCtx, dev, ip)
if len(profileStreams) > 0 {
streams = append(streams, profileStreams...)
o.logger.Debug("ONVIF discovery successful", "xaddr", xaddr, "profiles", len(profileStreams))
break // Found working port, stop trying
}
}
return streams
}
// getProfileStreams gets stream URIs from media profiles
func (o *ONVIFDiscovery) getProfileStreams(ctx context.Context, dev *onvif.Device, ip string) []models.DiscoveredStream {
var streams []models.DiscoveredStream
// Get media profiles
getProfilesReq := media.GetProfiles{}
profilesResp, err := dev.CallMethod(getProfilesReq)
if err != nil {
o.logger.Debug("failed to get ONVIF profiles", "error", err.Error())
return streams
}
defer profilesResp.Body.Close()
// Read and parse XML response
body, err := io.ReadAll(profilesResp.Body)
if err != nil {
o.logger.Debug("failed to read profiles response", "error", err.Error())
return streams
}
// Parse 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
}
// Get stream URI for each profile
for _, profile := range envelope.Body.GetProfilesResponse.Profiles {
streamURI := o.getStreamURI(dev, string(profile.Token))
if 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),
},
})
}
}
return streams
}
// getStreamURI retrieves stream URI for a profile
func (o *ONVIFDiscovery) getStreamURI(dev *onvif.Device, profileToken string) string {
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,
},
},
}
resp, err := dev.CallMethod(getStreamURIReq)
if err != nil {
o.logger.Debug("failed to get stream URI", "profile", profileToken, "error", err.Error())
return ""
}
defer resp.Body.Close()
// 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 ""
}
// 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 ""
}
return string(envelope.Body.GetStreamUriResponse.MediaUri.Uri)
}
// 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
}