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:
eduard256
2025-10-28 23:04:10 +03:00
parent f80f7ab314
commit bfade99c99
6 changed files with 190 additions and 12 deletions
+1
View File
@@ -1,5 +1,6 @@
# Binaries
bin/
strix
*.exe
*.exe~
*.dll
+1 -2
View File
@@ -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 {
+9
View File
@@ -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 {
+157 -4
View File
@@ -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
+6 -1
View File
@@ -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
+16 -5
View File
@@ -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"),