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
|
# Binaries
|
||||||
bin/
|
bin/
|
||||||
|
strix
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
*.dll
|
*.dll
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -100,7 +99,7 @@ func (s *Server) setupRoutes() {
|
|||||||
s.router.Use(middleware.RealIP)
|
s.router.Use(middleware.RealIP)
|
||||||
s.router.Use(middleware.Logger)
|
s.router.Use(middleware.Logger)
|
||||||
s.router.Use(middleware.Recoverer)
|
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
|
// CORS middleware
|
||||||
s.router.Use(func(next http.Handler) http.Handler {
|
s.router.Use(func(next http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ func (l *Loader) ListBrands() ([]string, error) {
|
|||||||
var brands []string
|
var brands []string
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") {
|
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")
|
brandID := strings.TrimSuffix(file.Name(), ".json")
|
||||||
brands = append(brands, brandID)
|
brands = append(brands, brandID)
|
||||||
}
|
}
|
||||||
@@ -157,6 +161,11 @@ func (l *Loader) StreamingSearch(searchFunc func(*models.Camera) bool) ([]*model
|
|||||||
continue
|
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())
|
filePath := filepath.Join(l.brandsPath, file.Name())
|
||||||
camera, err := l.loadCameraFromFile(filePath)
|
camera, err := l.loadCameraFromFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ package discovery
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"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"
|
"github.com/strix-project/strix/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,12 +34,159 @@ func (o *ONVIFDiscovery) DiscoverStreamsForIP(ctx context.Context, ip, username,
|
|||||||
ip = ip[:idx]
|
ip = ip[:idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return common RTSP streams as we can't use complex ONVIF due to API changes
|
var allStreams []models.DiscoveredStream
|
||||||
streams := o.getCommonRTSPStreams(ip, username, password)
|
|
||||||
|
|
||||||
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
|
// 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 {
|
func NewTester(ffprobeTimeout time.Duration, logger interface{ Debug(string, ...any); Error(string, error, ...any) }) *Tester {
|
||||||
return &Tester{
|
return &Tester{
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
ffprobeTimeout: ffprobeTimeout,
|
ffprobeTimeout: ffprobeTimeout,
|
||||||
logger: logger,
|
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
|
// Try to probe with ffprobe for more details
|
||||||
t.probeHTTPVideo(ctx, streamURL, username, password, result)
|
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:
|
default:
|
||||||
result.Type = "HTTP_UNKNOWN"
|
result.Type = "HTTP_UNKNOWN"
|
||||||
result.Working = true // Assume it works if we got 200 OK
|
result.Working = true // Assume it works if we got 200 OK
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,6 +42,10 @@ type ScannerConfig struct {
|
|||||||
FFProbeTimeout time.Duration
|
FFProbeTimeout time.Duration
|
||||||
RetryAttempts int
|
RetryAttempts int
|
||||||
RetryDelay time.Duration
|
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
|
// LoggerConfig contains logging settings
|
||||||
@@ -51,6 +56,8 @@ type LoggerConfig struct {
|
|||||||
|
|
||||||
// Load returns configuration with defaults
|
// Load returns configuration with defaults
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
|
dataPath := getEnv("STRIX_DATA_PATH", "./data")
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
Server: ServerConfig{
|
Server: ServerConfig{
|
||||||
Host: getEnv("STRIX_HOST", "0.0.0.0"),
|
Host: getEnv("STRIX_HOST", "0.0.0.0"),
|
||||||
@@ -59,10 +66,10 @@ func Load() *Config {
|
|||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
DataPath: getEnv("STRIX_DATA_PATH", "/home/dev/Strix/data"),
|
DataPath: dataPath,
|
||||||
BrandsPath: "/home/dev/Strix/data/brands",
|
BrandsPath: filepath.Join(dataPath, "brands"),
|
||||||
PatternsPath: "/home/dev/Strix/data/popular_stream_patterns.json",
|
PatternsPath: filepath.Join(dataPath, "popular_stream_patterns.json"),
|
||||||
ParametersPath: "/home/dev/Strix/data/query_parameters.json",
|
ParametersPath: filepath.Join(dataPath, "query_parameters.json"),
|
||||||
CacheEnabled: true,
|
CacheEnabled: true,
|
||||||
CacheTTL: 5 * time.Minute,
|
CacheTTL: 5 * time.Minute,
|
||||||
},
|
},
|
||||||
@@ -71,9 +78,13 @@ func Load() *Config {
|
|||||||
MaxStreams: 10,
|
MaxStreams: 10,
|
||||||
ModelSearchLimit: 6,
|
ModelSearchLimit: 6,
|
||||||
WorkerPoolSize: 20,
|
WorkerPoolSize: 20,
|
||||||
FFProbeTimeout: 5 * time.Second,
|
FFProbeTimeout: 30 * time.Second,
|
||||||
RetryAttempts: 2,
|
RetryAttempts: 2,
|
||||||
RetryDelay: 500 * time.Millisecond,
|
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{
|
Logger: LoggerConfig{
|
||||||
Level: getEnv("STRIX_LOG_LEVEL", "info"),
|
Level: getEnv("STRIX_LOG_LEVEL", "info"),
|
||||||
|
|||||||
Reference in New Issue
Block a user