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>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Binaries
|
||||
bin/
|
||||
strix
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -100,7 +99,7 @@ func (s *Server) setupRoutes() {
|
||||
s.router.Use(middleware.RealIP)
|
||||
s.router.Use(middleware.Logger)
|
||||
s.router.Use(middleware.Recoverer)
|
||||
s.router.Use(middleware.Timeout(60 * time.Second))
|
||||
// Note: No global timeout middleware - endpoints use context-based timeouts from request parameters
|
||||
|
||||
// CORS middleware
|
||||
s.router.Use(func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -78,6 +78,10 @@ func (l *Loader) ListBrands() ([]string, error) {
|
||||
var brands []string
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
|
||||
// Skip index files
|
||||
if file.Name() == "index.json" || file.Name() == "indexa.json" {
|
||||
continue
|
||||
}
|
||||
brandID := strings.TrimSuffix(file.Name(), ".json")
|
||||
brands = append(brands, brandID)
|
||||
}
|
||||
@@ -157,6 +161,11 @@ func (l *Loader) StreamingSearch(searchFunc func(*models.Camera) bool) ([]*model
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip index.json as it contains brand list, not camera data
|
||||
if file.Name() == "index.json" || file.Name() == "indexa.json" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(l.brandsPath, file.Name())
|
||||
camera, err := l.loadCameraFromFile(filePath)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,10 +2,16 @@ 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"
|
||||
)
|
||||
|
||||
@@ -28,12 +34,159 @@ func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username,
|
||||
ip = ip[:idx]
|
||||
}
|
||||
|
||||
// Return common RTSP streams as we can't use complex ONVIF due to API changes
|
||||
streams := o.getCommonRTSPStreams(ip, username, password)
|
||||
var allStreams []models.DiscoveredStream
|
||||
|
||||
o.logger.Debug("generated common RTSP streams", "count", len(streams))
|
||||
// Try real ONVIF discovery first
|
||||
onvifStreams := o.discoverViaONVIF(ctx, ip, username, password)
|
||||
allStreams = append(allStreams, onvifStreams...)
|
||||
|
||||
return streams, nil
|
||||
// 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
|
||||
|
||||
@@ -23,7 +23,7 @@ type Tester struct {
|
||||
func NewTester(ffprobeTimeout time.Duration, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Tester {
|
||||
return &Tester{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
ffprobeTimeout: ffprobeTimeout,
|
||||
logger: logger,
|
||||
@@ -265,6 +265,11 @@ func (t *Tester) testHTTP(ctx context.Context, streamURL, username, password str
|
||||
// Try to probe with ffprobe for more details
|
||||
t.probeHTTPVideo(ctx, streamURL, username, password, result)
|
||||
|
||||
case strings.Contains(contentType, "text/html"), strings.Contains(contentType, "text/plain"):
|
||||
// Ignore web interfaces and plain text responses
|
||||
result.Working = false
|
||||
result.Error = "web interface, not a video stream"
|
||||
|
||||
default:
|
||||
result.Type = "HTTP_UNKNOWN"
|
||||
result.Working = true // Assume it works if we got 200 OK
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,10 @@ type ScannerConfig struct {
|
||||
FFProbeTimeout time.Duration
|
||||
RetryAttempts int
|
||||
RetryDelay time.Duration
|
||||
// Validation settings
|
||||
StrictValidation bool // Enable strict validation mode
|
||||
MinImageSize int // Minimum bytes for valid image (JPEG/PNG)
|
||||
MinVideoStreams int // Minimum video streams required
|
||||
}
|
||||
|
||||
// LoggerConfig contains logging settings
|
||||
@@ -51,6 +56,8 @@ type LoggerConfig struct {
|
||||
|
||||
// Load returns configuration with defaults
|
||||
func Load() *Config {
|
||||
dataPath := getEnv("STRIX_DATA_PATH", "./data")
|
||||
|
||||
return &Config{
|
||||
Server: ServerConfig{
|
||||
Host: getEnv("STRIX_HOST", "0.0.0.0"),
|
||||
@@ -59,10 +66,10 @@ func Load() *Config {
|
||||
WriteTimeout: 30 * time.Second,
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
DataPath: getEnv("STRIX_DATA_PATH", "/home/dev/Strix/data"),
|
||||
BrandsPath: "/home/dev/Strix/data/brands",
|
||||
PatternsPath: "/home/dev/Strix/data/popular_stream_patterns.json",
|
||||
ParametersPath: "/home/dev/Strix/data/query_parameters.json",
|
||||
DataPath: dataPath,
|
||||
BrandsPath: filepath.Join(dataPath, "brands"),
|
||||
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
|
||||
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
|
||||
CacheEnabled: true,
|
||||
CacheTTL: 5 * time.Minute,
|
||||
},
|
||||
@@ -71,9 +78,13 @@ func Load() *Config {
|
||||
MaxStreams: 10,
|
||||
ModelSearchLimit: 6,
|
||||
WorkerPoolSize: 20,
|
||||
FFProbeTimeout: 5 * time.Second,
|
||||
FFProbeTimeout: 30 * time.Second,
|
||||
RetryAttempts: 2,
|
||||
RetryDelay: 500 * time.Millisecond,
|
||||
// Strict validation enabled by default
|
||||
StrictValidation: true,
|
||||
MinImageSize: 5120, // 5KB minimum for valid images
|
||||
MinVideoStreams: 1, // At least 1 video stream required
|
||||
},
|
||||
Logger: LoggerConfig{
|
||||
Level: getEnv("STRIX_LOG_LEVEL", "info"),
|
||||
|
||||
Reference in New Issue
Block a user