diff --git a/.github-workflows-example.yml b/.github-workflows-example.yml new file mode 100644 index 0000000..61d0488 --- /dev/null +++ b/.github-workflows-example.yml @@ -0,0 +1,120 @@ +# Example GitHub Actions workflow for camera integration tests +# Save as .github/workflows/camera-tests.yml + +name: Camera Integration Tests + +on: + # Run on manual trigger + workflow_dispatch: + inputs: + camera_endpoint: + description: 'Camera ONVIF endpoint' + required: true + default: 'http://192.168.1.201/onvif/device_service' + camera_username: + description: 'Camera username' + required: true + default: 'service' + + # Or run on schedule (daily at 2 AM) + schedule: + - cron: '0 2 * * *' + +jobs: + test-bosch-flexidome: + name: Test Bosch FLEXIDOME + runs-on: ubuntu-latest + + # Only run if secrets are configured + if: ${{ secrets.ONVIF_TEST_PASSWORD != '' }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run Bosch FLEXIDOME tests + env: + ONVIF_TEST_ENDPOINT: ${{ github.event.inputs.camera_endpoint || secrets.ONVIF_TEST_ENDPOINT }} + ONVIF_TEST_USERNAME: ${{ github.event.inputs.camera_username || secrets.ONVIF_TEST_USERNAME }} + ONVIF_TEST_PASSWORD: ${{ secrets.ONVIF_TEST_PASSWORD }} + run: | + echo "Testing camera at: $ONVIF_TEST_ENDPOINT" + go test -v -run TestBoschFLEXIDOMEIndoor5100iIR -timeout 5m + + - name: Run benchmarks + if: success() + env: + ONVIF_TEST_ENDPOINT: ${{ github.event.inputs.camera_endpoint || secrets.ONVIF_TEST_ENDPOINT }} + ONVIF_TEST_USERNAME: ${{ github.event.inputs.camera_username || secrets.ONVIF_TEST_USERNAME }} + ONVIF_TEST_PASSWORD: ${{ secrets.ONVIF_TEST_PASSWORD }} + run: | + go test -bench=BenchmarkBoschFLEXIDOMEIndoor5100iIR -benchmem -run=^$ | tee benchmark.txt + + - name: Upload benchmark results + if: success() + uses: actions/upload-artifact@v3 + with: + name: benchmark-results + path: benchmark.txt + + - name: Generate test coverage + if: success() + env: + ONVIF_TEST_ENDPOINT: ${{ github.event.inputs.camera_endpoint || secrets.ONVIF_TEST_ENDPOINT }} + ONVIF_TEST_USERNAME: ${{ github.event.inputs.camera_username || secrets.ONVIF_TEST_USERNAME }} + ONVIF_TEST_PASSWORD: ${{ secrets.ONVIF_TEST_PASSWORD }} + run: | + go test -coverprofile=coverage.out -run TestBoschFLEXIDOMEIndoor5100iIR + go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage report + if: success() + uses: actions/upload-artifact@v3 + with: + name: coverage-report + path: coverage.html + + - name: Comment test results + if: always() && github.event_name == 'workflow_dispatch' + uses: actions/github-script@v6 + with: + script: | + const outcome = '${{ job.status }}' === 'success' ? '✅ PASSED' : '❌ FAILED'; + console.log(`Camera integration tests: ${outcome}`); + +# Configuration Instructions: +# +# 1. Add secrets to your GitHub repository: +# - Go to Settings > Secrets and variables > Actions +# - Add the following secrets: +# * ONVIF_TEST_ENDPOINT (camera URL) +# * ONVIF_TEST_USERNAME (camera username) +# * ONVIF_TEST_PASSWORD (camera password) +# +# 2. Ensure your GitHub Actions runner can reach the camera: +# - Use self-hosted runner on same network as camera +# - Or use VPN/tunnel to access camera from GitHub-hosted runner +# +# 3. Run manually: +# - Go to Actions tab +# - Select "Camera Integration Tests" +# - Click "Run workflow" +# - Optionally override endpoint/username diff --git a/.gitignore b/.gitignore index 11fa47c..a6c2a00 100644 --- a/.gitignore +++ b/.gitignore @@ -29,12 +29,25 @@ go.work # Binaries bin/ dist/ +onvif-diagnostics +onvif-server +onvif-server-example +generate-tests # Temporary files tmp/ temp/ *.tmp +# Camera logs and captures (keep directory structure but ignore content) +camera-logs/*.json +camera-logs/*.tar.gz +xml-captures/ + +# Extracted test captures +capture_*.xml +capture_*.json + # Environment files .env .env.local diff --git a/CAMERA_ANALYSIS.md b/CAMERA_ANALYSIS.md new file mode 100644 index 0000000..5c9147a --- /dev/null +++ b/CAMERA_ANALYSIS.md @@ -0,0 +1,706 @@ +# ONVIF Camera Analysis Report + +Generated: November 7, 2025 + +## Executive Summary + +Analysis of 5 ONVIF-compliant cameras from 3 manufacturers (REOLINK, AXIS, Bosch) reveals diverse implementations and capabilities. All cameras successfully responded to ONVIF commands with varying feature sets. + +--- + +## Camera Inventory + +### 1. REOLINK E1 Zoom +- **Firmware**: v3.1.0.2649_23083101 +- **Serial**: 192168261 +- **IP**: 192.168.2.61:8000 +- **Type**: PTZ Indoor Camera +- **Key Features**: PTZ support, dual stream, basic imaging + +### 2. AXIS Q3819-PVE +- **Firmware**: 10.12.153 +- **Serial**: B8A44F9DC7ED +- **IP**: 192.168.2.190 +- **Type**: Panoramic Fixed Dome +- **Key Features**: Ultra-wide 8192x1728 resolution, analytics, advanced imaging + +### 3. AXIS P3818-PVE +- **Firmware**: 11.9.60 +- **Serial**: B8A44FA04F26 +- **IP**: 192.168.2.82 +- **Type**: Panoramic Fixed Dome +- **Key Features**: 5120x2560 resolution, analytics, dual encoding (H264/JPEG) + +### 4. Bosch FLEXIDOME panoramic 5100i +- **Firmware**: 9.00.0210 +- **Serial**: 404705923918060213 +- **IP**: 192.168.2.24 +- **Type**: 360° Panoramic Dome +- **Key Features**: 16 profiles, dewarping, circular image (2112x2112) + +### 5. Bosch FLEXIDOME IP starlight 8000i +- **Firmware**: 7.70.0126 +- **Serial**: 044518807925140011 +- **IP**: 192.168.2.200 +- **Type**: Fixed Dome with Low-Light Performance +- **Key Features**: Starlight imaging, I/O connectors, relay output + +--- + +## Comparative Analysis + +### Resolution Capabilities + +| Camera | Max Resolution | Aspect Ratio | Primary Use Case | +|--------|---------------|--------------|------------------| +| REOLINK E1 Zoom | 2048x1536 | 4:3 | Standard surveillance | +| AXIS Q3819-PVE | 8192x1728 | ~4.7:1 | 180° panoramic | +| AXIS P3818-PVE | 5120x2560 | 2:1 | 180° panoramic | +| Bosch panoramic 5100i | 2112x2112 | 1:1 | 360° fisheye | +| Bosch starlight 8000i | 1536x864 | 16:9 | Low-light environments | + +### Profile Count + +| Camera | Total Profiles | Video Profiles | Notes | +|--------|----------------|----------------|-------| +| REOLINK E1 Zoom | 2 | 2 | MainStream + SubStream | +| AXIS Q3819-PVE | 2 | 2 | H264 + JPEG | +| AXIS P3818-PVE | 2 | 2 | H264 + JPEG | +| Bosch panoramic 5100i | 16 | 9 valid | Includes metadata/audio profiles | +| Bosch starlight 8000i | 3 | 3 | 2x H264 + 1x JPEG | + +### ONVIF Service Support + +| Service | REOLINK | AXIS Q3819 | AXIS P3818 | Bosch Panoramic | Bosch Starlight | +|---------|---------|------------|------------|-----------------|-----------------| +| Device | ✓ | ✓ | ✓ | ✓ | ✓ | +| Media | ✓ | ✓ | ✓ | ✓ | ✓ | +| Imaging | ✓ | ✓ | ✓ | ✓ | ✓ | +| Events | ✓ | ✓ | ✓ | ✓ | ✓ | +| Analytics | ✗ | ✓ | ✓ | ✓ | ✗ | +| PTZ | ✓ | ✗ | ✗ | ✓ | ✗ | + +### Video Encoding + +| Camera | H264 | JPEG | MPEG4 | Notes | +|--------|------|------|-------|-------| +| REOLINK | ✓ | ✗ | ✗ | H264 only | +| AXIS Q3819 | ✓ | ✓ | ✗ | Dual encoding | +| AXIS P3818 | ✓ | ✓ | ✗ | Dual encoding | +| Bosch Panoramic | ✓ | ✗ | ✗ | H264 only | +| Bosch Starlight | ✓ | ✓ | ✗ | Dual encoding | + +### Network Capabilities + +| Feature | REOLINK | AXIS Q3819 | AXIS P3818 | Bosch Panoramic | Bosch Starlight | +|---------|---------|------------|------------|-----------------|-----------------| +| RTP Multicast | ✗ | ✓ | ✓ | ✓ | ✓ | +| RTP/TCP | ✓ | ✓ | ✓ | ✗ | ✗ | +| RTP/RTSP/TCP | ✓ | ✓ | ✓ | ✓ | ✓ | +| IPv6 Support | ✗ | ✓ | ✓ | ✗ | ✗ | +| TLS 1.2 | ✗ | ✓ | ✓ | ✓ | ✓ | + +### Imaging Features + +| Feature | REOLINK | AXIS Q3819 | AXIS P3818 | Bosch Panoramic | Bosch Starlight | +|---------|---------|------------|------------|-----------------|-----------------| +| Brightness Control | ✓ (128) | ✓ (50) | ✓ (50) | ✓ (127) | ✓ (128) | +| Saturation Control | ✓ (128) | ✓ (50) | ✓ (50) | ✓ (127) | ✓ (128) | +| Contrast Control | ✓ (128) | ✓ (50) | ✓ (50) | ✓ (127) | ✓ (128) | +| Sharpness Control | ✓ (128) | ✓ (50) | ✓ (50) | ✗ | ✗ | +| IrCutFilter | AUTO | AUTO | AUTO | ✗ | ✗ | +| WDR | ✗ | ON | ON | ✗ | ✗ | +| WhiteBalance | ✗ | AUTO | AUTO | ✗ | ✗ | +| Exposure Control | ✗ | AUTO | AUTO | ✗ | ✗ | + +### I/O and Security + +| Feature | REOLINK | AXIS Q3819 | AXIS P3818 | Bosch Panoramic | Bosch Starlight | +|---------|---------|------------|------------|-----------------|-----------------| +| Input Connectors | 0 | 2 | 2 | 0 | 2 | +| Relay Outputs | 0 | 0 | 0 | 0 | 1 | +| IP Filter | ✗ | ✓ | ✓ | ✗ | ✗ | +| TLS 1.1 | ✗ | ✓ | ✓ | ✗ | ✓ | +| TLS 1.2 | ✗ | ✓ | ✓ | ✓ | ✓ | + +--- + +## Manufacturer-Specific Findings + +### REOLINK +- **Strengths**: + - Simple, straightforward ONVIF implementation + - PTZ support with status reporting + - Good value camera with basic features +- **Limitations**: + - Limited imaging controls (no WDR, exposure, focus) + - Only H264 encoding (no JPEG profile) + - No analytics support + - Lower security features (no TLS) +- **RTSP Pattern**: `rtsp://IP:554/` (main), `rtsp://IP:554/h264Preview_01_sub` (sub) +- **Snapshot Pattern**: `http://IP:80/cgi-bin/api.cgi?cmd=onvifSnapPic&channel=0` + +### AXIS +- **Strengths**: + - Excellent ONVIF compliance and feature richness + - Ultra-high resolution panoramic cameras + - Advanced imaging with WDR, exposure control, white balance + - Strong security (TLS 1.1/1.2, IP filtering, access policy) + - Analytics and rule-based event support +- **Consistent Implementation**: + - Both cameras share similar ONVIF structure + - Dual H264/JPEG encoding profiles + - Same URL patterns and capabilities +- **RTSP Pattern**: `rtsp://IP/onvif-media/media.amp?profile=X&sessiontimeout=60&streamtype=unicast` +- **Snapshot Pattern**: `http://IP/onvif-cgi/jpg/image.cgi?resolution=WxH&compression=30` +- **Notable**: Q3819 has wider aspect ratio (8192x1728 vs 5120x2560) + +### Bosch +- **Strengths**: + - Specialized cameras with unique features + - Panoramic 5100i has comprehensive dewarping profiles + - Starlight 8000i optimized for low-light + - Good I/O options (starlight model has relay output) +- **Quirks**: + - Panoramic model has 16 profiles (many without video encoders) + - Some profiles return "IncompleteConfiguration" errors + - Less standardized RTSP URLs (tunnel-based) +- **RTSP Pattern**: `rtsp://IP/rtsp_tunnel?p=X&line=Y&inst=Z` (various parameters) +- **Snapshot Pattern**: `http://IP/snap.jpg?JpegCam=X` +- **Notable**: + - Panoramic uses circular (2112x2112) and dewarped (3072x1728) views + - 3 profiles failed GetStreamURI with incomplete configuration + +--- + +## Performance Metrics + +### Response Times (Average) + +| Operation | REOLINK | AXIS Q3819 | AXIS P3818 | Bosch Panoramic | Bosch Starlight | +|-----------|---------|------------|------------|-----------------|-----------------| +| DeviceInfo | 117.7ms | 5.0ms | 4.9ms | 8.5ms | 7.9ms | +| Capabilities | 85.6ms | 72.7ms | 69.3ms | 21.9ms | 27.1ms | +| GetProfiles | 832.1ms | 70.9ms | 8.0ms | 706.2ms | 258.3ms | +| GetStreamURI | ~129ms avg | ~20ms avg | ~4ms avg | ~11ms avg | ~10ms avg | +| GetSnapshot | ~170ms avg | ~20ms avg | ~4ms avg | ~11ms avg | ~6ms avg | +| Imaging | 111.8ms | 55.8ms | 67.2ms | 57.3ms | 14.8ms | + +**Key Observations**: +- AXIS cameras have fastest response times overall +- REOLINK has higher latency (likely due to port 8000, may be proxy/gateway) +- Bosch cameras have moderate, consistent response times +- GetProfiles is slowest operation for most cameras + +### Error Analysis + +| Camera | Total Errors | Error Types | +|--------|--------------|-------------| +| REOLINK E1 Zoom | 0 | None | +| AXIS Q3819-PVE | 0 | None | +| AXIS P3818-PVE | 0 | None | +| Bosch panoramic 5100i | 3 | GetStreamURI: IncompleteConfiguration (profiles 9,10,11) | +| Bosch starlight 8000i | 0 | None | + +**Bosch Panoramic Errors**: Profiles 9, 10, 11 have no VideoEncoderConfiguration, causing legitimate failures. These appear to be metadata-only or incomplete profiles. + +--- + +## Stream URI Patterns + +### REOLINK Pattern +``` +rtsp://192.168.2.61:554/ # MainStream +rtsp://192.168.2.61:554/h264Preview_01_sub # SubStream +``` + +### AXIS Pattern +``` +rtsp://IP/onvif-media/media.amp?profile=profile_1_h264&sessiontimeout=60&streamtype=unicast +rtsp://IP/onvif-media/media.amp?profile=profile_1_jpeg&sessiontimeout=60&streamtype=unicast +``` + +### Bosch Patterns + +**Indoor 5100i IR** (from previous report): +``` +rtsp://IP/rtsp_tunnel?p=0&line=1&inst=1&vcd=2 +``` + +**Panoramic 5100i**: +``` +rtsp://192.168.2.24/rtsp_tunnel?p=0&line=3&inst=4 # E_PTZ view +rtsp://192.168.2.24/rtsp_tunnel?p=1&line=2&inst=1 # Dewarped view +rtsp://192.168.2.24/rtsp_tunnel?p=2&line=1&inst=4 # Full circle +rtsp://192.168.2.24/rtsp_tunnel?von=0&aon=1&aud=1 # Audio only +rtsp://192.168.2.24/rtsp_tunnel?von=0&vcd=2&line=1 # Metadata +``` + +**Starlight 8000i**: +``` +rtsp://192.168.2.200/rtsp_tunnel?p=0&h26x=4&vcd=2 +rtsp://192.168.2.200/rtsp_tunnel?p=1&inst=2&h26x=4 +rtsp://192.168.2.200/rtsp_tunnel?h26x=0 # JPEG +``` + +**Parameter Meanings**: +- `p`: Profile index +- `line`: Video line/source (1=full, 2=dewarped, 3=ePTZ) +- `inst`: Instance number +- `vcd`: Video codec (2=metadata) +- `h26x`: H.26x codec (0=JPEG, 4=H264) +- `von`: Video on/off +- `aon`: Audio on/off + +--- + +## PTZ Capabilities + +### REOLINK E1 Zoom (PTZ Enabled) +- **PTZ Service**: http://192.168.2.61:8000/onvif/ptz_service +- **Status**: Both profiles report IDLE for PanTilt and Zoom +- **Presets**: 0 configured +- **Configuration**: PTZ config present but with empty position spaces +- **Notes**: PTZ capability exists but requires further testing for movement commands + +### Bosch Panoramic 5100i (ePTZ) +- **PTZ Service**: http://192.168.2.24/onvif/ptz_service +- **Type**: Electronic PTZ (digital zoom/pan on panoramic image) +- **Profile**: Dedicated ePTZ profile (token "0", 1920x1080) +- **Notes**: Digital PTZ on dewarped 360° image, not mechanical movement + +### Other Cameras +- AXIS Q3819-PVE, P3818-PVE, Bosch starlight 8000i: No PTZ support + +--- + +## Snapshot URI Patterns + +| Manufacturer | Pattern | Authentication Required | +|--------------|---------|------------------------| +| REOLINK | `http://IP:80/cgi-bin/api.cgi?cmd=onvifSnapPic&channel=0` | Yes | +| AXIS | `http://IP/onvif-cgi/jpg/image.cgi?resolution=WxH&compression=30` | Yes | +| Bosch | `http://IP/snap.jpg?JpegCam=N` | Yes | + +**InvalidAfterConnect/Reboot**: +- REOLINK: InvalidAfterConnect=true, InvalidAfterReboot=true +- AXIS: All false (persistent URIs) +- Bosch: InvalidAfterReboot=true + +--- + +## Bitrate and Frame Rate Analysis + +### REOLINK E1 Zoom +- **MainStream**: 1024 kbps @ 15fps (2048x1536) +- **SubStream**: 512 kbps @ 15fps (640x480) +- **Quality**: 0 (main), 2 (sub) + +### AXIS Q3819-PVE +- **H264**: Max bitrate @ 30fps (8192x1728) +- **JPEG**: Max bitrate @ 30fps (8192x1728) +- **Quality**: 70 for both +- **Bitrate Limit**: 2147483647 (max int32 = unlimited) + +### AXIS P3818-PVE +- **H264**: Max bitrate @ 30fps (1920x960) +- **JPEG**: Max bitrate @ 30fps (5120x2560) +- **Quality**: 70 for both +- **Bitrate Limit**: 2147483647 (unlimited) + +### Bosch Panoramic 5100i +- **Highest**: 13000 kbps @ 30fps (3072x1728 dewarped) +- **Lowest**: 400 kbps @ 30fps (512x288) +- **Standard**: 5200 kbps @ 30fps (1920x1080) +- **Quality**: 50 across all profiles + +### Bosch Starlight 8000i +- **H264**: 1400 kbps @ 30fps (1536x864) +- **JPEG**: 6000 kbps @ 1fps (1536x864) +- **Quality**: 50 (H264), 70 (JPEG) + +--- + +## Testing Recommendations + +### Priority 1: Create Camera-Specific Tests + +Each manufacturer has distinct patterns worthy of dedicated test files: + +1. **reolink_e1_zoom_test.go** + - Test PTZ status retrieval + - Verify dual-stream profiles + - Test CGI-based snapshot URLs + - Validate 15fps frame rate limits + +2. **axis_q3819_test.go** + - Test ultra-wide resolution (8192x1728) + - Verify analytics service + - Test dual H264/JPEG encoding + - Validate WDR and exposure settings + - Test multicast support + +3. **axis_p3818_test.go** + - Test 5120x2560 panoramic resolution + - Similar to Q3819 but different aspect ratio + - Benchmark performance differences + +4. **bosch_panoramic_5100i_test.go** + - Test circular (2112x2112) image profiles + - Test dewarped profiles + - Handle IncompleteConfiguration errors gracefully + - Test metadata and audio-only profiles + - Test 16 different profiles + +5. **bosch_starlight_8000i_test.go** + - Test low-light imaging capabilities + - Test I/O connectors (2 inputs, 1 relay output) + - Test JPEG motion (1fps) vs H264 (30fps) + +### Priority 2: Cross-Manufacturer Tests + +Create tests that verify common ONVIF compliance: + +1. **stream_uri_compatibility_test.go** + - Parse and validate different RTSP URL formats + - Test RTSP connection to each pattern + - Verify authentication handling + +2. **imaging_settings_test.go** + - Test brightness/contrast/saturation ranges + - Test optional features (WDR, exposure, white balance) + - Verify manufacturer-specific defaults + +3. **profile_enumeration_test.go** + - Test handling of 2-16 profiles + - Verify profile names and tokens + - Test resolution validation + +### Priority 3: Edge Case Tests + +1. **incomplete_profile_handling_test.go** + - Test cameras with profiles lacking video encoders + - Verify graceful error handling for IncompleteConfiguration + - Test metadata-only and audio-only profiles + +2. **performance_benchmark_test.go** + - Benchmark GetProfiles (100ms to 800ms variation) + - Test response time consistency + - Measure concurrent request handling + +--- + +## Code Patterns for Tests + +### Example: Testing AXIS Cameras + +```go +func TestAXISQ3819PVE_UltraWideResolution(t *testing.T) { + skipIfNoCamera(t) + + client := createTestClient(t) + profiles, err := client.GetProfiles() + require.NoError(t, err) + + // AXIS Q3819 should have H264 and JPEG profiles + assert.Equal(t, 2, len(profiles)) + + // Find H264 profile + var h264Profile *onvif.Profile + for _, p := range profiles { + if p.VideoEncoderConfiguration != nil && + p.VideoEncoderConfiguration.Encoding == "H264" { + h264Profile = &p + break + } + } + + require.NotNil(t, h264Profile, "H264 profile should exist") + + // Verify ultra-wide resolution + assert.Equal(t, 8192, h264Profile.VideoEncoderConfiguration.Resolution.Width) + assert.Equal(t, 1728, h264Profile.VideoEncoderConfiguration.Resolution.Height) + + // Verify 30fps + assert.Equal(t, 30, h264Profile.VideoEncoderConfiguration.RateControl.FrameRateLimit) +} +``` + +### Example: Testing Bosch Panoramic Profiles + +```go +func TestBoschPanoramic5100i_MultipleProfiles(t *testing.T) { + skipIfNoCamera(t) + + client := createTestClient(t) + profiles, err := client.GetProfiles() + require.NoError(t, err) + + // Should have 16 profiles + assert.Equal(t, 16, len(profiles)) + + // Count profiles with valid video encoders + validVideoProfiles := 0 + for _, p := range profiles { + if p.VideoEncoderConfiguration != nil { + validVideoProfiles++ + } + } + + assert.Equal(t, 9, validVideoProfiles, "Should have 9 video profiles") + + // Test that incomplete profiles fail gracefully + for _, p := range profiles { + uri, err := client.GetStreamURI(p.Token, "RTP-Unicast") + + if p.VideoEncoderConfiguration != nil { + // Valid profiles should succeed + if err != nil { + t.Logf("Profile %s failed: %v", p.Token, err) + } + } else { + // Incomplete profiles should fail + assert.Error(t, err, "Profile %s should fail (no video encoder)", p.Token) + } + } +} +``` + +### Example: Testing PTZ Status + +```go +func TestREOLINKE1Zoom_PTZStatus(t *testing.T) { + skipIfNoCamera(t) + + client := createTestClient(t) + profiles, err := client.GetProfiles() + require.NoError(t, err) + + for _, profile := range profiles { + if profile.PTZConfiguration != nil { + status, err := client.GetPTZStatus(profile.Token) + require.NoError(t, err) + + // Should report IDLE when not moving + assert.NotNil(t, status.MoveStatus) + assert.Contains(t, []string{"IDLE", "MOVING"}, status.MoveStatus.PanTilt) + assert.Contains(t, []string{"IDLE", "MOVING"}, status.MoveStatus.Zoom) + } + } +} +``` + +--- + +## Integration Test Suite Structure + +``` +tests/ +├── manufacturers/ +│ ├── reolink/ +│ │ └── e1_zoom_test.go +│ ├── axis/ +│ │ ├── q3819_pve_test.go +│ │ └── p3818_pve_test.go +│ └── bosch/ +│ ├── flexidome_indoor_5100i_ir_test.go (existing) +│ ├── flexidome_panoramic_5100i_test.go +│ └── flexidome_starlight_8000i_test.go +├── compliance/ +│ ├── stream_uri_test.go +│ ├── imaging_test.go +│ └── profile_test.go +├── benchmarks/ +│ └── response_time_test.go +└── edge_cases/ + ├── incomplete_profiles_test.go + └── error_handling_test.go +``` + +--- + +## Implementation Insights + +### RTSP Tunnel Parameters (Bosch) + +Bosch uses a proprietary `rtsp_tunnel` endpoint with various parameters: + +- **p**: Profile index (0-15) +- **line**: Video source line + - 1 = Full image circle + - 2 = Dewarped view mode + - 3 = Electronic PTZ +- **inst**: Stream instance (1-4, corresponds to bitrate tiers) +- **h26x**: Codec selection + - 0 = JPEG + - 4 = H.264 +- **vcd**: Video coding + - 2 = Metadata stream +- **von**: Video on (0/1) +- **aon**: Audio on (0/1) +- **aud**: Audio stream identifier +- **JpegCam**: Camera number for snapshots + +### AXIS URL Parameters + +- **profile**: Profile token +- **sessiontimeout**: Session timeout in seconds +- **streamtype**: unicast or multicast +- **resolution**: Snapshot resolution (WxH) +- **compression**: JPEG compression quality (0-100, lower = better) + +### REOLINK CGI API + +Uses proprietary CGI commands: +- `cmd=onvifSnapPic`: Get ONVIF-compliant snapshot +- `channel=0`: Camera channel + +--- + +## Security Considerations + +### Authentication +All cameras require HTTP Digest Authentication for ONVIF requests. + +### TLS Support + +| Camera | TLS 1.1 | TLS 1.2 | Notes | +|--------|---------|---------|-------| +| REOLINK E1 Zoom | ✗ | ✗ | HTTP only | +| AXIS Q3819-PVE | ✓ | ✓ | Full TLS support | +| AXIS P3818-PVE | ✓ | ✓ | Full TLS support | +| Bosch Panoramic 5100i | ✗ | ✓ | TLS 1.2 only | +| Bosch Starlight 8000i | ✓ | ✓ | Full TLS support | + +**Recommendation**: AXIS cameras provide the strongest security posture with IP filtering, access policy config, and TLS support. + +### WS-Security +All cameras support WS-Security UsernameToken with digest authentication, as evidenced by successful ONVIF communication. + +--- + +## Compatibility Matrix + +### ONVIF Profile Compliance + +Based on feature analysis, likely ONVIF profile compliance: + +| Camera | Profile S | Profile T | Profile G | Profile M | +|--------|-----------|-----------|-----------|-----------| +| REOLINK E1 Zoom | ✓ | ✓ (PTZ) | ✗ | ✗ | +| AXIS Q3819-PVE | ✓ | ✗ | ✓ (Analytics) | ✓ (Metadata) | +| AXIS P3818-PVE | ✓ | ✗ | ✓ (Analytics) | ✓ (Metadata) | +| Bosch Panoramic 5100i | ✓ | ✓ (ePTZ) | ✓ (Analytics) | ✓ (Metadata) | +| Bosch Starlight 8000i | ✓ | ✗ | ✗ | Partial | + +**Profiles**: +- **S**: Streaming (basic video) +- **T**: PTZ control +- **G**: Video analytics +- **M**: Metadata streaming + +--- + +## Conclusions + +### Best Practices Discovered + +1. **Profile Enumeration**: Always check VideoEncoderConfiguration before calling GetStreamURI +2. **Error Handling**: Bosch cameras may return IncompleteConfiguration for metadata profiles +3. **Response Times**: Expect 5-800ms for GetProfiles depending on camera complexity +4. **URL Patterns**: Cannot assume consistent RTSP URL format across manufacturers +5. **Imaging Defaults**: Manufacturers use different scales (0-255 vs 0-100 vs 0-128) + +### Client Library Improvements Needed + +1. **URL Parser**: Helper to parse and validate different RTSP URL formats +2. **Profile Filter**: Method to filter profiles by capability (video, audio, metadata) +3. **Retry Logic**: Handle transient errors and timeouts +4. **TLS Support**: Enable HTTPS for cameras supporting TLS +5. **Batch Operations**: Parallel GetStreamURI calls for cameras with many profiles + +### Test Coverage Recommendations + +Based on this analysis, create test files covering: + +1. ✅ Bosch FLEXIDOME indoor 5100i IR (already exists) +2. 🔲 REOLINK E1 Zoom (PTZ, dual stream) +3. 🔲 AXIS Q3819-PVE (ultra-wide, analytics) +4. 🔲 AXIS P3818-PVE (panoramic, analytics) +5. 🔲 Bosch FLEXIDOME panoramic 5100i (16 profiles, dewarping) +6. 🔲 Bosch FLEXIDOME IP starlight 8000i (low-light, I/O) + +### Interoperability Score + +Based on ONVIF compliance, feature richness, and ease of integration: + +| Camera | Score | Rationale | +|--------|-------|-----------| +| AXIS P3818-PVE | 9.5/10 | Excellent compliance, fast, feature-rich | +| AXIS Q3819-PVE | 9.5/10 | Same as P3818, ultra-wide resolution | +| Bosch Starlight 8000i | 8.0/10 | Good compliance, moderate features | +| Bosch Panoramic 5100i | 7.5/10 | Complex profile structure, some errors | +| REOLINK E1 Zoom | 7.0/10 | Basic features, slower responses, limited imaging | + +--- + +## Next Steps + +1. **Create manufacturer-specific test files** for each camera model +2. **Implement helper functions** for common patterns (URL parsing, profile filtering) +3. **Add benchmark tests** to track performance regression +4. **Document manufacturer quirks** in code comments +5. **Create CI/CD pipeline** to test against real cameras (when available) +6. **Expand coverage** for PTZ operations on REOLINK +7. **Test analytics** on AXIS cameras +8. **Validate TLS connections** on supported cameras + +--- + +## Appendix: Raw Data Summary + +### REOLINK E1 Zoom +- Profiles: 2 +- Stream URIs: 2/2 successful +- Snapshot URIs: 2/2 successful +- Video Encoders: 2/2 successful +- Imaging Settings: 1/1 successful +- PTZ Status: 2/2 successful (both IDLE) +- PTZ Presets: 0 +- Total Errors: 0 + +### AXIS Q3819-PVE +- Profiles: 2 +- Stream URIs: 2/2 successful +- Snapshot URIs: 2/2 successful +- Video Encoders: 2/2 successful +- Imaging Settings: 1/1 successful +- Total Errors: 0 + +### AXIS P3818-PVE +- Profiles: 2 +- Stream URIs: 2/2 successful +- Snapshot URIs: 2/2 successful +- Video Encoders: 2/2 successful +- Imaging Settings: 1/1 successful +- Total Errors: 0 + +### Bosch FLEXIDOME panoramic 5100i +- Profiles: 16 +- Stream URIs: 13/16 successful (3 IncompleteConfiguration errors) +- Snapshot URIs: 16/16 successful +- Video Encoders: 9/9 successful (only tested valid profiles) +- Imaging Settings: 1/1 successful +- Total Errors: 3 (expected for incomplete profiles) + +### Bosch FLEXIDOME IP starlight 8000i +- Profiles: 3 +- Stream URIs: 3/3 successful +- Snapshot URIs: 3/3 successful +- Video Encoders: 3/3 successful +- Imaging Settings: 1/1 successful +- Total Errors: 0 + +--- + +**End of Analysis Report** diff --git a/CAMERA_TESTS.md b/CAMERA_TESTS.md new file mode 100644 index 0000000..c94badb --- /dev/null +++ b/CAMERA_TESTS.md @@ -0,0 +1,140 @@ +# Camera-Specific Integration Tests + +This directory contains integration tests for specific ONVIF camera models based on real-world testing. + +## Bosch FLEXIDOME indoor 5100i IR Tests + +The `bosch_flexidome_test.go` file contains comprehensive tests verified against a real Bosch FLEXIDOME indoor 5100i IR camera running firmware 8.71.0066. + +### Running the Tests + +Set the following environment variables with your camera credentials: + +```bash +export ONVIF_TEST_ENDPOINT="http://192.168.1.201/onvif/device_service" +export ONVIF_TEST_USERNAME="service" +export ONVIF_TEST_PASSWORD="Service.1234" +``` + +Then run the tests: + +```bash +# Run all tests +go test -v ./... -run TestBoschFLEXIDOMEIndoor5100iIR + +# Run specific test +go test -v -run TestBoschFLEXIDOMEIndoor5100iIR_GetDeviceInformation + +# Run all tests with race detection +go test -v -race -run TestBoschFLEXIDOMEIndoor5100iIR + +# Run benchmarks +go test -v -bench=BenchmarkBoschFLEXIDOMEIndoor5100iIR -benchmem + +# Run full workflow test +go test -v -run TestBoschFLEXIDOMEIndoor5100iIR_FullWorkflow +``` + +### Test Coverage + +The tests cover the following ONVIF operations: + +- ✅ **GetDeviceInformation** - Device identification and firmware info +- ✅ **GetSystemDateAndTime** - System time retrieval +- ✅ **GetCapabilities** - Service capability discovery +- ✅ **Initialize** - Service endpoint initialization +- ✅ **GetProfiles** - Media profile retrieval (4 profiles expected) +- ✅ **GetStreamURI** - RTSP stream URI retrieval for all profiles +- ✅ **GetSnapshotURI** - Snapshot URI retrieval +- ✅ **GetVideoEncoderConfiguration** - Video encoder settings +- ✅ **GetImagingSettings** - Camera imaging parameters +- ✅ **Full Workflow** - Complete operation sequence + +### Expected Results for Bosch FLEXIDOME indoor 5100i IR + +- **Manufacturer**: Bosch +- **Model**: FLEXIDOME indoor 5100i IR +- **Profiles**: 4 H264 profiles + - Profile 1: 1920x1080 @ 30fps, 5200 kbps + - Profile 2: 1536x864 + - Profile 3: 1280x720 + - Profile 4: 512x288 +- **Services**: Device, Media, Imaging, Events, Analytics +- **Stream Protocol**: RTSP +- **Snapshot Format**: JPEG +- **Default Imaging Settings**: + - Brightness: 128.0 + - Color Saturation: 128.0 + - Contrast: 128.0 + +### Test Without Camera + +If environment variables are not set, tests will be automatically skipped: + +```bash +go test -v ./... +# Output: SKIP: Skipping test: ONVIF camera credentials not set +``` + +### Performance Benchmarks + +The test suite includes benchmarks for critical operations: + +- `BenchmarkBoschFLEXIDOMEIndoor5100iIR_GetDeviceInformation` - Device info retrieval performance +- `BenchmarkBoschFLEXIDOMEIndoor5100iIR_GetStreamURI` - Stream URI retrieval performance + +### Adding Tests for Other Camera Models + +To add tests for a new camera model: + +1. Create a new test file: `__test.go` +2. Follow the same pattern as `bosch_flexidome_test.go` +3. Update environment variable names to be model-specific if needed +4. Document expected values and behaviors for the specific model +5. Add README entry with camera-specific details + +Example: +```go +// hikvision_ds2cd2xxx_test.go +func TestHikvisionDS2CD_GetDeviceInformation(t *testing.T) { + // Test implementation +} +``` + +### Continuous Integration + +These tests can be integrated into CI/CD pipelines using secrets management: + +```yaml +# GitHub Actions example +- name: Run Camera Integration Tests + env: + ONVIF_TEST_ENDPOINT: ${{ secrets.ONVIF_ENDPOINT }} + ONVIF_TEST_USERNAME: ${{ secrets.ONVIF_USERNAME }} + ONVIF_TEST_PASSWORD: ${{ secrets.ONVIF_PASSWORD }} + run: go test -v -run TestBoschFLEXIDOMEIndoor5100iIR +``` + +### Troubleshooting + +**Tests fail with "connection refused":** +- Verify camera IP address and network connectivity +- Check firewall settings +- Ensure camera is powered on + +**Tests fail with authentication errors:** +- Verify username and password are correct +- Check if camera requires digest authentication +- Ensure user has appropriate permissions + +**Tests fail with unexpected values:** +- Camera firmware may have been updated +- Camera settings may have been changed +- Update expected values in tests to match current configuration + +### Notes + +- These tests require a physical camera or camera simulator +- Tests modify NO camera settings (read-only operations) +- Some tests may take several seconds due to network communication +- Camera responses may vary based on firmware version and configuration diff --git a/README.md b/README.md index 9045c3b..7b30743 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,16 @@ client, err := onvif.NewClient( | `GetSystemDateAndTime()` | Get device system time | | `SystemReboot()` | Reboot the device | | `Initialize()` | Discover and cache service endpoints | +| `GetHostname()` | Get device hostname configuration | +| `SetHostname()` | Set device hostname | +| `GetDNS()` | Get DNS configuration | +| `GetNTP()` | Get NTP configuration | +| `GetNetworkInterfaces()` | Get network interface configuration | +| `GetScopes()` | Get configured discovery scopes | +| `GetUsers()` | Get list of user accounts | +| `CreateUsers()` | Create new user accounts | +| `DeleteUsers()` | Delete user accounts | +| `SetUser()` | Modify existing user account | ### Media Service @@ -215,6 +225,12 @@ client, err := onvif.NewClient( | `GetStreamURI()` | Get RTSP/HTTP stream URI | | `GetSnapshotURI()` | Get snapshot image URI | | `GetVideoEncoderConfiguration()` | Get video encoder settings | +| `GetVideoSources()` | Get all video sources | +| `GetAudioSources()` | Get all audio sources | +| `GetAudioOutputs()` | Get all audio outputs | +| `CreateProfile()` | Create new media profile | +| `DeleteProfile()` | Delete media profile | +| `SetVideoEncoderConfiguration()` | Set video encoder configuration | ### PTZ Service @@ -227,6 +243,12 @@ client, err := onvif.NewClient( | `GetStatus()` | Get current PTZ status and position | | `GetPresets()` | Get list of PTZ presets | | `GotoPreset()` | Move to a preset position | +| `SetPreset()` | Save current position as preset | +| `RemovePreset()` | Delete a preset | +| `GotoHomePosition()` | Move to home position | +| `SetHomePosition()` | Set current position as home | +| `GetConfiguration()` | Get PTZ configuration | +| `GetConfigurations()` | Get all PTZ configurations | ### Imaging Service @@ -235,6 +257,10 @@ client, err := onvif.NewClient( | `GetImagingSettings()` | Get imaging settings (brightness, contrast, etc.) | | `SetImagingSettings()` | Set imaging settings | | `Move()` | Perform focus move operations | +| `GetOptions()` | Get available imaging options and ranges | +| `GetMoveOptions()` | Get available focus move options | +| `StopFocus()` | Stop focus movement | +| `GetImagingStatus()` | Get current imaging/focus status | ### Discovery Service @@ -415,6 +441,62 @@ Contributions are welcome! Please feel free to submit a Pull Request. For major - [ ] Performance benchmarks - [ ] CLI tool for camera management +## Debugging Tools + +### 🔍 Diagnostic Utility + +Comprehensive camera testing and analysis with optional XML capture: + +```bash +go build -o onvif-diagnostics ./cmd/onvif-diagnostics/ + +# Standard diagnostic report +./onvif-diagnostics \ + -endpoint "http://camera-ip/onvif/device_service" \ + -username "admin" \ + -password "pass" \ + -verbose + +# With raw SOAP XML capture for debugging +./onvif-diagnostics \ + -endpoint "http://camera-ip/onvif/device_service" \ + -username "admin" \ + -password "pass" \ + -capture-xml \ + -verbose +``` + +**Generates**: +- `camera-logs/Manufacturer_Model_Firmware_timestamp.json` - Diagnostic report +- `camera-logs/Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz` - Raw XML (with `-capture-xml`) + +**See**: `XML_DEBUGGING_SOLUTION.md` for complete debugging workflow + +### 🧪 Camera Test Framework + +Automated regression testing using captured camera responses: + +```bash +# 1. Capture from camera +./onvif-diagnostics -endpoint "http://camera/onvif/device_service" \ + -username "user" -password "pass" -capture-xml + +# 2. Generate test +go build -o generate-tests ./cmd/generate-tests/ +./generate-tests -capture camera-logs/*_xmlcapture_*.tar.gz -output testdata/captures/ + +# 3. Run tests +go test -v ./testdata/captures/ +``` + +**Benefits**: +- Test without physical cameras +- Prevent regressions across camera models +- Fast CI/CD integration +- Real camera response validation + +**See**: `testdata/captures/README.md` for complete testing guide + ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/TEST_QUICKSTART.md b/TEST_QUICKSTART.md new file mode 100644 index 0000000..08d974b --- /dev/null +++ b/TEST_QUICKSTART.md @@ -0,0 +1,116 @@ +# Quick Test Reference + +## Running Camera Tests + +### Option 1: Using the test script (Recommended) +```bash +# Set credentials +export ONVIF_TEST_ENDPOINT="http://192.168.1.201/onvif/device_service" +export ONVIF_TEST_USERNAME="service" +export ONVIF_TEST_PASSWORD="Service.1234" + +# Run all Bosch FLEXIDOME tests +./run-camera-tests.sh + +# Run specific test +./run-camera-tests.sh TestBoschFLEXIDOMEIndoor5100iIR_GetDeviceInformation +``` + +### Option 2: Direct go test commands +```bash +# Run all camera tests +go test -v -run TestBoschFLEXIDOMEIndoor5100iIR + +# Run specific test +go test -v -run TestBoschFLEXIDOMEIndoor5100iIR_GetStreamURI + +# Run with race detection +go test -v -race -run TestBoschFLEXIDOMEIndoor5100iIR + +# Run benchmarks +go test -v -bench=BenchmarkBoschFLEXIDOMEIndoor5100iIR -benchmem +``` + +### Option 3: One-liner with credentials +```bash +ONVIF_TEST_ENDPOINT="http://192.168.1.201/onvif/device_service" \ +ONVIF_TEST_USERNAME="service" \ +ONVIF_TEST_PASSWORD="Service.1234" \ +go test -v -run TestBoschFLEXIDOMEIndoor5100iIR +``` + +## Test List + +### Device Tests +- `TestBoschFLEXIDOMEIndoor5100iIR_GetDeviceInformation` - Device info retrieval +- `TestBoschFLEXIDOMEIndoor5100iIR_GetSystemDateAndTime` - System time +- `TestBoschFLEXIDOMEIndoor5100iIR_GetCapabilities` - Capability discovery + +### Media Tests +- `TestBoschFLEXIDOMEIndoor5100iIR_GetProfiles` - Media profiles (4 expected) +- `TestBoschFLEXIDOMEIndoor5100iIR_GetStreamURI` - RTSP stream URIs +- `TestBoschFLEXIDOMEIndoor5100iIR_GetSnapshotURI` - Snapshot URLs +- `TestBoschFLEXIDOMEIndoor5100iIR_GetVideoEncoderConfiguration` - Encoder settings + +### Imaging Tests +- `TestBoschFLEXIDOMEIndoor5100iIR_GetImagingSettings` - Camera imaging parameters + +### Integration Tests +- `TestBoschFLEXIDOMEIndoor5100iIR_Initialize` - Service discovery +- `TestBoschFLEXIDOMEIndoor5100iIR_FullWorkflow` - Complete operation sequence + +### Performance Tests +- `BenchmarkBoschFLEXIDOMEIndoor5100iIR_GetDeviceInformation` - Device info benchmark +- `BenchmarkBoschFLEXIDOMEIndoor5100iIR_GetStreamURI` - Stream URI benchmark + +## Expected Test Results + +All tests should **PASS** with the following outputs: + +``` +✓ Manufacturer: Bosch +✓ Model: FLEXIDOME indoor 5100i IR +✓ 4 Profiles found (1920x1080, 1536x864, 1280x720, 512x288) +✓ All profiles have RTSP stream URIs +✓ Snapshot URI available +✓ Video encoding: H264 @ 30fps, 5200kbps +✓ Default imaging: Brightness 128.0, Saturation 128.0, Contrast 128.0 +``` + +## Troubleshooting + +### Tests are skipped +**Solution**: Set environment variables with camera credentials + +### Connection timeout +**Solutions**: +- Verify camera IP address +- Check network connectivity +- Ensure firewall allows connection + +### Authentication failed +**Solutions**: +- Verify username and password +- Check user permissions on camera + +### Unexpected values +**Note**: Camera settings may differ based on: +- Firmware version +- Manual configuration changes +- Update test expectations if needed + +## Coverage Report + +Generate test coverage: +```bash +go test -coverprofile=coverage.out -run TestBoschFLEXIDOMEIndoor5100iIR +go tool cover -html=coverage.out +``` + +## Adding New Camera Tests + +1. Copy `bosch_flexidome_test.go` to `__test.go` +2. Update test function names +3. Update expected values +4. Run tests to verify +5. Document in CAMERA_TESTS.md diff --git a/XML_DEBUGGING_SOLUTION.md b/XML_DEBUGGING_SOLUTION.md new file mode 100644 index 0000000..688d21b --- /dev/null +++ b/XML_DEBUGGING_SOLUTION.md @@ -0,0 +1,380 @@ +# ONVIF Debugging Solution + +## Problem + +The diagnostic utility (`onvif-diagnostics`) logs only parsed JSON results. When XML parsing fails or responses are unexpected, you can't see the raw SOAP XML to debug the issue. + +## Solution + +The `onvif-diagnostics` utility now includes built-in XML capture functionality via the `-capture-xml` flag. This captures raw SOAP request/response XML and creates a compressed tar.gz archive. + +## What Changed + +### 1. Enhanced SOAP Client (`soap/soap.go`) + +Added debug logging capability: + +```go +type Client struct { + httpClient *http.Client + username string + password string + debug bool // NEW + logger func(format string, args ...interface{}) // NEW +} + +// New methods: +func (c *Client) SetDebug(enabled bool, logger func(format string, args ...interface{})) +func (c *Client) logDebug(format string, args ...interface{}) +``` + +The SOAP client now logs requests/responses when debug mode is enabled. + +### 2. Integrated XML Capture in `onvif-diagnostics` + +Location: `cmd/onvif-diagnostics/main.go` + +Features: +- Single command for both diagnostic report and XML capture +- `-capture-xml` flag enables raw SOAP traffic capture +- Creates compressed tar.gz archive with camera identification +- Archive naming: `Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz` +- Saves to `camera-logs/` directory (same as diagnostic report) +- Automatic cleanup of temporary files + +## Usage + +### Quick Start + +```bash +# Build the utility +go build -o onvif-diagnostics ./cmd/onvif-diagnostics/ + +# Run with XML capture enabled +./onvif-diagnostics \ + -endpoint "http://192.168.1.164/onvif/device_service" \ + -username "admin" \ + -password "password" \ + -capture-xml \ + -verbose +``` + +This creates two files: +- `Manufacturer_Model_Firmware_timestamp.json` - Diagnostic report +- `Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz` - Raw SOAP XML archive + +### Without XML Capture (Faster) + +```bash +# Just diagnostic report +./onvif-diagnostics \ + -endpoint "http://192.168.1.164/onvif/device_service" \ + -username "admin" \ + -password "password" \ + -verbose +``` + +### Extract and Analyze XML + +```bash +# Extract the archive +tar -xzf camera-logs/Camera_Model_xmlcapture_timestamp.tar.gz -C /tmp/xml-debug + +# View files (now with operation names) +ls /tmp/xml-debug/ +# capture_001_GetDeviceInformation.json +# capture_001_GetDeviceInformation_request.xml +# capture_001_GetDeviceInformation_response.xml +# capture_002_GetSystemDateAndTime.json +# ... +``` + +## Workflow + +### 1. Run Diagnostic with XML Capture + +```bash +./onvif-diagnostics \ + -endpoint "http://camera-ip/onvif/device_service" \ + -username "user" \ + -password "pass" \ + -capture-xml \ + -verbose +``` + +This generates both: +- JSON diagnostic report +- tar.gz XML capture archive + +### 2. Review Diagnostic Report + +Check the JSON file for errors: +```bash +cat camera-logs/Camera_Model_Firmware_timestamp.json | jq '.errors' +``` + +### 3. Analyze Raw XML (if needed) + +Extract and inspect the XML archive: +```bash +tar -xzf camera-logs/Camera_Model_xmlcapture_timestamp.tar.gz -C /tmp/xml-debug +``` + +### 3. Analyze Raw XML + +```bash +# Extract the archive +tar -xzf camera-logs/Camera_Model_xmlcapture_timestamp.tar.gz -C /tmp/xml-debug + +# View specific operation (now easier to find) +cat /tmp/xml-debug/capture_*_GetCapabilities_response.xml + +# Search for errors +grep "Fault" /tmp/xml-debug/capture_*_response.xml + +# Pretty-print (XML is already formatted with indentation) +cat /tmp/xml-debug/capture_001_GetDeviceInformation_response.xml +``` + +## Example: Debugging AXIS Q3626-VE Localhost Issue + +### Problem (from diagnostic report) + +```json +{ + "operation": "GetProfiles", + "error": "Post \"http://127.0.0.1/onvif/services\": EOF" +} +``` + +### Capture XML + +```bash +### Capture XML + +```bash +./onvif-diagnostics \ + -endpoint "http://192.168.1.164/onvif/device_service" \ + -username "admin" \ + -password "password" \ + -capture-xml \ + -verbose +``` + +Result: +- `camera-logs/AXIS_Q3626-VE_12.6.104_20251110-120000.json` +- `camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-120000.tar.gz` +``` + +Result: `camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-120000.tar.gz` + +### Analyze Response + +```bash +tar -xzf camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-120000.tar.gz +cat capture_*_GetCapabilities_response.xml | grep XAddr +``` + +Shows: + +```xml + + http://127.0.0.1/onvif/services + +``` + +### Root Cause + +Camera returns `127.0.0.1` instead of actual IP `192.168.1.164`, causing client to connect to localhost. + +### Solution Required + +Client needs to rewrite localhost addresses: + +```go +if strings.Contains(xAddr, "127.0.0.1") || strings.Contains(xAddr, "localhost") { + // Replace with actual camera IP from original endpoint +} +``` + +## Example: Debugging Bosch Panoramic "Incomplete Configuration" + +### Problem (from diagnostic report) + +```json +{ + "operation": "GetStreamURI[9]", + "error": "ter:IncompleteConfiguration - Configuration not complete" +} +``` + +### Capture XML + +```bash +### Capture XML + +```bash +./onvif-diagnostics \ + -endpoint "http://192.168.2.24/onvif/device_service" \ + -username "service" \ + -password "Service.1234" \ + -capture-xml \ + -verbose +``` + +Result: +- `camera-logs/Bosch_FLEXIDOME_panoramic_5100i_9.00.0210_20251110.json` +- `camera-logs/Bosch_FLEXIDOME_panoramic_5100i_9.00.0210_xmlcapture_20251110.tar.gz` +``` + +### Analyze Response + +```bash +tar -xzf camera-logs/Bosch_FLEXIDOME_panoramic_5100i_*_xmlcapture_*.tar.gz +# Look for GetStreamUri operation (easy to find by name) +cat capture_*_GetStreamUri_response.xml +``` + +Result: + +```xml + + + + ter:IncompleteConfiguration + + + + Configuration not complete + + +``` + +### Root Cause + +Profile 9 has `VideoEncoderConfiguration: null` in the profiles response. Can't get stream URI for profile without video encoder. + +### Solution + +Skip GetStreamURI for profiles without VideoEncoderConfiguration: + +```go +if profile.VideoEncoderConfiguration == nil { + // Skip - this is audio-only or metadata-only profile + continue +} +``` + +## Files Created + +### SOAP Client Enhancement +- `soap/soap.go` - Added debug logging capability + +### Diagnostic Utility Enhancement +- `cmd/onvif-diagnostics/main.go` - Added XML capture functionality with `-capture-xml` flag + +## Output Organization + +All debugging files are saved to the same `camera-logs/` directory: + +``` +camera-logs/ +├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_20251107-193656.json # Diagnostic report +├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110.tar.gz # XML capture archive +├── AXIS_Q3626-VE_12.6.104_20251108-212157.json +├── AXIS_Q3626-VE_12.6.104_xmlcapture_20251108-213000.tar.gz +└── Bosch_FLEXIDOME_panoramic_5100i_9.00.0210_20251107-195636.json +``` + +### Archive Contents + +Each tar.gz archive contains the captured XML files with descriptive operation names: + +```bash +$ tar -tzf camera-logs/Bosch_FLEXIDOME_indoor_5100i_IR_*_xmlcapture_*.tar.gz +capture_001_GetDeviceInformation.json +capture_001_GetDeviceInformation_request.xml +capture_001_GetDeviceInformation_response.xml +capture_002_GetSystemDateAndTime.json +capture_002_GetSystemDateAndTime_request.xml +capture_002_GetSystemDateAndTime_response.xml +capture_003_GetCapabilities.json +capture_003_GetCapabilities_request.xml +capture_003_GetCapabilities_response.xml +... +``` + +Each file is named with both a sequence number and the SOAP operation name for easy identification. + +## Benefits + +1. **Complete Visibility**: See exact SOAP XML sent/received +2. **Namespace Debugging**: Identify namespace mismatches +3. **Fault Analysis**: See detailed SOAP fault information +4. **Comparison**: Compare working vs failing cameras +5. **Easy Sharing**: Compressed archives (< 10KB) easy to share via email +6. **Organized**: All camera logs in one directory with consistent naming +7. **Privacy**: Review and sanitize XML before sharing archives + +## Next Steps + +When you encounter errors in the diagnostic report: + +1. ✅ Run `onvif-diagnostics` to identify which operations fail +2. ✅ Re-run with `-capture-xml` flag to capture raw XML +3. ✅ Extract and analyze the tar.gz archive +4. ✅ Share both files (JSON report + tar.gz archive) for debugging assistance + +## Command-Line Flags + +``` +-endpoint string + ONVIF device endpoint (required) + +-username string + Username for authentication (required) + +-password string + Password for authentication (required) + +-output string + Output directory (default: "./camera-logs") + +-timeout int + Request timeout in seconds (default: 30) + +-verbose + Enable verbose output + +-capture-xml + Capture raw SOAP XML traffic and create tar.gz archive +``` + +## Output Structure + +### Before (separate files): +``` +xml-captures/ +└── 20251110-095000/ + ├── capture_001.json + ├── capture_001_request.xml + ├── capture_001_response.xml + └── ... +``` + +### Now (compressed archives): +``` +camera-logs/ +├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_20251107-193656.json +├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-115830.tar.gz (5KB) +├── AXIS_Q3626-VE_12.6.104_20251108-212157.json +└── AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-120000.tar.gz (3KB) +``` + +## Tips + +- Use `-operation` to test specific failing operations +- Check response XML for `` elements +- Compare namespace prefixes (tds, trt, tt, etc.) +- Look for XAddr values in capabilities response +- Verify authentication headers in request XML diff --git a/device.go b/device.go index 49d0f68..0d5b55e 100644 --- a/device.go +++ b/device.go @@ -35,7 +35,7 @@ func (c *Client) GetDeviceInformation(ctx context.Context) (*DeviceInformation, username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetDeviceInformation failed: %w", err) } @@ -129,7 +129,7 @@ func (c *Client) GetCapabilities(ctx context.Context) (*Capabilities, error) { username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetCapabilities failed: %w", err) } @@ -250,7 +250,7 @@ func (c *Client) SystemReboot(ctx context.Context) (string, error) { username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { return "", fmt.Errorf("SystemReboot failed: %w", err) } @@ -273,10 +273,432 @@ func (c *Client) GetSystemDateAndTime(ctx context.Context) (interface{}, error) username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetSystemDateAndTime failed: %w", err) } return resp, nil } + +// GetHostname retrieves the device's hostname +func (c *Client) GetHostname(ctx context.Context) (*HostnameInformation, error) { + type GetHostname struct { + XMLName xml.Name `xml:"tds:GetHostname"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetHostnameResponse struct { + XMLName xml.Name `xml:"GetHostnameResponse"` + HostnameInformation struct { + FromDHCP bool `xml:"FromDHCP"` + Name string `xml:"Name"` + } `xml:"HostnameInformation"` + } + + req := GetHostname{ + Xmlns: deviceNamespace, + } + + var resp GetHostnameResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetHostname failed: %w", err) + } + + return &HostnameInformation{ + FromDHCP: resp.HostnameInformation.FromDHCP, + Name: resp.HostnameInformation.Name, + }, nil +} + +// SetHostname sets the device's hostname +func (c *Client) SetHostname(ctx context.Context, name string) error { + type SetHostname struct { + XMLName xml.Name `xml:"tds:SetHostname"` + Xmlns string `xml:"xmlns:tds,attr"` + Name string `xml:"tds:Name"` + } + + req := SetHostname{ + Xmlns: deviceNamespace, + Name: name, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, nil); err != nil { + return fmt.Errorf("SetHostname failed: %w", err) + } + + return nil +} + +// GetDNS retrieves DNS configuration +func (c *Client) GetDNS(ctx context.Context) (*DNSInformation, error) { + type GetDNS struct { + XMLName xml.Name `xml:"tds:GetDNS"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetDNSResponse struct { + XMLName xml.Name `xml:"GetDNSResponse"` + DNSInformation struct { + FromDHCP bool `xml:"FromDHCP"` + SearchDomain []string `xml:"SearchDomain"` + DNSFromDHCP []struct { + Type string `xml:"Type"` + IPv4Address string `xml:"IPv4Address"` + } `xml:"DNSFromDHCP"` + DNSManual []struct { + Type string `xml:"Type"` + IPv4Address string `xml:"IPv4Address"` + } `xml:"DNSManual"` + } `xml:"DNSInformation"` + } + + req := GetDNS{ + Xmlns: deviceNamespace, + } + + var resp GetDNSResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetDNS failed: %w", err) + } + + dns := &DNSInformation{ + FromDHCP: resp.DNSInformation.FromDHCP, + SearchDomain: resp.DNSInformation.SearchDomain, + } + + for _, d := range resp.DNSInformation.DNSFromDHCP { + dns.DNSFromDHCP = append(dns.DNSFromDHCP, IPAddress{ + Type: d.Type, + IPv4Address: d.IPv4Address, + }) + } + + for _, d := range resp.DNSInformation.DNSManual { + dns.DNSManual = append(dns.DNSManual, IPAddress{ + Type: d.Type, + IPv4Address: d.IPv4Address, + }) + } + + return dns, nil +} + +// GetNTP retrieves NTP configuration +func (c *Client) GetNTP(ctx context.Context) (*NTPInformation, error) { + type GetNTP struct { + XMLName xml.Name `xml:"tds:GetNTP"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetNTPResponse struct { + XMLName xml.Name `xml:"GetNTPResponse"` + NTPInformation struct { + FromDHCP bool `xml:"FromDHCP"` + NTPFromDHCP []struct { + Type string `xml:"Type"` + IPv4Address string `xml:"IPv4Address"` + DNSname string `xml:"DNSname"` + } `xml:"NTPFromDHCP"` + NTPManual []struct { + Type string `xml:"Type"` + IPv4Address string `xml:"IPv4Address"` + DNSname string `xml:"DNSname"` + } `xml:"NTPManual"` + } `xml:"NTPInformation"` + } + + req := GetNTP{ + Xmlns: deviceNamespace, + } + + var resp GetNTPResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetNTP failed: %w", err) + } + + ntp := &NTPInformation{ + FromDHCP: resp.NTPInformation.FromDHCP, + } + + for _, n := range resp.NTPInformation.NTPFromDHCP { + ntp.NTPFromDHCP = append(ntp.NTPFromDHCP, NetworkHost{ + Type: n.Type, + IPv4Address: n.IPv4Address, + DNSname: n.DNSname, + }) + } + + for _, n := range resp.NTPInformation.NTPManual { + ntp.NTPManual = append(ntp.NTPManual, NetworkHost{ + Type: n.Type, + IPv4Address: n.IPv4Address, + DNSname: n.DNSname, + }) + } + + return ntp, nil +} + +// GetNetworkInterfaces retrieves network interface configuration +func (c *Client) GetNetworkInterfaces(ctx context.Context) ([]*NetworkInterface, error) { + type GetNetworkInterfaces struct { + XMLName xml.Name `xml:"tds:GetNetworkInterfaces"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetNetworkInterfacesResponse struct { + XMLName xml.Name `xml:"GetNetworkInterfacesResponse"` + NetworkInterfaces []struct { + Token string `xml:"token,attr"` + Enabled bool `xml:"Enabled"` + Info struct { + Name string `xml:"Name"` + HwAddress string `xml:"HwAddress"` + MTU int `xml:"MTU"` + } `xml:"Info"` + IPv4 struct { + Enabled bool `xml:"Enabled"` + Config struct { + Manual []struct { + Address string `xml:"Address"` + PrefixLength int `xml:"PrefixLength"` + } `xml:"Manual"` + DHCP bool `xml:"DHCP"` + } `xml:"Config"` + } `xml:"IPv4"` + } `xml:"NetworkInterfaces"` + } + + req := GetNetworkInterfaces{ + Xmlns: deviceNamespace, + } + + var resp GetNetworkInterfacesResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetNetworkInterfaces failed: %w", err) + } + + interfaces := make([]*NetworkInterface, len(resp.NetworkInterfaces)) + for i, iface := range resp.NetworkInterfaces { + ni := &NetworkInterface{ + Token: iface.Token, + Enabled: iface.Enabled, + Info: NetworkInterfaceInfo{ + Name: iface.Info.Name, + HwAddress: iface.Info.HwAddress, + MTU: iface.Info.MTU, + }, + } + + if iface.IPv4.Enabled { + ni.IPv4 = &IPv4NetworkInterface{ + Enabled: iface.IPv4.Enabled, + Config: IPv4Configuration{ + DHCP: iface.IPv4.Config.DHCP, + }, + } + + for _, m := range iface.IPv4.Config.Manual { + ni.IPv4.Config.Manual = append(ni.IPv4.Config.Manual, PrefixedIPv4Address{ + Address: m.Address, + PrefixLength: m.PrefixLength, + }) + } + } + + interfaces[i] = ni + } + + return interfaces, nil +} + +// GetScopes retrieves configured scopes +func (c *Client) GetScopes(ctx context.Context) ([]*Scope, error) { + type GetScopes struct { + XMLName xml.Name `xml:"tds:GetScopes"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetScopesResponse struct { + XMLName xml.Name `xml:"GetScopesResponse"` + Scopes []struct { + ScopeDef string `xml:"ScopeDef"` + ScopeItem string `xml:"ScopeItem"` + } `xml:"Scopes"` + } + + req := GetScopes{ + Xmlns: deviceNamespace, + } + + var resp GetScopesResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetScopes failed: %w", err) + } + + scopes := make([]*Scope, len(resp.Scopes)) + for i, s := range resp.Scopes { + scopes[i] = &Scope{ + ScopeDef: s.ScopeDef, + ScopeItem: s.ScopeItem, + } + } + + return scopes, nil +} + +// GetUsers retrieves user accounts +func (c *Client) GetUsers(ctx context.Context) ([]*User, error) { + type GetUsers struct { + XMLName xml.Name `xml:"tds:GetUsers"` + Xmlns string `xml:"xmlns:tds,attr"` + } + + type GetUsersResponse struct { + XMLName xml.Name `xml:"GetUsersResponse"` + User []struct { + Username string `xml:"Username"` + UserLevel string `xml:"UserLevel"` + } `xml:"User"` + } + + req := GetUsers{ + Xmlns: deviceNamespace, + } + + var resp GetUsersResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetUsers failed: %w", err) + } + + users := make([]*User, len(resp.User)) + for i, u := range resp.User { + users[i] = &User{ + Username: u.Username, + UserLevel: u.UserLevel, + } + } + + return users, nil +} + +// CreateUsers creates new user accounts +func (c *Client) CreateUsers(ctx context.Context, users []*User) error { + type CreateUsers struct { + XMLName xml.Name `xml:"tds:CreateUsers"` + Xmlns string `xml:"xmlns:tds,attr"` + User []struct { + Username string `xml:"tds:Username"` + Password string `xml:"tds:Password"` + UserLevel string `xml:"tds:UserLevel"` + } `xml:"tds:User"` + } + + req := CreateUsers{ + Xmlns: deviceNamespace, + } + + for _, user := range users { + req.User = append(req.User, struct { + Username string `xml:"tds:Username"` + Password string `xml:"tds:Password"` + UserLevel string `xml:"tds:UserLevel"` + }{ + Username: user.Username, + Password: user.Password, + UserLevel: user.UserLevel, + }) + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, nil); err != nil { + return fmt.Errorf("CreateUsers failed: %w", err) + } + + return nil +} + +// DeleteUsers deletes user accounts +func (c *Client) DeleteUsers(ctx context.Context, usernames []string) error { + type DeleteUsers struct { + XMLName xml.Name `xml:"tds:DeleteUsers"` + Xmlns string `xml:"xmlns:tds,attr"` + Username []string `xml:"tds:Username"` + } + + req := DeleteUsers{ + Xmlns: deviceNamespace, + Username: usernames, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, nil); err != nil { + return fmt.Errorf("DeleteUsers failed: %w", err) + } + + return nil +} + +// SetUser modifies an existing user account +func (c *Client) SetUser(ctx context.Context, user *User) error { + type SetUser struct { + XMLName xml.Name `xml:"tds:SetUser"` + Xmlns string `xml:"xmlns:tds,attr"` + User struct { + Username string `xml:"tds:Username"` + Password *string `xml:"tds:Password,omitempty"` + UserLevel string `xml:"tds:UserLevel"` + } `xml:"tds:User"` + } + + req := SetUser{ + Xmlns: deviceNamespace, + } + req.User.Username = user.Username + if user.Password != "" { + req.User.Password = &user.Password + } + req.User.UserLevel = user.UserLevel + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, c.endpoint, "", req, nil); err != nil { + return fmt.Errorf("SetUser failed: %w", err) + } + + return nil +} diff --git a/examples/comprehensive-test/main.go b/examples/comprehensive-test/main.go new file mode 100644 index 0000000..3ed98e5 --- /dev/null +++ b/examples/comprehensive-test/main.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/0x524A/go-onvif" +) + +func main() { + // Camera connection details + endpoint := "http://192.168.1.201/onvif/device_service" + username := "service" + password := "Service.1234" + + fmt.Println("=== Comprehensive ONVIF Camera Test ===") + fmt.Println("Connecting to:", endpoint) + fmt.Println() + + // Create client + client, err := onvif.NewClient( + endpoint, + onvif.WithCredentials(username, password), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + // Test 1: Get Device Information + fmt.Println("=== Test 1: GetDeviceInformation ===") + info, err := client.GetDeviceInformation(ctx) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Manufacturer: %s\n", info.Manufacturer) + fmt.Printf("✓ Model: %s\n", info.Model) + fmt.Printf("✓ Firmware: %s\n", info.FirmwareVersion) + fmt.Printf("✓ Serial Number: %s\n", info.SerialNumber) + fmt.Printf("✓ Hardware ID: %s\n", info.HardwareID) + } + fmt.Println() + + // Test 2: Get System Date and Time + fmt.Println("=== Test 2: GetSystemDateAndTime ===") + dateTime, err := client.GetSystemDateAndTime(ctx) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ System Date/Time: %+v\n", dateTime) + } + fmt.Println() + + // Test 3: Get Capabilities + fmt.Println("=== Test 3: GetCapabilities ===") + capabilities, err := client.GetCapabilities(ctx) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Println("✓ Capabilities retrieved successfully:") + if capabilities.Device != nil { + fmt.Printf(" - Device: %s\n", capabilities.Device.XAddr) + } + if capabilities.Media != nil { + fmt.Printf(" - Media: %s\n", capabilities.Media.XAddr) + } + if capabilities.PTZ != nil { + fmt.Printf(" - PTZ: %s\n", capabilities.PTZ.XAddr) + } + if capabilities.Imaging != nil { + fmt.Printf(" - Imaging: %s\n", capabilities.Imaging.XAddr) + } + if capabilities.Events != nil { + fmt.Printf(" - Events: %s\n", capabilities.Events.XAddr) + } + if capabilities.Analytics != nil { + fmt.Printf(" - Analytics: %s\n", capabilities.Analytics.XAddr) + } + } + fmt.Println() + + // Initialize client to discover service endpoints + fmt.Println("=== Test 4: Initialize (Discover Services) ===") + if err := client.Initialize(ctx); err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Println("✓ Services discovered successfully") + } + fmt.Println() + + // Test 5: Get Media Profiles + fmt.Println("=== Test 5: GetProfiles ===") + profiles, err := client.GetProfiles(ctx) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Found %d profile(s)\n", len(profiles)) + for i, profile := range profiles { + fmt.Printf(" Profile %d: %s (Token: %s)\n", i+1, profile.Name, profile.Token) + if profile.VideoEncoderConfiguration != nil { + fmt.Printf(" - Encoding: %s\n", profile.VideoEncoderConfiguration.Encoding) + if profile.VideoEncoderConfiguration.Resolution != nil { + fmt.Printf(" - Resolution: %dx%d\n", + profile.VideoEncoderConfiguration.Resolution.Width, + profile.VideoEncoderConfiguration.Resolution.Height) + } + } + } + } + fmt.Println() + + // Test 6: Get Stream URIs + fmt.Println("=== Test 6: GetStreamURI (for first profile) ===") + if len(profiles) > 0 { + streamURI, err := client.GetStreamURI(ctx, profiles[0].Token) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Stream URI: %s\n", streamURI.URI) + fmt.Printf(" - Invalid After Connect: %v\n", streamURI.InvalidAfterConnect) + fmt.Printf(" - Invalid After Reboot: %v\n", streamURI.InvalidAfterReboot) + } + } + fmt.Println() + + // Test 7: Get Snapshot URI + fmt.Println("=== Test 7: GetSnapshotURI (for first profile) ===") + if len(profiles) > 0 { + snapshotURI, err := client.GetSnapshotURI(ctx, profiles[0].Token) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Snapshot URI: %s\n", snapshotURI.URI) + } + } + fmt.Println() + + // Test 8: Get Video Encoder Configuration + fmt.Println("=== Test 8: GetVideoEncoderConfiguration ===") + if len(profiles) > 0 && profiles[0].VideoEncoderConfiguration != nil { + config, err := client.GetVideoEncoderConfiguration(ctx, profiles[0].VideoEncoderConfiguration.Token) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Video Encoder Configuration:\n") + fmt.Printf(" - Name: %s\n", config.Name) + fmt.Printf(" - Encoding: %s\n", config.Encoding) + if config.Resolution != nil { + fmt.Printf(" - Resolution: %dx%d\n", config.Resolution.Width, config.Resolution.Height) + } + fmt.Printf(" - Quality: %.1f\n", config.Quality) + if config.RateControl != nil { + fmt.Printf(" - Frame Rate Limit: %d\n", config.RateControl.FrameRateLimit) + fmt.Printf(" - Bitrate Limit: %d\n", config.RateControl.BitrateLimit) + } + } + } + fmt.Println() + + // Test 9: PTZ Operations (if PTZ is available) + fmt.Println("=== Test 9: PTZ Operations ===") + if len(profiles) > 0 && profiles[0].PTZConfiguration != nil { + fmt.Println("PTZ configuration detected, testing PTZ operations...") + + // Get PTZ Status + ptzStatus, err := client.GetStatus(ctx, profiles[0].Token) + if err != nil { + log.Printf("ERROR getting PTZ status: %v\n", err) + } else { + fmt.Printf("✓ PTZ Status retrieved\n") + if ptzStatus.Position != nil { + if ptzStatus.Position.PanTilt != nil { + fmt.Printf(" - Pan/Tilt Position: X=%.2f, Y=%.2f\n", + ptzStatus.Position.PanTilt.X, + ptzStatus.Position.PanTilt.Y) + } + if ptzStatus.Position.Zoom != nil { + fmt.Printf(" - Zoom Position: %.2f\n", ptzStatus.Position.Zoom.X) + } + } + if ptzStatus.MoveStatus != nil { + fmt.Printf(" - Pan/Tilt Move Status: %s\n", ptzStatus.MoveStatus.PanTilt) + fmt.Printf(" - Zoom Move Status: %s\n", ptzStatus.MoveStatus.Zoom) + } + } + + // Get PTZ Presets + presets, err := client.GetPresets(ctx, profiles[0].Token) + if err != nil { + log.Printf("ERROR getting PTZ presets: %v\n", err) + } else { + fmt.Printf("✓ Found %d PTZ preset(s)\n", len(presets)) + for i, preset := range presets { + fmt.Printf(" Preset %d: %s (Token: %s)\n", i+1, preset.Name, preset.Token) + } + } + } else { + fmt.Println("⊘ No PTZ configuration found for this profile") + } + fmt.Println() + + // Test 10: Imaging Settings + fmt.Println("=== Test 10: Imaging Settings ===") + if len(profiles) > 0 && profiles[0].VideoSourceConfiguration != nil { + settings, err := client.GetImagingSettings(ctx, profiles[0].VideoSourceConfiguration.SourceToken) + if err != nil { + log.Printf("ERROR: %v\n", err) + } else { + fmt.Printf("✓ Imaging Settings:\n") + if settings.Brightness != nil { + fmt.Printf(" - Brightness: %.1f\n", *settings.Brightness) + } + if settings.ColorSaturation != nil { + fmt.Printf(" - Color Saturation: %.1f\n", *settings.ColorSaturation) + } + if settings.Contrast != nil { + fmt.Printf(" - Contrast: %.1f\n", *settings.Contrast) + } + if settings.Sharpness != nil { + fmt.Printf(" - Sharpness: %.1f\n", *settings.Sharpness) + } + if settings.IrCutFilter != nil { + fmt.Printf(" - IR Cut Filter: %s\n", *settings.IrCutFilter) + } + if settings.BacklightCompensation != nil { + fmt.Printf(" - Backlight Compensation: %s (Level: %.1f)\n", + settings.BacklightCompensation.Mode, + settings.BacklightCompensation.Level) + } + if settings.Exposure != nil { + fmt.Printf(" - Exposure Mode: %s\n", settings.Exposure.Mode) + fmt.Printf(" Priority: %s\n", settings.Exposure.Priority) + } + if settings.Focus != nil { + fmt.Printf(" - Focus Mode: %s\n", settings.Focus.AutoFocusMode) + } + if settings.WhiteBalance != nil { + fmt.Printf(" - White Balance Mode: %s\n", settings.WhiteBalance.Mode) + } + if settings.WideDynamicRange != nil { + fmt.Printf(" - Wide Dynamic Range: %s (Level: %.1f)\n", + settings.WideDynamicRange.Mode, + settings.WideDynamicRange.Level) + } + } + } + fmt.Println() + + fmt.Println("=== Test Summary ===") + fmt.Println("All tests completed!") +} diff --git a/examples/debug-soap/main.go b/examples/debug-soap/main.go new file mode 100644 index 0000000..aeb4ef2 --- /dev/null +++ b/examples/debug-soap/main.go @@ -0,0 +1,152 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "time" +) + +// SOAP Envelope structures +type Envelope struct { + XMLName xml.Name `xml:"http://www.w3.org/2003/05/soap-envelope Envelope"` + Header *Header `xml:"http://www.w3.org/2003/05/soap-envelope Header,omitempty"` + Body Body `xml:"http://www.w3.org/2003/05/soap-envelope Body"` +} + +type Header struct { + Security *Security `xml:"Security,omitempty"` +} + +type Body struct { + Content interface{} `xml:",omitempty"` +} + +type Security struct { + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd Security"` + MustUnderstand string `xml:"http://www.w3.org/2003/05/soap-envelope mustUnderstand,attr,omitempty"` + UsernameToken *UsernameToken `xml:"UsernameToken,omitempty"` +} + +type UsernameToken struct { + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd UsernameToken"` + Username string `xml:"Username"` + Password Password `xml:"Password"` + Nonce Nonce `xml:"Nonce"` + Created string `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd Created"` +} + +type Password struct { + Type string `xml:"Type,attr"` + Password string `xml:",chardata"` +} + +type Nonce struct { + Type string `xml:"EncodingType,attr"` + Nonce string `xml:",chardata"` +} + +type GetDeviceInformation struct { + XMLName xml.Name `xml:"tds:GetDeviceInformation"` + Xmlns string `xml:"xmlns:tds,attr"` +} + +func createSecurityHeader(username, password string) *Security { + nonceBytes := make([]byte, 16) + rand.Read(nonceBytes) + nonce := base64.StdEncoding.EncodeToString(nonceBytes) + + created := time.Now().UTC().Format(time.RFC3339) + + hash := sha1.New() + hash.Write(nonceBytes) + hash.Write([]byte(created)) + hash.Write([]byte(password)) + digest := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + + return &Security{ + MustUnderstand: "1", + UsernameToken: &UsernameToken{ + Username: username, + Password: Password{ + Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest", + Password: digest, + }, + Nonce: Nonce{ + Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary", + Nonce: nonce, + }, + Created: created, + }, + } +} + +func main() { + endpoint := "http://192.168.1.201/onvif/device_service" + username := "service" + password := "Service.1234" + + fmt.Println("Testing direct SOAP request to camera...") + + // Build request + req := GetDeviceInformation{ + Xmlns: "http://www.onvif.org/ver10/device/wsdl", + } + + envelope := &Envelope{ + Header: &Header{ + Security: createSecurityHeader(username, password), + }, + Body: Body{ + Content: req, + }, + } + + // Marshal to XML + body, err := xml.MarshalIndent(envelope, "", " ") + if err != nil { + log.Fatalf("Failed to marshal: %v", err) + } + + xmlBody := append([]byte(xml.Header), body...) + + fmt.Println("\n=== Request XML ===") + fmt.Println(string(xmlBody)) + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(context.Background(), "POST", endpoint, bytes.NewReader(xmlBody)) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + + httpReq.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") + + // Send request + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + log.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Read response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response: %v", err) + } + + fmt.Printf("\n=== HTTP Status: %d ===\n", resp.StatusCode) + fmt.Printf("\n=== Response Headers ===\n") + for k, v := range resp.Header { + fmt.Printf("%s: %v\n", k, v) + } + fmt.Printf("\n=== Response Body ===\n") + fmt.Println(string(respBody)) +} diff --git a/examples/debug-streamuri/main.go b/examples/debug-streamuri/main.go new file mode 100644 index 0000000..01da6f6 --- /dev/null +++ b/examples/debug-streamuri/main.go @@ -0,0 +1,162 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "log" + "net/http" + "time" +) + +// SOAP Envelope structures +type Envelope struct { + XMLName xml.Name `xml:"http://www.w3.org/2003/05/soap-envelope Envelope"` + Header *Header `xml:"http://www.w3.org/2003/05/soap-envelope Header,omitempty"` + Body Body `xml:"http://www.w3.org/2003/05/soap-envelope Body"` +} + +type Header struct { + Security *Security `xml:"Security,omitempty"` +} + +type Body struct { + Content interface{} `xml:",omitempty"` +} + +type Security struct { + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd Security"` + MustUnderstand string `xml:"http://www.w3.org/2003/05/soap-envelope mustUnderstand,attr,omitempty"` + UsernameToken *UsernameToken `xml:"UsernameToken,omitempty"` +} + +type UsernameToken struct { + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd UsernameToken"` + Username string `xml:"Username"` + Password Password `xml:"Password"` + Nonce Nonce `xml:"Nonce"` + Created string `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd Created"` +} + +type Password struct { + Type string `xml:"Type,attr"` + Password string `xml:",chardata"` +} + +type Nonce struct { + Type string `xml:"EncodingType,attr"` + Nonce string `xml:",chardata"` +} + +type GetStreamUri struct { + XMLName xml.Name `xml:"trt:GetStreamUri"` + Xmlns string `xml:"xmlns:trt,attr"` + Xmlnst string `xml:"xmlns:tt,attr"` + StreamSetup struct { + Stream string `xml:"tt:Stream"` + Transport struct { + Protocol string `xml:"tt:Protocol"` + } `xml:"tt:Transport"` + } `xml:"trt:StreamSetup"` + ProfileToken string `xml:"trt:ProfileToken"` +} + +func createSecurityHeader(username, password string) *Security { + nonceBytes := make([]byte, 16) + rand.Read(nonceBytes) + nonce := base64.StdEncoding.EncodeToString(nonceBytes) + + created := time.Now().UTC().Format(time.RFC3339) + + hash := sha1.New() + hash.Write(nonceBytes) + hash.Write([]byte(created)) + hash.Write([]byte(password)) + digest := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + + return &Security{ + MustUnderstand: "1", + UsernameToken: &UsernameToken{ + Username: username, + Password: Password{ + Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest", + Password: digest, + }, + Nonce: Nonce{ + Type: "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary", + Nonce: nonce, + }, + Created: created, + }, + } +} + +func main() { + // Using the media service endpoint + endpoint := "http://192.168.1.201/onvif/media_service" + username := "service" + password := "Service.1234" + profileToken := "0" + + fmt.Println("Testing GetStreamUri SOAP request...") + + // Build request + req := GetStreamUri{ + Xmlns: "http://www.onvif.org/ver10/media/wsdl", + Xmlnst: "http://www.onvif.org/ver10/schema", + ProfileToken: profileToken, + } + req.StreamSetup.Stream = "RTP-Unicast" + req.StreamSetup.Transport.Protocol = "RTSP" + + envelope := &Envelope{ + Header: &Header{ + Security: createSecurityHeader(username, password), + }, + Body: Body{ + Content: req, + }, + } + + // Marshal to XML + body, err := xml.MarshalIndent(envelope, "", " ") + if err != nil { + log.Fatalf("Failed to marshal: %v", err) + } + + xmlBody := append([]byte(xml.Header), body...) + + fmt.Println("\n=== Request XML ===") + fmt.Println(string(xmlBody)) + + // Create HTTP request + httpReq, err := http.NewRequestWithContext(context.Background(), "POST", endpoint, bytes.NewReader(xmlBody)) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + + httpReq.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") + + // Send request + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + log.Fatalf("Failed to send request: %v", err) + } + defer resp.Body.Close() + + // Read response + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response: %v", err) + } + + fmt.Printf("\n=== HTTP Status: %d ===\n", resp.StatusCode) + fmt.Printf("\n=== Response Body ===\n") + fmt.Println(string(respBody)) +} diff --git a/examples/discover-and-test/main.go b/examples/discover-and-test/main.go new file mode 100644 index 0000000..250247d --- /dev/null +++ b/examples/discover-and-test/main.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/0x524A/go-onvif" + "github.com/0x524A/go-onvif/discovery" +) + +func main() { + fmt.Println("🔍 Discovering ONVIF cameras on the network...") + fmt.Println("This may take a few seconds...") + fmt.Println() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + devices, err := discovery.Discover(ctx, 10*time.Second) + if err != nil { + log.Fatalf("❌ Discovery failed: %v", err) + } + + if len(devices) == 0 { + fmt.Println("❌ No ONVIF cameras found on the network") + fmt.Println("💡 Make sure:") + fmt.Println(" - Camera is powered on and connected to the network") + fmt.Println(" - ONVIF is enabled on the camera") + fmt.Println(" - You're on the same network segment as the camera") + fmt.Println(" - Camera IP 192.168.1.201 is reachable (try: ping 192.168.1.201)") + return + } + + fmt.Printf("✅ Found %d camera(s):\n\n", len(devices)) + + var targetDevice *discovery.Device + for i, device := range devices { + fmt.Printf("📹 Camera #%d:\n", i+1) + fmt.Printf(" Endpoint: %s\n", device.GetDeviceEndpoint()) + fmt.Printf(" Name: %s\n", device.GetName()) + fmt.Printf(" Location: %s\n", device.GetLocation()) + fmt.Printf(" Types: %v\n", device.Types) + fmt.Printf(" XAddrs: %v\n", device.XAddrs) + fmt.Println() + + // Check if this is our target camera (192.168.1.201) + endpoint := device.GetDeviceEndpoint() + if len(endpoint) > 7 { + // Simple check if endpoint contains the IP + if len(endpoint) > 20 && (endpoint[7:20] == "192.168.1.201" || endpoint[7:21] == "192.168.1.201:") { + targetDevice = device + } + } + } + + if targetDevice == nil { + fmt.Println("⚠️ Camera at 192.168.1.201 was not discovered") + fmt.Println("💡 You can still try to connect manually with the correct endpoint") + return + } + + // Now try to connect to the discovered camera + fmt.Printf("\n🎯 Found target camera at 192.168.1.201\n") + fmt.Printf("Endpoint: %s\n", targetDevice.GetDeviceEndpoint()) + fmt.Println() + + // Test connection with credentials + username := "service" + password := "Service.1234" + + fmt.Println("📡 Connecting with credentials...") + client, err := onvif.NewClient( + targetDevice.GetDeviceEndpoint(), + onvif.WithCredentials(username, password), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + log.Fatalf("❌ Failed to create client: %v", err) + } + + ctx2 := context.Background() + + // Get device information + fmt.Println("🔍 Retrieving device information...") + info, err := client.GetDeviceInformation(ctx2) + if err != nil { + log.Fatalf("❌ Failed to get device information: %v\n\n💡 Possible issues:\n - Wrong username or password\n - Camera requires different authentication\n - Try username/password combinations like: admin/admin, admin/12345, etc.\n", err) + } + + fmt.Printf("\n✅ Device Information:\n") + fmt.Printf(" Manufacturer: %s\n", info.Manufacturer) + fmt.Printf(" Model: %s\n", info.Model) + fmt.Printf(" Firmware: %s\n", info.FirmwareVersion) + fmt.Printf(" Serial Number: %s\n", info.SerialNumber) + fmt.Printf(" Hardware ID: %s\n", info.HardwareID) + + // Initialize client (discover service endpoints) + fmt.Println("\n🔧 Initializing client and discovering services...") + if err := client.Initialize(ctx2); err != nil { + log.Fatalf("❌ Failed to initialize client: %v", err) + } + fmt.Println("✅ Services discovered successfully") + + // Get capabilities + fmt.Println("\n🎯 Getting device capabilities...") + caps, err := client.GetCapabilities(ctx2) + if err != nil { + log.Printf("⚠️ Failed to get capabilities: %v", err) + } else { + fmt.Println("✅ Supported Services:") + if caps.Device != nil { + fmt.Println(" ✓ Device Service") + } + if caps.Media != nil { + fmt.Println(" ✓ Media Service (Streaming)") + } + if caps.PTZ != nil { + fmt.Println(" ✓ PTZ Service (Pan/Tilt/Zoom)") + } + if caps.Imaging != nil { + fmt.Println(" ✓ Imaging Service") + } + if caps.Events != nil { + fmt.Println(" ✓ Event Service") + } + if caps.Analytics != nil { + fmt.Println(" ✓ Analytics Service") + } + } + + // Get media profiles + fmt.Println("\n📹 Retrieving media profiles...") + profiles, err := client.GetProfiles(ctx2) + if err != nil { + log.Fatalf("❌ Failed to get profiles: %v", err) + } + + fmt.Printf("\n✅ Found %d profile(s):\n", len(profiles)) + for i, profile := range profiles { + fmt.Printf("\n📺 Profile #%d:\n", i+1) + fmt.Printf(" Token: %s\n", profile.Token) + fmt.Printf(" Name: %s\n", profile.Name) + + if profile.VideoEncoderConfiguration != nil { + fmt.Printf(" Encoding: %s\n", profile.VideoEncoderConfiguration.Encoding) + if profile.VideoEncoderConfiguration.Resolution != nil { + fmt.Printf(" Resolution: %dx%d\n", + profile.VideoEncoderConfiguration.Resolution.Width, + profile.VideoEncoderConfiguration.Resolution.Height) + } + fmt.Printf(" Quality: %.1f\n", profile.VideoEncoderConfiguration.Quality) + if profile.VideoEncoderConfiguration.RateControl != nil { + fmt.Printf(" Frame Rate: %d fps\n", profile.VideoEncoderConfiguration.RateControl.FrameRateLimit) + fmt.Printf(" Bitrate: %d kbps\n", profile.VideoEncoderConfiguration.RateControl.BitrateLimit) + } + } + + if profile.PTZConfiguration != nil { + fmt.Printf(" PTZ: Enabled\n") + } + + // Get stream URI + streamURI, err := client.GetStreamURI(ctx2, profile.Token) + if err != nil { + fmt.Printf(" Stream URI: ❌ Error - %v\n", err) + } else { + fmt.Printf(" Stream URI: %s\n", streamURI.URI) + fmt.Printf(" 📱 Use this URL in VLC or other RTSP player\n") + } + + // Get snapshot URI + snapshotURI, err := client.GetSnapshotURI(ctx2, profile.Token) + if err != nil { + fmt.Printf(" Snapshot URI: ❌ Error - %v\n", err) + } else { + fmt.Printf(" Snapshot URI: %s\n", snapshotURI.URI) + fmt.Printf(" 🌐 You can open this URL in a browser\n") + } + } + + // Test PTZ if available + if len(profiles) > 0 { + fmt.Println("\n🎮 Testing PTZ capabilities...") + profileToken := profiles[0].Token + + status, err := client.GetStatus(ctx2, profileToken) + if err != nil { + fmt.Printf("⚠️ PTZ not supported or error: %v\n", err) + } else { + fmt.Println("✅ PTZ is supported!") + if status.Position != nil && status.Position.PanTilt != nil { + fmt.Printf(" Current Position: Pan=%.3f, Tilt=%.3f\n", + status.Position.PanTilt.X, + status.Position.PanTilt.Y) + } + if status.Position != nil && status.Position.Zoom != nil { + fmt.Printf(" Current Zoom: %.3f\n", status.Position.Zoom.X) + } + + // Get presets + presets, err := client.GetPresets(ctx2, profileToken) + if err != nil { + fmt.Printf(" Presets: ❌ Error - %v\n", err) + } else { + fmt.Printf(" Available Presets: %d\n", len(presets)) + for _, preset := range presets { + fmt.Printf(" - %s (Token: %s)\n", preset.Name, preset.Token) + } + } + } + } + + // Test Imaging if available + if len(profiles) > 0 && profiles[0].VideoSourceConfiguration != nil { + fmt.Println("\n🎨 Testing Imaging capabilities...") + videoSourceToken := profiles[0].VideoSourceConfiguration.SourceToken + + settings, err := client.GetImagingSettings(ctx2, videoSourceToken) + if err != nil { + fmt.Printf("⚠️ Imaging settings not available: %v\n", err) + } else { + fmt.Println("✅ Current Imaging Settings:") + if settings.Brightness != nil { + fmt.Printf(" Brightness: %.1f\n", *settings.Brightness) + } + if settings.Contrast != nil { + fmt.Printf(" Contrast: %.1f\n", *settings.Contrast) + } + if settings.ColorSaturation != nil { + fmt.Printf(" Saturation: %.1f\n", *settings.ColorSaturation) + } + if settings.Sharpness != nil { + fmt.Printf(" Sharpness: %.1f\n", *settings.Sharpness) + } + if settings.Exposure != nil { + fmt.Printf(" Exposure Mode: %s\n", settings.Exposure.Mode) + } + if settings.Focus != nil { + fmt.Printf(" Focus Mode: %s\n", settings.Focus.AutoFocusMode) + } + if settings.WhiteBalance != nil { + fmt.Printf(" White Balance: %s\n", settings.WhiteBalance.Mode) + } + } + } + + fmt.Println("\n✅ All tests completed successfully!") + fmt.Println("\n💡 Next steps:") + fmt.Println(" - Use the stream URI in VLC to view the live feed") + fmt.Println(" - Open the snapshot URI in a browser to see still images") + fmt.Println(" - Use the PTZ controls to move the camera (if supported)") + fmt.Println(" - Adjust imaging settings for better image quality") +} diff --git a/examples/discover-real-camera/main.go b/examples/discover-real-camera/main.go new file mode 100644 index 0000000..a337a4d --- /dev/null +++ b/examples/discover-real-camera/main.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/0x524A/go-onvif/discovery" +) + +func main() { + fmt.Println("Discovering ONVIF cameras on the network...") + + ctx := context.Background() + + devices, err := discovery.Discover(ctx, 10*time.Second) + if err != nil { + log.Fatalf("Discovery failed: %v", err) + } + + if len(devices) == 0 { + fmt.Println("No ONVIF devices found") + return + } + + fmt.Printf("\nFound %d device(s):\n\n", len(devices)) + for i, device := range devices { + fmt.Printf("Device #%d:\n", i+1) + fmt.Printf(" Endpoint Ref: %s\n", device.EndpointRef) + fmt.Printf(" XAddrs: %v\n", device.XAddrs) + fmt.Printf(" Device Endpoint: %s\n", device.GetDeviceEndpoint()) + fmt.Printf(" Name: %s\n", device.GetName()) + fmt.Printf(" Location: %s\n", device.GetLocation()) + fmt.Printf(" Types: %v\n", device.Types) + fmt.Printf(" Scopes: %v\n", device.Scopes) + fmt.Println() + } +} diff --git a/examples/manual-soap-test/main.go b/examples/manual-soap-test/main.go new file mode 100644 index 0000000..c157f09 --- /dev/null +++ b/examples/manual-soap-test/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "time" +) + +func main() { + // Test SOAP request manually + endpoint := "http://192.168.1.201/onvif/device_service" + username := "service" + password := "Service.1234" + + fmt.Println("🔧 Manual SOAP Test for ONVIF Camera") + fmt.Println("=====================================") + fmt.Printf("Endpoint: %s\n", endpoint) + fmt.Printf("Username: %s\n", username) + fmt.Println() + + // Simple GetDeviceInformation SOAP request (without auth for now) + soapRequest := ` + + + + +` + + fmt.Println("📤 Sending SOAP request (without authentication)...") + fmt.Println() + + req, err := http.NewRequest("POST", endpoint, bytes.NewBufferString(soapRequest)) + if err != nil { + log.Fatalf("Failed to create request: %v", err) + } + + req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + log.Fatalf("❌ Failed to send request: %v", err) + } + defer resp.Body.Close() + + fmt.Printf("📥 Response Status: %s\n", resp.Status) + fmt.Println("📋 Response Headers:") + for key, values := range resp.Header { + for _, value := range values { + fmt.Printf(" %s: %s\n", key, value) + } + } + fmt.Println() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Fatalf("Failed to read response: %v", err) + } + + fmt.Println("📄 Response Body:") + fmt.Println(string(body)) + fmt.Println() + + if resp.StatusCode != 200 { + fmt.Printf("⚠️ Non-200 status code: %d\n", resp.StatusCode) + + if resp.StatusCode == 401 { + fmt.Println("💡 Authentication required - this is expected!") + fmt.Println("💡 Now testing with go-onvif client library...") + fmt.Println() + testWithClient(username, password) + } else { + fmt.Println("💡 Unexpected status code. Check:") + fmt.Println(" - Is ONVIF enabled on the camera?") + fmt.Println(" - Is the endpoint path correct?") + } + } else { + fmt.Println("✅ Got successful response!") + } +} + +func testWithClient(username, password string) { + // Import locally to avoid conflicts + onvif := struct{}{} + _ = onvif + + fmt.Println("Note: Would test with go-onvif client here, but keeping this simple.") + fmt.Println("The camera appears to be responding to ONVIF requests.") + fmt.Println() + fmt.Println("💡 Next step: Check if the credentials are correct") + fmt.Printf(" Username: %s\n", username) + fmt.Printf(" Password: %s\n", password) +} diff --git a/examples/test-new-features/main.go b/examples/test-new-features/main.go new file mode 100644 index 0000000..d72e95b --- /dev/null +++ b/examples/test-new-features/main.go @@ -0,0 +1,444 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/0x524A/go-onvif" +) + +var ( + endpoint = flag.String("endpoint", "http://192.168.1.201/onvif/device_service", "ONVIF device endpoint") + username = flag.String("username", "admin", "Username for authentication") + password = flag.String("password", "", "Password for authentication") + verbose = flag.Bool("verbose", true, "Enable verbose output") + output = flag.String("output", "test-results.json", "Output file for results") +) + +type TestResults struct { + Timestamp time.Time `json:"timestamp"` + CameraInfo *CameraInfo `json:"camera_info"` + DeviceTests map[string]interface{} `json:"device_tests"` + MediaTests map[string]interface{} `json:"media_tests"` + PTZTests map[string]interface{} `json:"ptz_tests"` + ImagingTests map[string]interface{} `json:"imaging_tests"` + Errors []string `json:"errors"` +} + +type CameraInfo struct { + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + FirmwareVersion string `json:"firmware_version"` + SerialNumber string `json:"serial_number"` + HardwareID string `json:"hardware_id"` +} + +func main() { + flag.Parse() + + if *password == "" { + log.Fatal("Password is required. Use -password flag") + } + + log.Printf("Testing ONVIF camera at: %s", *endpoint) + log.Printf("Username: %s", *username) + + // Create client + client, err := onvif.NewClient( + *endpoint, + onvif.WithCredentials(*username, *password), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + results := &TestResults{ + Timestamp: time.Now(), + DeviceTests: make(map[string]interface{}), + MediaTests: make(map[string]interface{}), + PTZTests: make(map[string]interface{}), + ImagingTests: make(map[string]interface{}), + Errors: []string{}, + } + + // Initialize client + log.Println("\n=== Initializing Client ===") + if err := client.Initialize(ctx); err != nil { + log.Printf("Warning: Initialize failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("Initialize: %v", err)) + } + + // Get basic device information + log.Println("\n=== Getting Device Information ===") + info, err := client.GetDeviceInformation(ctx) + if err != nil { + log.Fatalf("Failed to get device information: %v", err) + } + log.Printf("Camera: %s %s", info.Manufacturer, info.Model) + log.Printf("Firmware: %s", info.FirmwareVersion) + log.Printf("Serial: %s", info.SerialNumber) + + results.CameraInfo = &CameraInfo{ + Manufacturer: info.Manufacturer, + Model: info.Model, + FirmwareVersion: info.FirmwareVersion, + SerialNumber: info.SerialNumber, + HardwareID: info.HardwareID, + } + + // Test NEW Device Service Methods + testDeviceService(ctx, client, results) + + // Test NEW Media Service Methods + testMediaService(ctx, client, results) + + // Test NEW PTZ Service Methods + testPTZService(ctx, client, results) + + // Test NEW Imaging Service Methods + testImagingService(ctx, client, results) + + // Save results + saveResults(results) + + log.Printf("\n=== Test Complete ===") + log.Printf("Results saved to: %s", *output) + log.Printf("Total errors: %d", len(results.Errors)) +} + +func testDeviceService(ctx context.Context, client *onvif.Client, results *TestResults) { + log.Println("\n=== Testing Device Service (NEW Methods) ===") + + // Test GetHostname + log.Println("\n--- GetHostname ---") + if hostname, err := client.GetHostname(ctx); err != nil { + log.Printf("❌ GetHostname failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetHostname: %v", err)) + } else { + log.Printf("✅ Hostname: %+v", hostname) + results.DeviceTests["hostname"] = hostname + } + + // Test GetDNS + log.Println("\n--- GetDNS ---") + if dns, err := client.GetDNS(ctx); err != nil { + log.Printf("❌ GetDNS failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetDNS: %v", err)) + } else { + log.Printf("✅ DNS: FromDHCP=%v, SearchDomain=%v", dns.FromDHCP, dns.SearchDomain) + log.Printf(" DNSFromDHCP: %+v", dns.DNSFromDHCP) + log.Printf(" DNSManual: %+v", dns.DNSManual) + results.DeviceTests["dns"] = dns + } + + // Test GetNTP + log.Println("\n--- GetNTP ---") + if ntp, err := client.GetNTP(ctx); err != nil { + log.Printf("❌ GetNTP failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetNTP: %v", err)) + } else { + log.Printf("✅ NTP: FromDHCP=%v", ntp.FromDHCP) + log.Printf(" NTPFromDHCP: %+v", ntp.NTPFromDHCP) + log.Printf(" NTPManual: %+v", ntp.NTPManual) + results.DeviceTests["ntp"] = ntp + } + + // Test GetNetworkInterfaces + log.Println("\n--- GetNetworkInterfaces ---") + if interfaces, err := client.GetNetworkInterfaces(ctx); err != nil { + log.Printf("❌ GetNetworkInterfaces failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetNetworkInterfaces: %v", err)) + } else { + log.Printf("✅ Found %d network interface(s)", len(interfaces)) + for i, iface := range interfaces { + log.Printf(" Interface %d: Token=%s, Name=%s, Enabled=%v", + i+1, iface.Token, iface.Info.Name, iface.Enabled) + log.Printf(" HwAddress=%s, MTU=%d", iface.Info.HwAddress, iface.Info.MTU) + if iface.IPv4 != nil { + log.Printf(" IPv4: Enabled=%v, DHCP=%v", iface.IPv4.Enabled, iface.IPv4.Config.DHCP) + for _, addr := range iface.IPv4.Config.Manual { + log.Printf(" Manual: %s/%d", addr.Address, addr.PrefixLength) + } + } + } + results.DeviceTests["network_interfaces"] = interfaces + } + + // Test GetScopes + log.Println("\n--- GetScopes ---") + if scopes, err := client.GetScopes(ctx); err != nil { + log.Printf("❌ GetScopes failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetScopes: %v", err)) + } else { + log.Printf("✅ Found %d scope(s)", len(scopes)) + for i, scope := range scopes { + log.Printf(" Scope %d: Def=%s, Item=%s", i+1, scope.ScopeDef, scope.ScopeItem) + } + results.DeviceTests["scopes"] = scopes + } + + // Test GetUsers + log.Println("\n--- GetUsers ---") + if users, err := client.GetUsers(ctx); err != nil { + log.Printf("❌ GetUsers failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetUsers: %v", err)) + } else { + log.Printf("✅ Found %d user(s)", len(users)) + for i, user := range users { + log.Printf(" User %d: Username=%s, Level=%s", i+1, user.Username, user.UserLevel) + } + results.DeviceTests["users"] = users + } +} + +func testMediaService(ctx context.Context, client *onvif.Client, results *TestResults) { + log.Println("\n=== Testing Media Service (NEW Methods) ===") + + // Test GetVideoSources + log.Println("\n--- GetVideoSources ---") + if sources, err := client.GetVideoSources(ctx); err != nil { + log.Printf("❌ GetVideoSources failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetVideoSources: %v", err)) + } else { + log.Printf("✅ Found %d video source(s)", len(sources)) + for i, source := range sources { + log.Printf(" Source %d: Token=%s, Framerate=%.1f", + i+1, source.Token, source.Framerate) + if source.Resolution != nil { + log.Printf(" Resolution: %dx%d", source.Resolution.Width, source.Resolution.Height) + } + } + results.MediaTests["video_sources"] = sources + } + + // Test GetAudioSources + log.Println("\n--- GetAudioSources ---") + if sources, err := client.GetAudioSources(ctx); err != nil { + log.Printf("❌ GetAudioSources failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetAudioSources: %v", err)) + } else { + log.Printf("✅ Found %d audio source(s)", len(sources)) + for i, source := range sources { + log.Printf(" Source %d: Token=%s, Channels=%d", + i+1, source.Token, source.Channels) + } + results.MediaTests["audio_sources"] = sources + } + + // Test GetAudioOutputs + log.Println("\n--- GetAudioOutputs ---") + if outputs, err := client.GetAudioOutputs(ctx); err != nil { + log.Printf("❌ GetAudioOutputs failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetAudioOutputs: %v", err)) + } else { + log.Printf("✅ Found %d audio output(s)", len(outputs)) + for i, output := range outputs { + log.Printf(" Output %d: Token=%s", i+1, output.Token) + } + results.MediaTests["audio_outputs"] = outputs + } + + // Get profiles for further testing + profiles, err := client.GetProfiles(ctx) + if err != nil { + log.Printf("⚠️ Could not get profiles: %v", err) + return + } + + if len(profiles) > 0 { + log.Printf("\nUsing profile: %s (%s)", profiles[0].Name, profiles[0].Token) + results.MediaTests["test_profile_token"] = profiles[0].Token + } +} + +func testPTZService(ctx context.Context, client *onvif.Client, results *TestResults) { + log.Println("\n=== Testing PTZ Service (NEW Methods) ===") + + // Get profiles to find one with PTZ + profiles, err := client.GetProfiles(ctx) + if err != nil { + log.Printf("⚠️ Could not get profiles for PTZ tests: %v", err) + return + } + + var ptzProfile *onvif.Profile + for _, p := range profiles { + if p.PTZConfiguration != nil { + ptzProfile = p + break + } + } + + if ptzProfile == nil { + log.Println("⚠️ No PTZ-enabled profile found, skipping PTZ tests") + results.PTZTests["skipped"] = "No PTZ profile found" + return + } + + log.Printf("Using PTZ profile: %s (%s)", ptzProfile.Name, ptzProfile.Token) + results.PTZTests["test_profile_token"] = ptzProfile.Token + + // Test GetConfigurations + log.Println("\n--- GetConfigurations ---") + if configs, err := client.GetConfigurations(ctx); err != nil { + log.Printf("❌ GetConfigurations failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetConfigurations: %v", err)) + } else { + log.Printf("✅ Found %d PTZ configuration(s)", len(configs)) + for i, cfg := range configs { + log.Printf(" Config %d: Token=%s, Name=%s, NodeToken=%s", + i+1, cfg.Token, cfg.Name, cfg.NodeToken) + } + results.PTZTests["configurations"] = configs + } + + // Test GetConfiguration + if ptzProfile.PTZConfiguration != nil { + log.Println("\n--- GetConfiguration ---") + if cfg, err := client.GetConfiguration(ctx, ptzProfile.PTZConfiguration.Token); err != nil { + log.Printf("❌ GetConfiguration failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetConfiguration: %v", err)) + } else { + log.Printf("✅ Configuration: Token=%s, Name=%s", cfg.Token, cfg.Name) + results.PTZTests["configuration"] = cfg + } + } + + // Test GetPresets + log.Println("\n--- GetPresets ---") + if presets, err := client.GetPresets(ctx, ptzProfile.Token); err != nil { + log.Printf("❌ GetPresets failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetPresets: %v", err)) + } else { + log.Printf("✅ Found %d preset(s)", len(presets)) + for i, preset := range presets { + log.Printf(" Preset %d: Token=%s, Name=%s", i+1, preset.Token, preset.Name) + if preset.PTZPosition != nil { + if preset.PTZPosition.PanTilt != nil { + log.Printf(" PanTilt: X=%.2f, Y=%.2f", + preset.PTZPosition.PanTilt.X, preset.PTZPosition.PanTilt.Y) + } + if preset.PTZPosition.Zoom != nil { + log.Printf(" Zoom: X=%.2f", preset.PTZPosition.Zoom.X) + } + } + } + results.PTZTests["presets"] = presets + } + + // Test GetStatus + log.Println("\n--- GetStatus ---") + if status, err := client.GetStatus(ctx, ptzProfile.Token); err != nil { + log.Printf("❌ GetStatus failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("PTZ GetStatus: %v", err)) + } else { + log.Printf("✅ PTZ Status:") + if status.Position != nil { + if status.Position.PanTilt != nil { + log.Printf(" Position PanTilt: X=%.2f, Y=%.2f", + status.Position.PanTilt.X, status.Position.PanTilt.Y) + } + if status.Position.Zoom != nil { + log.Printf(" Position Zoom: X=%.2f", status.Position.Zoom.X) + } + } + if status.MoveStatus != nil { + log.Printf(" MoveStatus: PanTilt=%s, Zoom=%s", + status.MoveStatus.PanTilt, status.MoveStatus.Zoom) + } + results.PTZTests["status"] = status + } +} + +func testImagingService(ctx context.Context, client *onvif.Client, results *TestResults) { + log.Println("\n=== Testing Imaging Service (NEW Methods) ===") + + // Get video sources first + sources, err := client.GetVideoSources(ctx) + if err != nil || len(sources) == 0 { + log.Printf("⚠️ Could not get video sources for imaging tests: %v", err) + return + } + + videoSourceToken := sources[0].Token + log.Printf("Using video source: %s", videoSourceToken) + results.ImagingTests["test_video_source_token"] = videoSourceToken + + // Test GetOptions + log.Println("\n--- GetOptions ---") + if options, err := client.GetOptions(ctx, videoSourceToken); err != nil { + log.Printf("❌ GetOptions failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetOptions: %v", err)) + } else { + log.Printf("✅ Imaging Options:") + if options.Brightness != nil { + log.Printf(" Brightness: Min=%.1f, Max=%.1f", options.Brightness.Min, options.Brightness.Max) + } + if options.ColorSaturation != nil { + log.Printf(" ColorSaturation: Min=%.1f, Max=%.1f", options.ColorSaturation.Min, options.ColorSaturation.Max) + } + if options.Contrast != nil { + log.Printf(" Contrast: Min=%.1f, Max=%.1f", options.Contrast.Min, options.Contrast.Max) + } + results.ImagingTests["options"] = options + } + + // Test GetMoveOptions + log.Println("\n--- GetMoveOptions ---") + if moveOptions, err := client.GetMoveOptions(ctx, videoSourceToken); err != nil { + log.Printf("❌ GetMoveOptions failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("GetMoveOptions: %v", err)) + } else { + log.Printf("✅ Move Options:") + if moveOptions.Absolute != nil { + log.Printf(" Absolute Position: Min=%.1f, Max=%.1f", + moveOptions.Absolute.Position.Min, moveOptions.Absolute.Position.Max) + log.Printf(" Absolute Speed: Min=%.1f, Max=%.1f", + moveOptions.Absolute.Speed.Min, moveOptions.Absolute.Speed.Max) + } + if moveOptions.Relative != nil { + log.Printf(" Relative Distance: Min=%.1f, Max=%.1f", + moveOptions.Relative.Distance.Min, moveOptions.Relative.Distance.Max) + } + if moveOptions.Continuous != nil { + log.Printf(" Continuous Speed: Min=%.1f, Max=%.1f", + moveOptions.Continuous.Speed.Min, moveOptions.Continuous.Speed.Max) + } + results.ImagingTests["move_options"] = moveOptions + } + + // Test GetImagingStatus + log.Println("\n--- GetImagingStatus ---") + if status, err := client.GetImagingStatus(ctx, videoSourceToken); err != nil { + log.Printf("❌ GetImagingStatus failed: %v", err) + results.Errors = append(results.Errors, fmt.Sprintf("Imaging GetImagingStatus: %v", err)) + } else { + log.Printf("✅ Imaging Status:") + if status.FocusStatus != nil { + log.Printf(" Focus Position: %.2f", status.FocusStatus.Position) + log.Printf(" Focus MoveStatus: %s", status.FocusStatus.MoveStatus) + if status.FocusStatus.Error != "" { + log.Printf(" Focus Error: %s", status.FocusStatus.Error) + } + } + results.ImagingTests["status"] = status + } +} + +func saveResults(results *TestResults) { + data, err := json.MarshalIndent(results, "", " ") + if err != nil { + log.Fatalf("Failed to marshal results: %v", err) + } + + if err := os.WriteFile(*output, data, 0644); err != nil { + log.Fatalf("Failed to write results: %v", err) + } +} diff --git a/examples/test-real-camera/main.go b/examples/test-real-camera/main.go new file mode 100644 index 0000000..cbafd89 --- /dev/null +++ b/examples/test-real-camera/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/0x524A/go-onvif" +) + +func main() { + // Camera connection details + endpoint := "http://192.168.1.201/onvif/device_service" + username := "service" + password := "Service.1234" + + fmt.Println("Connecting to ONVIF camera at 192.168.1.201...") + + // Create a new ONVIF client + client, err := onvif.NewClient( + endpoint, + onvif.WithCredentials(username, password), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + // Get device information + fmt.Println("\nRetrieving device information...") + info, err := client.GetDeviceInformation(ctx) + if err != nil { + log.Fatalf("Failed to get device information: %v", err) + } + + fmt.Printf("\nDevice Information:\n") + fmt.Printf(" Manufacturer: %s\n", info.Manufacturer) + fmt.Printf(" Model: %s\n", info.Model) + fmt.Printf(" Firmware: %s\n", info.FirmwareVersion) + fmt.Printf(" Serial Number: %s\n", info.SerialNumber) + fmt.Printf(" Hardware ID: %s\n", info.HardwareID) + + // Initialize client (discover service endpoints) + fmt.Println("\nInitializing client and discovering services...") + if err := client.Initialize(ctx); err != nil { + log.Fatalf("Failed to initialize client: %v", err) + } + + // Get media profiles + fmt.Println("\nRetrieving media profiles...") + profiles, err := client.GetProfiles(ctx) + if err != nil { + log.Fatalf("Failed to get profiles: %v", err) + } + + fmt.Printf("\nFound %d profile(s):\n", len(profiles)) + for i, profile := range profiles { + fmt.Printf("\nProfile #%d:\n", i+1) + fmt.Printf(" Token: %s\n", profile.Token) + fmt.Printf(" Name: %s\n", profile.Name) + + if profile.VideoEncoderConfiguration != nil { + fmt.Printf(" Video Encoding: %s\n", profile.VideoEncoderConfiguration.Encoding) + if profile.VideoEncoderConfiguration.Resolution != nil { + fmt.Printf(" Resolution: %dx%d\n", + profile.VideoEncoderConfiguration.Resolution.Width, + profile.VideoEncoderConfiguration.Resolution.Height) + } + fmt.Printf(" Quality: %.1f\n", profile.VideoEncoderConfiguration.Quality) + } + + // Get stream URI + streamURI, err := client.GetStreamURI(ctx, profile.Token) + if err != nil { + fmt.Printf(" Stream URI: Error - %v\n", err) + } else { + fmt.Printf(" Stream URI: %s\n", streamURI.URI) + } + + // Get snapshot URI + snapshotURI, err := client.GetSnapshotURI(ctx, profile.Token) + if err != nil { + fmt.Printf(" Snapshot URI: Error - %v\n", err) + } else { + fmt.Printf(" Snapshot URI: %s\n", snapshotURI.URI) + } + } + + fmt.Println("\nDone!") +} diff --git a/imaging.go b/imaging.go index 778bd40..06f32a2 100644 --- a/imaging.go +++ b/imaging.go @@ -53,8 +53,8 @@ func (c *Client) GetImagingSettings(ctx context.Context, videoSourceToken string NearLimit float64 `xml:"NearLimit"` FarLimit float64 `xml:"FarLimit"` } `xml:"Focus"` - IrCutFilter *string `xml:"IrCutFilter"` - Sharpness *float64 `xml:"Sharpness"` + IrCutFilter *string `xml:"IrCutFilter"` + Sharpness *float64 `xml:"Sharpness"` WideDynamicRange *struct { Mode string `xml:"Mode"` Level float64 `xml:"Level"` @@ -76,7 +76,7 @@ func (c *Client) GetImagingSettings(ctx context.Context, videoSourceToken string username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetImagingSettings failed: %w", err) } @@ -177,8 +177,8 @@ func (c *Client) SetImagingSettings(ctx context.Context, videoSourceToken string NearLimit float64 `xml:"NearLimit,omitempty"` FarLimit float64 `xml:"FarLimit,omitempty"` } `xml:"Focus,omitempty"` - IrCutFilter *string `xml:"IrCutFilter,omitempty"` - Sharpness *float64 `xml:"Sharpness,omitempty"` + IrCutFilter *string `xml:"IrCutFilter,omitempty"` + Sharpness *float64 `xml:"Sharpness,omitempty"` WideDynamicRange *struct { Mode string `xml:"Mode"` Level float64 `xml:"Level,omitempty"` @@ -281,7 +281,7 @@ func (c *Client) SetImagingSettings(ctx context.Context, videoSourceToken string username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("SetImagingSettings failed: %w", err) } @@ -339,7 +339,7 @@ func (c *Client) Move(ctx context.Context, videoSourceToken string, focus *Focus username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("Move failed: %w", err) } @@ -351,3 +351,274 @@ func (c *Client) Move(ctx context.Context, videoSourceToken string, focus *Focus type FocusMove struct { // Can be extended with Absolute, Relative, Continuous move types } + +// GetOptions retrieves imaging options for a video source +func (c *Client) GetOptions(ctx context.Context, videoSourceToken string) (*ImagingOptions, error) { + endpoint := c.imagingEndpoint + if endpoint == "" { + return nil, ErrServiceNotSupported + } + + type GetOptions struct { + XMLName xml.Name `xml:"timg:GetOptions"` + Xmlns string `xml:"xmlns:timg,attr"` + VideoSourceToken string `xml:"timg:VideoSourceToken"` + } + + type GetOptionsResponse struct { + XMLName xml.Name `xml:"GetOptionsResponse"` + ImagingOptions struct { + BacklightCompensation *struct { + Mode []string `xml:"Mode"` + Level struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Level"` + } `xml:"BacklightCompensation"` + Brightness *struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Brightness"` + ColorSaturation *struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"ColorSaturation"` + Contrast *struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Contrast"` + Exposure *struct { + Mode []string `xml:"Mode"` + Priority []string `xml:"Priority"` + MinExposureTime struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"MinExposureTime"` + MaxExposureTime struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"MaxExposureTime"` + } `xml:"Exposure"` + Focus *struct { + AutoFocusModes []string `xml:"AutoFocusModes"` + DefaultSpeed struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"DefaultSpeed"` + } `xml:"Focus"` + } `xml:"ImagingOptions"` + } + + req := GetOptions{ + Xmlns: imagingNamespace, + VideoSourceToken: videoSourceToken, + } + + var resp GetOptionsResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetOptions failed: %w", err) + } + + options := &ImagingOptions{} + + if resp.ImagingOptions.Brightness != nil { + options.Brightness = &FloatRange{ + Min: resp.ImagingOptions.Brightness.Min, + Max: resp.ImagingOptions.Brightness.Max, + } + } + + if resp.ImagingOptions.ColorSaturation != nil { + options.ColorSaturation = &FloatRange{ + Min: resp.ImagingOptions.ColorSaturation.Min, + Max: resp.ImagingOptions.ColorSaturation.Max, + } + } + + if resp.ImagingOptions.Contrast != nil { + options.Contrast = &FloatRange{ + Min: resp.ImagingOptions.Contrast.Min, + Max: resp.ImagingOptions.Contrast.Max, + } + } + + return options, nil +} + +// GetMoveOptions retrieves imaging move options for focus +func (c *Client) GetMoveOptions(ctx context.Context, videoSourceToken string) (*MoveOptions, error) { + endpoint := c.imagingEndpoint + if endpoint == "" { + return nil, ErrServiceNotSupported + } + + type GetMoveOptions struct { + XMLName xml.Name `xml:"timg:GetMoveOptions"` + Xmlns string `xml:"xmlns:timg,attr"` + VideoSourceToken string `xml:"timg:VideoSourceToken"` + } + + type GetMoveOptionsResponse struct { + XMLName xml.Name `xml:"GetMoveOptionsResponse"` + MoveOptions struct { + Absolute *struct { + Position struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Position"` + Speed struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Speed"` + } `xml:"Absolute"` + Relative *struct { + Distance struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Distance"` + Speed struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Speed"` + } `xml:"Relative"` + Continuous *struct { + Speed struct { + Min float64 `xml:"Min"` + Max float64 `xml:"Max"` + } `xml:"Speed"` + } `xml:"Continuous"` + } `xml:"MoveOptions"` + } + + req := GetMoveOptions{ + Xmlns: imagingNamespace, + VideoSourceToken: videoSourceToken, + } + + var resp GetMoveOptionsResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetMoveOptions failed: %w", err) + } + + options := &MoveOptions{} + + if resp.MoveOptions.Absolute != nil { + options.Absolute = &AbsoluteFocusOptions{ + Position: FloatRange{ + Min: resp.MoveOptions.Absolute.Position.Min, + Max: resp.MoveOptions.Absolute.Position.Max, + }, + Speed: FloatRange{ + Min: resp.MoveOptions.Absolute.Speed.Min, + Max: resp.MoveOptions.Absolute.Speed.Max, + }, + } + } + + if resp.MoveOptions.Relative != nil { + options.Relative = &RelativeFocusOptions{ + Distance: FloatRange{ + Min: resp.MoveOptions.Relative.Distance.Min, + Max: resp.MoveOptions.Relative.Distance.Max, + }, + Speed: FloatRange{ + Min: resp.MoveOptions.Relative.Speed.Min, + Max: resp.MoveOptions.Relative.Speed.Max, + }, + } + } + + if resp.MoveOptions.Continuous != nil { + options.Continuous = &ContinuousFocusOptions{ + Speed: FloatRange{ + Min: resp.MoveOptions.Continuous.Speed.Min, + Max: resp.MoveOptions.Continuous.Speed.Max, + }, + } + } + + return options, nil +} + +// StopFocus stops focus movement +func (c *Client) StopFocus(ctx context.Context, videoSourceToken string) error { + endpoint := c.imagingEndpoint + if endpoint == "" { + return ErrServiceNotSupported + } + + type Stop struct { + XMLName xml.Name `xml:"timg:Stop"` + Xmlns string `xml:"xmlns:timg,attr"` + VideoSourceToken string `xml:"timg:VideoSourceToken"` + } + + req := Stop{ + Xmlns: imagingNamespace, + VideoSourceToken: videoSourceToken, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("Stop failed: %w", err) + } + + return nil +} + +// GetImagingStatus retrieves imaging status +func (c *Client) GetImagingStatus(ctx context.Context, videoSourceToken string) (*ImagingStatus, error) { + endpoint := c.imagingEndpoint + if endpoint == "" { + return nil, ErrServiceNotSupported + } + + type GetStatus struct { + XMLName xml.Name `xml:"timg:GetStatus"` + Xmlns string `xml:"xmlns:timg,attr"` + VideoSourceToken string `xml:"timg:VideoSourceToken"` + } + + type GetStatusResponse struct { + XMLName xml.Name `xml:"GetStatusResponse"` + ImagingStatus struct { + FocusStatus struct { + Position float64 `xml:"Position"` + MoveStatus string `xml:"MoveStatus"` + Error string `xml:"Error"` + } `xml:"FocusStatus"` + } `xml:"Status"` + } + + req := GetStatus{ + Xmlns: imagingNamespace, + VideoSourceToken: videoSourceToken, + } + + var resp GetStatusResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetStatus failed: %w", err) + } + + return &ImagingStatus{ + FocusStatus: &FocusStatus{ + Position: resp.ImagingStatus.FocusStatus.Position, + MoveStatus: resp.ImagingStatus.FocusStatus.MoveStatus, + Error: resp.ImagingStatus.FocusStatus.Error, + }, + }, nil +} diff --git a/media.go b/media.go index d179a00..d7b1e6c 100644 --- a/media.go +++ b/media.go @@ -26,8 +26,8 @@ func (c *Client) GetProfiles(ctx context.Context) ([]*Profile, error) { type GetProfilesResponse struct { XMLName xml.Name `xml:"GetProfilesResponse"` Profiles []struct { - Token string `xml:"token,attr"` - Name string `xml:"Name"` + Token string `xml:"token,attr"` + Name string `xml:"Name"` VideoSourceConfiguration *struct { Token string `xml:"token,attr"` Name string `xml:"Name"` @@ -57,9 +57,9 @@ func (c *Client) GetProfiles(ctx context.Context) ([]*Profile, error) { } `xml:"RateControl"` } `xml:"VideoEncoderConfiguration"` PTZConfiguration *struct { - Token string `xml:"token,attr"` - Name string `xml:"Name"` - UseCount int `xml:"UseCount"` + Token string `xml:"token,attr"` + Name string `xml:"Name"` + UseCount int `xml:"UseCount"` NodeToken string `xml:"NodeToken"` } `xml:"PTZConfiguration"` } `xml:"Profiles"` @@ -73,7 +73,7 @@ func (c *Client) GetProfiles(ctx context.Context) ([]*Profile, error) { username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetProfiles failed: %w", err) } @@ -148,13 +148,14 @@ func (c *Client) GetStreamURI(ctx context.Context, profileToken string) (*MediaU } type GetStreamUri struct { - XMLName xml.Name `xml:"trt:GetStreamUri"` - Xmlns string `xml:"xmlns:trt,attr"` - StreamSetup struct { - Stream string `xml:"Stream"` + XMLName xml.Name `xml:"trt:GetStreamUri"` + Xmlns string `xml:"xmlns:trt,attr"` + Xmlnst string `xml:"xmlns:tt,attr"` + StreamSetup struct { + Stream string `xml:"tt:Stream"` Transport struct { - Protocol string `xml:"Protocol"` - } `xml:"Transport"` + Protocol string `xml:"tt:Protocol"` + } `xml:"tt:Transport"` } `xml:"trt:StreamSetup"` ProfileToken string `xml:"trt:ProfileToken"` } @@ -171,6 +172,7 @@ func (c *Client) GetStreamURI(ctx context.Context, profileToken string) (*MediaU req := GetStreamUri{ Xmlns: mediaNamespace, + Xmlnst: "http://www.onvif.org/ver10/schema", ProfileToken: profileToken, } req.StreamSetup.Stream = "RTP-Unicast" @@ -180,7 +182,7 @@ func (c *Client) GetStreamURI(ctx context.Context, profileToken string) (*MediaU username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetStreamUri failed: %w", err) } @@ -224,7 +226,7 @@ func (c *Client) GetSnapshotURI(ctx context.Context, profileToken string) (*Medi username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetSnapshotUri failed: %w", err) } @@ -278,7 +280,7 @@ func (c *Client) GetVideoEncoderConfiguration(ctx context.Context, configuration username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetVideoEncoderConfiguration failed: %w", err) } @@ -308,3 +310,291 @@ func (c *Client) GetVideoEncoderConfiguration(ctx context.Context, configuration return config, nil } + +// GetVideoSources retrieves all video sources +func (c *Client) GetVideoSources(ctx context.Context) ([]*VideoSource, error) { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type GetVideoSources struct { + XMLName xml.Name `xml:"trt:GetVideoSources"` + Xmlns string `xml:"xmlns:trt,attr"` + } + + type GetVideoSourcesResponse struct { + XMLName xml.Name `xml:"GetVideoSourcesResponse"` + VideoSources []struct { + Token string `xml:"token,attr"` + Framerate float64 `xml:"Framerate"` + Resolution struct { + Width int `xml:"Width"` + Height int `xml:"Height"` + } `xml:"Resolution"` + } `xml:"VideoSources"` + } + + req := GetVideoSources{ + Xmlns: mediaNamespace, + } + + var resp GetVideoSourcesResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetVideoSources failed: %w", err) + } + + sources := make([]*VideoSource, len(resp.VideoSources)) + for i, s := range resp.VideoSources { + sources[i] = &VideoSource{ + Token: s.Token, + Framerate: s.Framerate, + Resolution: &VideoResolution{ + Width: s.Resolution.Width, + Height: s.Resolution.Height, + }, + } + } + + return sources, nil +} + +// GetAudioSources retrieves all audio sources +func (c *Client) GetAudioSources(ctx context.Context) ([]*AudioSource, error) { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type GetAudioSources struct { + XMLName xml.Name `xml:"trt:GetAudioSources"` + Xmlns string `xml:"xmlns:trt,attr"` + } + + type GetAudioSourcesResponse struct { + XMLName xml.Name `xml:"GetAudioSourcesResponse"` + AudioSources []struct { + Token string `xml:"token,attr"` + Channels int `xml:"Channels"` + } `xml:"AudioSources"` + } + + req := GetAudioSources{ + Xmlns: mediaNamespace, + } + + var resp GetAudioSourcesResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetAudioSources failed: %w", err) + } + + sources := make([]*AudioSource, len(resp.AudioSources)) + for i, s := range resp.AudioSources { + sources[i] = &AudioSource{ + Token: s.Token, + Channels: s.Channels, + } + } + + return sources, nil +} + +// GetAudioOutputs retrieves all audio outputs +func (c *Client) GetAudioOutputs(ctx context.Context) ([]*AudioOutput, error) { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type GetAudioOutputs struct { + XMLName xml.Name `xml:"trt:GetAudioOutputs"` + Xmlns string `xml:"xmlns:trt,attr"` + } + + type GetAudioOutputsResponse struct { + XMLName xml.Name `xml:"GetAudioOutputsResponse"` + AudioOutputs []struct { + Token string `xml:"token,attr"` + } `xml:"AudioOutputs"` + } + + req := GetAudioOutputs{ + Xmlns: mediaNamespace, + } + + var resp GetAudioOutputsResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetAudioOutputs failed: %w", err) + } + + outputs := make([]*AudioOutput, len(resp.AudioOutputs)) + for i, o := range resp.AudioOutputs { + outputs[i] = &AudioOutput{ + Token: o.Token, + } + } + + return outputs, nil +} + +// CreateProfile creates a new media profile +func (c *Client) CreateProfile(ctx context.Context, name, token string) (*Profile, error) { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type CreateProfile struct { + XMLName xml.Name `xml:"trt:CreateProfile"` + Xmlns string `xml:"xmlns:trt,attr"` + Name string `xml:"trt:Name"` + Token *string `xml:"trt:Token,omitempty"` + } + + type CreateProfileResponse struct { + XMLName xml.Name `xml:"CreateProfileResponse"` + Profile struct { + Token string `xml:"token,attr"` + Name string `xml:"Name"` + } `xml:"Profile"` + } + + req := CreateProfile{ + Xmlns: mediaNamespace, + Name: name, + } + if token != "" { + req.Token = &token + } + + var resp CreateProfileResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("CreateProfile failed: %w", err) + } + + return &Profile{ + Token: resp.Profile.Token, + Name: resp.Profile.Name, + }, nil +} + +// DeleteProfile deletes a media profile +func (c *Client) DeleteProfile(ctx context.Context, profileToken string) error { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type DeleteProfile struct { + XMLName xml.Name `xml:"trt:DeleteProfile"` + Xmlns string `xml:"xmlns:trt,attr"` + ProfileToken string `xml:"trt:ProfileToken"` + } + + req := DeleteProfile{ + Xmlns: mediaNamespace, + ProfileToken: profileToken, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("DeleteProfile failed: %w", err) + } + + return nil +} + +// SetVideoEncoderConfiguration sets video encoder configuration +func (c *Client) SetVideoEncoderConfiguration(ctx context.Context, config *VideoEncoderConfiguration, forcePersistence bool) error { + endpoint := c.mediaEndpoint + if endpoint == "" { + endpoint = c.endpoint + } + + type SetVideoEncoderConfiguration struct { + XMLName xml.Name `xml:"trt:SetVideoEncoderConfiguration"` + Xmlns string `xml:"xmlns:trt,attr"` + Xmlnst string `xml:"xmlns:tt,attr"` + Configuration struct { + Token string `xml:"token,attr"` + Name string `xml:"tt:Name"` + UseCount int `xml:"tt:UseCount"` + Encoding string `xml:"tt:Encoding"` + Resolution *struct { + Width int `xml:"tt:Width"` + Height int `xml:"tt:Height"` + } `xml:"tt:Resolution,omitempty"` + Quality *float64 `xml:"tt:Quality,omitempty"` + RateControl *struct { + FrameRateLimit int `xml:"tt:FrameRateLimit"` + EncodingInterval int `xml:"tt:EncodingInterval"` + BitrateLimit int `xml:"tt:BitrateLimit"` + } `xml:"tt:RateControl,omitempty"` + } `xml:"trt:Configuration"` + ForcePersistence bool `xml:"trt:ForcePersistence"` + } + + req := SetVideoEncoderConfiguration{ + Xmlns: mediaNamespace, + Xmlnst: "http://www.onvif.org/ver10/schema", + ForcePersistence: forcePersistence, + } + + req.Configuration.Token = config.Token + req.Configuration.Name = config.Name + req.Configuration.UseCount = config.UseCount + req.Configuration.Encoding = config.Encoding + + if config.Resolution != nil { + req.Configuration.Resolution = &struct { + Width int `xml:"tt:Width"` + Height int `xml:"tt:Height"` + }{ + Width: config.Resolution.Width, + Height: config.Resolution.Height, + } + } + + if config.Quality > 0 { + req.Configuration.Quality = &config.Quality + } + + if config.RateControl != nil { + req.Configuration.RateControl = &struct { + FrameRateLimit int `xml:"tt:FrameRateLimit"` + EncodingInterval int `xml:"tt:EncodingInterval"` + BitrateLimit int `xml:"tt:BitrateLimit"` + }{ + FrameRateLimit: config.RateControl.FrameRateLimit, + EncodingInterval: config.RateControl.EncodingInterval, + BitrateLimit: config.RateControl.BitrateLimit, + } + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("SetVideoEncoderConfiguration failed: %w", err) + } + + return nil +} diff --git a/ptz.go b/ptz.go index 35c45b1..62cb54e 100644 --- a/ptz.go +++ b/ptz.go @@ -80,7 +80,7 @@ func (c *Client) ContinuousMove(ctx context.Context, profileToken string, veloci username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("ContinuousMove failed: %w", err) } @@ -202,7 +202,7 @@ func (c *Client) AbsoluteMove(ctx context.Context, profileToken string, position username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("AbsoluteMove failed: %w", err) } @@ -324,7 +324,7 @@ func (c *Client) RelativeMove(ctx context.Context, profileToken string, translat username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("RelativeMove failed: %w", err) } @@ -361,7 +361,7 @@ func (c *Client) Stop(ctx context.Context, profileToken string, panTilt, zoom bo username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("Stop failed: %w", err) } @@ -383,7 +383,7 @@ func (c *Client) GetStatus(ctx context.Context, profileToken string) (*PTZStatus } type GetStatusResponse struct { - XMLName xml.Name `xml:"GetStatusResponse"` + XMLName xml.Name `xml:"GetStatusResponse"` PTZStatus struct { Position *struct { PanTilt *struct { @@ -414,7 +414,7 @@ func (c *Client) GetStatus(ctx context.Context, profileToken string) (*PTZStatus username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetStatus failed: %w", err) } @@ -466,8 +466,8 @@ func (c *Client) GetPresets(ctx context.Context, profileToken string) ([]*PTZPre type GetPresetsResponse struct { XMLName xml.Name `xml:"GetPresetsResponse"` Preset []struct { - Token string `xml:"token,attr"` - Name string `xml:"Name"` + Token string `xml:"token,attr"` + Name string `xml:"Name"` PTZPosition *struct { PanTilt *struct { X float64 `xml:"x,attr"` @@ -491,7 +491,7 @@ func (c *Client) GetPresets(ctx context.Context, profileToken string) ([]*PTZPre username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { return nil, fmt.Errorf("GetPresets failed: %w", err) } @@ -595,10 +595,280 @@ func (c *Client) GotoPreset(ctx context.Context, profileToken, presetToken strin username, password := c.GetCredentials() soapClient := soap.NewClient(c.httpClient, username, password) - + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { return fmt.Errorf("GotoPreset failed: %w", err) } return nil } + +// SetPreset sets a preset position +func (c *Client) SetPreset(ctx context.Context, profileToken, presetName, presetToken string) (string, error) { + endpoint := c.ptzEndpoint + if endpoint == "" { + return "", ErrServiceNotSupported + } + + type SetPreset struct { + XMLName xml.Name `xml:"tptz:SetPreset"` + Xmlns string `xml:"xmlns:tptz,attr"` + ProfileToken string `xml:"tptz:ProfileToken"` + PresetName *string `xml:"tptz:PresetName,omitempty"` + PresetToken *string `xml:"tptz:PresetToken,omitempty"` + } + + type SetPresetResponse struct { + XMLName xml.Name `xml:"SetPresetResponse"` + PresetToken string `xml:"PresetToken"` + } + + req := SetPreset{ + Xmlns: ptzNamespace, + ProfileToken: profileToken, + } + + if presetName != "" { + req.PresetName = &presetName + } + if presetToken != "" { + req.PresetToken = &presetToken + } + + var resp SetPresetResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return "", fmt.Errorf("SetPreset failed: %w", err) + } + + return resp.PresetToken, nil +} + +// RemovePreset removes a preset +func (c *Client) RemovePreset(ctx context.Context, profileToken, presetToken string) error { + endpoint := c.ptzEndpoint + if endpoint == "" { + return ErrServiceNotSupported + } + + type RemovePreset struct { + XMLName xml.Name `xml:"tptz:RemovePreset"` + Xmlns string `xml:"xmlns:tptz,attr"` + ProfileToken string `xml:"tptz:ProfileToken"` + PresetToken string `xml:"tptz:PresetToken"` + } + + req := RemovePreset{ + Xmlns: ptzNamespace, + ProfileToken: profileToken, + PresetToken: presetToken, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("RemovePreset failed: %w", err) + } + + return nil +} + +// GotoHomePosition moves PTZ to home position +func (c *Client) GotoHomePosition(ctx context.Context, profileToken string, speed *PTZSpeed) error { + endpoint := c.ptzEndpoint + if endpoint == "" { + return ErrServiceNotSupported + } + + type GotoHomePosition struct { + XMLName xml.Name `xml:"tptz:GotoHomePosition"` + Xmlns string `xml:"xmlns:tptz,attr"` + ProfileToken string `xml:"tptz:ProfileToken"` + Speed *struct { + PanTilt *struct { + X float64 `xml:"x,attr"` + Y float64 `xml:"y,attr"` + Space string `xml:"space,attr,omitempty"` + } `xml:"PanTilt,omitempty"` + Zoom *struct { + X float64 `xml:"x,attr"` + Space string `xml:"space,attr,omitempty"` + } `xml:"Zoom,omitempty"` + } `xml:"tptz:Speed,omitempty"` + } + + req := GotoHomePosition{ + Xmlns: ptzNamespace, + ProfileToken: profileToken, + } + + if speed != nil { + req.Speed = &struct { + PanTilt *struct { + X float64 `xml:"x,attr"` + Y float64 `xml:"y,attr"` + Space string `xml:"space,attr,omitempty"` + } `xml:"PanTilt,omitempty"` + Zoom *struct { + X float64 `xml:"x,attr"` + Space string `xml:"space,attr,omitempty"` + } `xml:"Zoom,omitempty"` + }{} + + if speed.PanTilt != nil { + req.Speed.PanTilt = &struct { + X float64 `xml:"x,attr"` + Y float64 `xml:"y,attr"` + Space string `xml:"space,attr,omitempty"` + }{ + X: speed.PanTilt.X, + Y: speed.PanTilt.Y, + Space: speed.PanTilt.Space, + } + } + + if speed.Zoom != nil { + req.Speed.Zoom = &struct { + X float64 `xml:"x,attr"` + Space string `xml:"space,attr,omitempty"` + }{ + X: speed.Zoom.X, + Space: speed.Zoom.Space, + } + } + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("GotoHomePosition failed: %w", err) + } + + return nil +} + +// SetHomePosition sets the current position as home position +func (c *Client) SetHomePosition(ctx context.Context, profileToken string) error { + endpoint := c.ptzEndpoint + if endpoint == "" { + return ErrServiceNotSupported + } + + type SetHomePosition struct { + XMLName xml.Name `xml:"tptz:SetHomePosition"` + Xmlns string `xml:"xmlns:tptz,attr"` + ProfileToken string `xml:"tptz:ProfileToken"` + } + + req := SetHomePosition{ + Xmlns: ptzNamespace, + ProfileToken: profileToken, + } + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, nil); err != nil { + return fmt.Errorf("SetHomePosition failed: %w", err) + } + + return nil +} + +// GetConfiguration retrieves PTZ configuration +func (c *Client) GetConfiguration(ctx context.Context, configurationToken string) (*PTZConfiguration, error) { + endpoint := c.ptzEndpoint + if endpoint == "" { + return nil, ErrServiceNotSupported + } + + type GetConfiguration struct { + XMLName xml.Name `xml:"tptz:GetConfiguration"` + Xmlns string `xml:"xmlns:tptz,attr"` + PTZConfigurationToken string `xml:"tptz:PTZConfigurationToken"` + } + + type GetConfigurationResponse struct { + XMLName xml.Name `xml:"GetConfigurationResponse"` + PTZConfiguration struct { + Token string `xml:"token,attr"` + Name string `xml:"Name"` + UseCount int `xml:"UseCount"` + NodeToken string `xml:"NodeToken"` + } `xml:"PTZConfiguration"` + } + + req := GetConfiguration{ + Xmlns: ptzNamespace, + PTZConfigurationToken: configurationToken, + } + + var resp GetConfigurationResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetConfiguration failed: %w", err) + } + + return &PTZConfiguration{ + Token: resp.PTZConfiguration.Token, + Name: resp.PTZConfiguration.Name, + UseCount: resp.PTZConfiguration.UseCount, + NodeToken: resp.PTZConfiguration.NodeToken, + }, nil +} + +// GetConfigurations retrieves all PTZ configurations +func (c *Client) GetConfigurations(ctx context.Context) ([]*PTZConfiguration, error) { + endpoint := c.ptzEndpoint + if endpoint == "" { + return nil, ErrServiceNotSupported + } + + type GetConfigurations struct { + XMLName xml.Name `xml:"tptz:GetConfigurations"` + Xmlns string `xml:"xmlns:tptz,attr"` + } + + type GetConfigurationsResponse struct { + XMLName xml.Name `xml:"GetConfigurationsResponse"` + PTZConfiguration []struct { + Token string `xml:"token,attr"` + Name string `xml:"Name"` + UseCount int `xml:"UseCount"` + NodeToken string `xml:"NodeToken"` + } `xml:"PTZConfiguration"` + } + + req := GetConfigurations{ + Xmlns: ptzNamespace, + } + + var resp GetConfigurationsResponse + + username, password := c.GetCredentials() + soapClient := soap.NewClient(c.httpClient, username, password) + + if err := soapClient.Call(ctx, endpoint, "", req, &resp); err != nil { + return nil, fmt.Errorf("GetConfigurations failed: %w", err) + } + + configs := make([]*PTZConfiguration, len(resp.PTZConfiguration)) + for i, cfg := range resp.PTZConfiguration { + configs[i] = &PTZConfiguration{ + Token: cfg.Token, + Name: cfg.Name, + UseCount: cfg.UseCount, + NodeToken: cfg.NodeToken, + } + } + + return configs, nil +} diff --git a/run-camera-tests.sh b/run-camera-tests.sh new file mode 100644 index 0000000..fffd4a8 --- /dev/null +++ b/run-camera-tests.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Test script for running ONVIF camera integration tests +# Usage: ./run-camera-tests.sh [test-name] + +set -e + +# Color output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}=== ONVIF Camera Integration Tests ===${NC}" +echo + +# Check if environment variables are set +if [ -z "$ONVIF_TEST_ENDPOINT" ] || [ -z "$ONVIF_TEST_USERNAME" ] || [ -z "$ONVIF_TEST_PASSWORD" ]; then + echo -e "${YELLOW}Warning: Camera credentials not set${NC}" + echo "Set the following environment variables:" + echo " export ONVIF_TEST_ENDPOINT=\"http://192.168.1.201/onvif/device_service\"" + echo " export ONVIF_TEST_USERNAME=\"service\"" + echo " export ONVIF_TEST_PASSWORD=\"Service.1234\"" + echo + echo -e "${YELLOW}Tests will be skipped.${NC}" + echo +fi + +# Determine which tests to run +TEST_PATTERN="${1:-TestBoschFLEXIDOMEIndoor5100iIR}" + +echo -e "${GREEN}Running tests matching: ${TEST_PATTERN}${NC}" +echo + +# Run tests with verbose output +go test -v -run "$TEST_PATTERN" -timeout 60s + +# Check exit code +if [ $? -eq 0 ]; then + echo + echo -e "${GREEN}✓ All tests passed!${NC}" +else + echo + echo -e "${RED}✗ Some tests failed${NC}" + exit 1 +fi diff --git a/soap/soap.go b/soap/soap.go index 0e0621e..c966f2e 100644 --- a/soap/soap.go +++ b/soap/soap.go @@ -41,18 +41,18 @@ type Fault struct { // Security represents WS-Security header type Security struct { - XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd Security"` - MustUnderstand string `xml:"http://www.w3.org/2003/05/soap-envelope mustUnderstand,attr,omitempty"` - UsernameToken *UsernameToken `xml:"UsernameToken,omitempty"` + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd Security"` + MustUnderstand string `xml:"http://www.w3.org/2003/05/soap-envelope mustUnderstand,attr,omitempty"` + UsernameToken *UsernameToken `xml:"UsernameToken,omitempty"` } // UsernameToken represents a WS-Security username token type UsernameToken struct { - XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd UsernameToken"` - Username string `xml:"Username"` - Password Password `xml:"Password"` - Nonce Nonce `xml:"Nonce"` - Created string `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd Created"` + XMLName xml.Name `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd UsernameToken"` + Username string `xml:"Username"` + Password Password `xml:"Password"` + Nonce Nonce `xml:"Nonce"` + Created string `xml:"http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd Created"` } // Password represents a WS-Security password @@ -72,6 +72,8 @@ type Client struct { httpClient *http.Client username string password string + debug bool + logger func(format string, args ...interface{}) } // NewClient creates a new SOAP client @@ -80,6 +82,21 @@ func NewClient(httpClient *http.Client, username, password string) *Client { httpClient: httpClient, username: username, password: password, + debug: false, + logger: nil, + } +} + +// SetDebug enables debug logging with a custom logger +func (c *Client) SetDebug(enabled bool, logger func(format string, args ...interface{})) { + c.debug = enabled + c.logger = logger +} + +// logDebug logs debug information if debug mode is enabled +func (c *Client) logDebug(format string, args ...interface{}) { + if c.debug && c.logger != nil { + c.logger(format, args...) } } @@ -108,6 +125,9 @@ func (c *Client) Call(ctx context.Context, endpoint string, action string, reque // Add XML declaration xmlBody := append([]byte(xml.Header), body...) + // Log request if debug is enabled + c.logDebug("=== SOAP Request ===\nEndpoint: %s\nAction: %s\n%s\n", endpoint, action, string(xmlBody)) + // Create HTTP request req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(xmlBody)) if err != nil { @@ -133,33 +153,34 @@ func (c *Client) Call(ctx context.Context, endpoint string, action string, reque return fmt.Errorf("failed to read response body: %w", err) } + // Log response if debug is enabled + c.logDebug("=== SOAP Response ===\nStatus: %d\n%s\n", resp.StatusCode, string(respBody)) + // Check HTTP status if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, string(respBody)) } - // Parse response - var respEnvelope Envelope - if err := xml.Unmarshal(respBody, &respEnvelope); err != nil { - return fmt.Errorf("failed to unmarshal SOAP response: %w", err) + // If response is empty, return immediately + if len(respBody) == 0 { + return fmt.Errorf("received empty response body") } - // Check for SOAP fault - if respEnvelope.Body.Fault != nil { - return fmt.Errorf("SOAP fault: [%s] %s - %s", - respEnvelope.Body.Fault.Code, - respEnvelope.Body.Fault.Reason, - respEnvelope.Body.Fault.Detail) - } - - // Unmarshal response content + // Unmarshal response content if response is provided if response != nil { - // Re-marshal the body content and unmarshal into the response struct - bodyXML, err := xml.Marshal(respEnvelope.Body.Content) - if err != nil { - return fmt.Errorf("failed to marshal response body: %w", err) + // Create a flexible envelope structure for parsing responses + var envelope struct { + Body struct { + Content []byte `xml:",innerxml"` + } `xml:"Body"` } - if err := xml.Unmarshal(bodyXML, response); err != nil { + + if err := xml.Unmarshal(respBody, &envelope); err != nil { + return fmt.Errorf("failed to unmarshal SOAP envelope: %w", err) + } + + // Unmarshal the body content into the response + if err := xml.Unmarshal(envelope.Body.Content, response); err != nil { return fmt.Errorf("failed to unmarshal response: %w", err) } } diff --git a/testdata/captures/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-123259.tar.gz b/testdata/captures/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-123259.tar.gz new file mode 100644 index 0000000..73ad52f Binary files /dev/null and b/testdata/captures/Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-123259.tar.gz differ diff --git a/testdata/captures/README.md b/testdata/captures/README.md new file mode 100644 index 0000000..685bf1e --- /dev/null +++ b/testdata/captures/README.md @@ -0,0 +1,298 @@ +# Camera Test Framework + +This directory contains camera-specific tests generated from real camera XML captures. These tests ensure the ONVIF client works correctly with various camera models and prevents regressions when making changes. + +## Overview + +The test framework consists of: + +1. **Captured XML Archives** (`*.tar.gz`) - Real SOAP XML request/response pairs from cameras +2. **Generated Tests** (`*_test.go`) - Automated tests that replay captures through a mock server +3. **Test Generator** (`cmd/generate-tests`) - Tool to create tests from captures +4. **Mock Server** (`testing/mock_server.go`) - HTTP server that replays captured responses + +## Benefits + +✅ **Test Without Hardware** - Run ONVIF tests without needing physical cameras +✅ **Prevent Regressions** - Catch breaking changes before they affect real deployments +✅ **Camera Coverage** - Test against multiple camera manufacturers and models +✅ **Fast Feedback** - Tests complete in milliseconds vs. minutes with real cameras +✅ **CI/CD Ready** - Automated tests that can run in continuous integration + +## Running Tests + +### Run All Camera Tests + +```bash +go test -v ./testdata/captures/ +``` + +### Run Specific Camera + +```bash +go test -v ./testdata/captures/ -run TestBosch +``` + +### Run from Project Root + +```bash +go test -v ./... +``` + +## Adding New Camera Tests + +### 1. Capture Camera XML + +First, capture SOAP XML from your camera: + +```bash +# Run diagnostic with XML capture +./onvif-diagnostics \ + -endpoint "http://camera-ip/onvif/device_service" \ + -username "user" \ + -password "pass" \ + -capture-xml \ + -verbose +``` + +This creates an archive like: +``` +camera-logs/Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz +``` + +### 2. Copy to testdata/captures + +```bash +cp camera-logs/Manufacturer_Model_*_xmlcapture_*.tar.gz testdata/captures/ +``` + +### 3. Generate Test + +```bash +./generate-tests \ + -capture testdata/captures/Manufacturer_Model_*_xmlcapture_*.tar.gz \ + -output testdata/captures/ +``` + +This generates: +``` +testdata/captures/manufacturer_model_firmware_test.go +``` + +### 4. Run the Test + +```bash +go test -v ./testdata/captures/ -run TestManufacturerModel +``` + +## Example Workflow + +Complete example adding an AXIS camera: + +```bash +# 1. Capture from camera +./onvif-diagnostics \ + -endpoint "http://192.168.1.100/onvif/device_service" \ + -username "root" \ + -password "pass" \ + -capture-xml + +# Output: camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz + +# 2. Copy to testdata +cp camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz testdata/captures/ + +# 3. Generate test +./generate-tests \ + -capture testdata/captures/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz \ + -output testdata/captures/ + +# Output: testdata/captures/axis_q3626-ve_12.6.104_test.go + +# 4. Run test +go test -v ./testdata/captures/ -run TestAXIS +``` + +## Directory Structure + +``` +testdata/captures/ +├── README.md # This file +├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_*.tar.gz # Capture archive +├── bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go # Generated test +├── AXIS_Q3626-VE_12.6.104_xmlcapture_*.tar.gz # Another camera +└── axis_q3626-ve_12.6.104_test.go # Its test +``` + +## How It Works + +### Capture Archive Contents + +Each `*.tar.gz` archive contains: + +``` +capture_001.json # Request/response metadata +capture_001_request.xml # SOAP request +capture_001_response.xml # SOAP response +capture_002.json +capture_002_request.xml +capture_002_response.xml +... +``` + +### Mock Server + +The test framework includes a mock HTTP server that: + +1. Loads all captured exchanges from the archive +2. Extracts SOAP operation names from requests (GetDeviceInformation, GetProfiles, etc.) +3. Matches incoming test requests to captured responses by operation name +4. Returns the exact SOAP response the real camera sent + +This allows the ONVIF client to interact with "virtual cameras" that behave exactly like the real ones. + +### Generated Test + +Each generated test: + +1. Creates a mock server from the capture archive +2. Creates an ONVIF client pointing to the mock server +3. Runs common ONVIF operations (GetDeviceInformation, GetProfiles, etc.) +4. Validates responses match expected values + +## Customizing Tests + +### Adding Custom Assertions + +Edit the generated test file to add camera-specific validations: + +```go +t.Run("GetDeviceInformation", func(t *testing.T) { + info, err := client.GetDeviceInformation(ctx) + if err != nil { + t.Errorf("GetDeviceInformation failed: %v", err) + return + } + + // Add custom assertions + if info.Manufacturer != "Bosch" { + t.Errorf("Expected Bosch, got %s", info.Manufacturer) + } + if !strings.Contains(info.Model, "FLEXIDOME") { + t.Errorf("Expected FLEXIDOME model, got %s", info.Model) + } +}) +``` + +### Testing Specific Operations + +Add tests for camera-specific features: + +```go +t.Run("PTZPresets", func(t *testing.T) { + // Only for PTZ cameras + presets, err := client.GetPresets(ctx, "profile_token") + if err != nil { + t.Errorf("GetPresets failed: %v", err) + return + } + + if len(presets) == 0 { + t.Error("Expected at least one preset") + } +}) +``` + +## Troubleshooting + +### Test Fails: "No matching capture found" + +The mock server couldn't find a captured response for the operation. + +**Solution**: Re-capture from the camera to include all operations. + +### Test Fails: Unexpected Response + +The client is receiving the wrong SOAP response. + +**Solution**: Check that operation names match. The mock server matches by SOAP operation name extracted from the `` element. + +### Archive Not Found + +``` +Failed to create mock server: failed to open archive: no such file or directory +``` + +**Solution**: Ensure the capture archive is in `testdata/captures/` directory. + +## Maintenance + +### Updating Captures + +When camera firmware changes: + +1. Re-run diagnostics with `-capture-xml` +2. Replace old capture archive +3. Regenerate test (or manually update paths) +4. Re-run tests to verify + +### Cleaning Up + +Remove old captures and tests: + +```bash +rm testdata/captures/old_camera_*.tar.gz +rm testdata/captures/old_camera_test.go +``` + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Camera Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Run Camera Tests + run: go test -v ./testdata/captures/ +``` + +### Benefits in CI + +- Tests run on every commit +- Prevents merging code that breaks camera compatibility +- No need for test cameras in CI environment +- Fast execution (< 1 second for all cameras) + +## Best Practices + +1. **Capture from latest firmware** - Use up-to-date camera firmware +2. **Include all operations** - Run full diagnostic to capture all SOAP operations +3. **Document camera models** - Add comments in tests noting camera specifics +4. **Version control captures** - Commit `.tar.gz` files to track camera behavior over time +5. **Test before changes** - Run tests before making client changes to establish baseline +6. **Test after changes** - Verify all camera tests pass after modifications + +## Related Tools + +- **onvif-diagnostics** - Captures XML from cameras (`cmd/onvif-diagnostics`) +- **generate-tests** - Creates tests from captures (`cmd/generate-tests`) +- **mock_server** - Test server implementation (`testing/mock_server.go`) + +## Support + +For issues or questions: + +1. Check that capture archive is valid (can extract with `tar -xzf`) +2. Verify test file package is `onvif_test` +3. Run with `-v` flag for verbose output +4. Check `testing/mock_server.go` logs for operation matching details diff --git a/testdata/captures/bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go b/testdata/captures/bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go new file mode 100644 index 0000000..43d4e0e --- /dev/null +++ b/testdata/captures/bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go @@ -0,0 +1,98 @@ +package onvif_test + +import ( + "context" + "testing" + "time" + + "github.com/0x524A/go-onvif" + onviftesting "github.com/0x524A/go-onvif/testing" +) + +// TestBosch_FLEXIDOME_indoor_5100i_IR_8710066 tests ONVIF client against Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066 captured responses +func TestBosch_FLEXIDOME_indoor_5100i_IR_8710066(t *testing.T) { + // Load capture archive (in same directory as test) + captureArchive := "Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-123259.tar.gz" + + mockServer, err := onviftesting.NewMockSOAPServer(captureArchive) + if err != nil { + t.Fatalf("Failed to create mock server: %v", err) + } + defer mockServer.Close() + + // Create ONVIF client pointing to mock server + client, err := onvif.NewClient( + mockServer.URL()+"/onvif/device_service", + onvif.WithCredentials("testuser", "testpass"), + ) + if err != nil { + t.Fatalf("Failed to create ONVIF client: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + t.Run("GetDeviceInformation", func(t *testing.T) { + info, err := client.GetDeviceInformation(ctx) + if err != nil { + t.Errorf("GetDeviceInformation failed: %v", err) + return + } + + // Validate expected values + if info.Manufacturer == "" { + t.Error("Manufacturer is empty") + } + if info.Model == "" { + t.Error("Model is empty") + } + if info.FirmwareVersion == "" { + t.Error("FirmwareVersion is empty") + } + + t.Logf("Device: %s %s (Firmware: %s)", info.Manufacturer, info.Model, info.FirmwareVersion) + }) + + t.Run("GetSystemDateAndTime", func(t *testing.T) { + _, err := client.GetSystemDateAndTime(ctx) + if err != nil { + t.Errorf("GetSystemDateAndTime failed: %v", err) + } + }) + + t.Run("GetCapabilities", func(t *testing.T) { + caps, err := client.GetCapabilities(ctx) + if err != nil { + t.Errorf("GetCapabilities failed: %v", err) + return + } + + if caps.Device == nil { + t.Error("Device capabilities is nil") + } + if caps.Media == nil { + t.Error("Media capabilities is nil") + } + + t.Logf("Capabilities: Device=%v, Media=%v, Imaging=%v, PTZ=%v", + caps.Device != nil, caps.Media != nil, caps.Imaging != nil, caps.PTZ != nil) + }) + + t.Run("GetProfiles", func(t *testing.T) { + profiles, err := client.GetProfiles(ctx) + if err != nil { + t.Errorf("GetProfiles failed: %v", err) + return + } + + if len(profiles) == 0 { + t.Error("No profiles returned") + } + + t.Logf("Found %d profile(s)", len(profiles)) + for i, profile := range profiles { + t.Logf(" Profile %d: %s (Token: %s)", i+1, profile.Name, profile.Token) + } + }) + +} diff --git a/testdata/captures/enhanced_device_features_test.go b/testdata/captures/enhanced_device_features_test.go new file mode 100644 index 0000000..ff73eab --- /dev/null +++ b/testdata/captures/enhanced_device_features_test.go @@ -0,0 +1,367 @@ +package onvif +package captures + +import ( + "context" + "testing" + "time" + + "github.com/0x524A/go-onvif" +) + +// TestEnhancedDeviceFeatures tests new Device service methods with real camera data +// Based on test results from Bosch FLEXIDOME indoor 5100i IR (8.71.0066) +func TestEnhancedDeviceFeatures(t *testing.T) { + // Create client with test credentials + client, err := onvif.NewClient( + "http://192.168.1.201/onvif/device_service", + onvif.WithCredentials("service", "Service.1234"), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + t.Run("GetHostname", func(t *testing.T) { + hostname, err := client.GetHostname(ctx) + if err != nil { + t.Fatalf("GetHostname failed: %v", err) + } + + // Bosch camera has hostname configuration + if hostname == nil { + t.Fatal("Expected hostname information, got nil") + } + + t.Logf("Hostname: FromDHCP=%v, Name=%q", hostname.FromDHCP, hostname.Name) + }) + + t.Run("GetDNS", func(t *testing.T) { + dns, err := client.GetDNS(ctx) + if err != nil { + t.Fatalf("GetDNS failed: %v", err) + } + + if dns == nil { + t.Fatal("Expected DNS information, got nil") + } + + // Bosch camera uses DHCP for DNS + if !dns.FromDHCP { + t.Logf("Note: Camera not using DHCP for DNS") + } + + // Should have at least one DNS server + if len(dns.DNSFromDHCP) == 0 && len(dns.DNSManual) == 0 { + t.Error("Expected at least one DNS server") + } + + t.Logf("DNS: FromDHCP=%v, Servers=%d (DHCP) + %d (Manual)", + dns.FromDHCP, len(dns.DNSFromDHCP), len(dns.DNSManual)) + }) + + t.Run("GetNTP", func(t *testing.T) { + ntp, err := client.GetNTP(ctx) + if err != nil { + t.Fatalf("GetNTP failed: %v", err) + } + + if ntp == nil { + t.Fatal("Expected NTP information, got nil") + } + + // Bosch camera uses DHCP for NTP + if !ntp.FromDHCP { + t.Logf("Note: Camera not using DHCP for NTP") + } + + t.Logf("NTP: FromDHCP=%v, Servers=%d (DHCP) + %d (Manual)", + ntp.FromDHCP, len(ntp.NTPFromDHCP), len(ntp.NTPManual)) + }) + + t.Run("GetNetworkInterfaces", func(t *testing.T) { + interfaces, err := client.GetNetworkInterfaces(ctx) + if err != nil { + t.Fatalf("GetNetworkInterfaces failed: %v", err) + } + + // Bosch camera has 1 network interface + if len(interfaces) == 0 { + t.Fatal("Expected at least one network interface") + } + + iface := interfaces[0] + if iface.Token == "" { + t.Error("Expected interface to have token") + } + + if iface.Info.Name == "" { + t.Error("Expected interface to have name") + } + + if iface.Info.HwAddress == "" { + t.Error("Expected interface to have hardware address") + } + + // Bosch camera has MTU of 1514 + if iface.Info.MTU == 0 { + t.Error("Expected interface to have MTU") + } + + t.Logf("Interface: Token=%s, Name=%s, HwAddr=%s, MTU=%d", + iface.Token, iface.Info.Name, iface.Info.HwAddress, iface.Info.MTU) + + if iface.IPv4 != nil { + t.Logf(" IPv4: Enabled=%v, DHCP=%v", + iface.IPv4.Enabled, iface.IPv4.Config.DHCP) + } + }) + + t.Run("GetScopes", func(t *testing.T) { + scopes, err := client.GetScopes(ctx) + if err != nil { + t.Fatalf("GetScopes failed: %v", err) + } + + // Bosch camera has 8 scopes + if len(scopes) == 0 { + t.Fatal("Expected at least one scope") + } + + // Check for expected scopes + foundManufacturer := false + foundType := false + foundProfiles := 0 + + for _, scope := range scopes { + if scope.ScopeItem == "onvif://www.onvif.org/name/Bosch" { + foundManufacturer = true + } + if scope.ScopeItem == "onvif://www.onvif.org/type/Network_Video_Transmitter" { + foundType = true + } + // Count ONVIF profiles + if len(scope.ScopeItem) > 30 && scope.ScopeItem[:30] == "onvif://www.onvif.org/Profile/" { + foundProfiles++ + } + } + + if !foundManufacturer { + t.Error("Expected to find manufacturer scope") + } + if !foundType { + t.Error("Expected to find device type scope") + } + + t.Logf("Scopes: Total=%d, Manufacturer=%v, Type=%v, Profiles=%d", + len(scopes), foundManufacturer, foundType, foundProfiles) + }) + + t.Run("GetUsers", func(t *testing.T) { + users, err := client.GetUsers(ctx) + if err != nil { + t.Fatalf("GetUsers failed: %v", err) + } + + // Bosch camera has 3 users + if len(users) == 0 { + t.Fatal("Expected at least one user") + } + + // Verify user levels + userLevels := make(map[string]int) + for _, user := range users { + if user.Username == "" { + t.Error("Expected user to have username") + } + if user.UserLevel == "" { + t.Error("Expected user to have level") + } + userLevels[user.UserLevel]++ + } + + t.Logf("Users: Total=%d, Administrator=%d, Operator=%d, User=%d", + len(users), + userLevels["Administrator"], + userLevels["Operator"], + userLevels["User"]) + }) +} + +// TestEnhancedMediaFeatures tests new Media service methods +func TestEnhancedMediaFeatures(t *testing.T) { + client, err := onvif.NewClient( + "http://192.168.1.201/onvif/device_service", + onvif.WithCredentials("service", "Service.1234"), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + // Initialize to get media endpoint + if err := client.Initialize(ctx); err != nil { + t.Logf("Warning: Initialize failed: %v", err) + } + + t.Run("GetVideoSources", func(t *testing.T) { + sources, err := client.GetVideoSources(ctx) + if err != nil { + t.Fatalf("GetVideoSources failed: %v", err) + } + + // Bosch camera has 1 video source + if len(sources) == 0 { + t.Fatal("Expected at least one video source") + } + + source := sources[0] + if source.Token == "" { + t.Error("Expected source to have token") + } + + // Bosch camera supports 30fps + if source.Framerate == 0 { + t.Error("Expected source to have framerate") + } + + // Bosch camera has 1920x1080 resolution + if source.Resolution == nil { + t.Error("Expected source to have resolution") + } else { + if source.Resolution.Width == 0 || source.Resolution.Height == 0 { + t.Error("Expected valid resolution dimensions") + } + t.Logf("Video Source: Token=%s, Framerate=%.1ffps, Resolution=%dx%d", + source.Token, source.Framerate, + source.Resolution.Width, source.Resolution.Height) + } + }) + + t.Run("GetAudioSources", func(t *testing.T) { + sources, err := client.GetAudioSources(ctx) + if err != nil { + t.Fatalf("GetAudioSources failed: %v", err) + } + + // Bosch camera has 1 audio source with 2 channels + if len(sources) == 0 { + t.Fatal("Expected at least one audio source") + } + + source := sources[0] + if source.Token == "" { + t.Error("Expected source to have token") + } + + t.Logf("Audio Source: Token=%s, Channels=%d", + source.Token, source.Channels) + }) + + t.Run("GetAudioOutputs", func(t *testing.T) { + outputs, err := client.GetAudioOutputs(ctx) + if err != nil { + t.Fatalf("GetAudioOutputs failed: %v", err) + } + + // Bosch camera has 1 audio output + if len(outputs) == 0 { + t.Fatal("Expected at least one audio output") + } + + output := outputs[0] + if output.Token == "" { + t.Error("Expected output to have token") + } + + t.Logf("Audio Output: Token=%s", output.Token) + }) +} + +// TestEnhancedImagingFeatures tests new Imaging service methods +func TestEnhancedImagingFeatures(t *testing.T) { + client, err := onvif.NewClient( + "http://192.168.1.201/onvif/device_service", + onvif.WithCredentials("service", "Service.1234"), + onvif.WithTimeout(30*time.Second), + ) + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + ctx := context.Background() + + // Initialize to get imaging endpoint + if err := client.Initialize(ctx); err != nil { + t.Logf("Warning: Initialize failed: %v", err) + } + + // Get video source token + sources, err := client.GetVideoSources(ctx) + if err != nil || len(sources) == 0 { + t.Skip("No video sources available for imaging tests") + } + + videoSourceToken := sources[0].Token + + t.Run("GetOptions", func(t *testing.T) { + options, err := client.GetOptions(ctx, videoSourceToken) + if err != nil { + t.Fatalf("GetOptions failed: %v", err) + } + + if options == nil { + t.Fatal("Expected imaging options, got nil") + } + + // Bosch camera supports brightness (0-255) + if options.Brightness != nil { + if options.Brightness.Min > options.Brightness.Max { + t.Error("Expected Min <= Max for brightness") + } + t.Logf("Brightness range: %.0f - %.0f", + options.Brightness.Min, options.Brightness.Max) + } + + // Bosch camera supports color saturation (0-255) + if options.ColorSaturation != nil { + if options.ColorSaturation.Min > options.ColorSaturation.Max { + t.Error("Expected Min <= Max for color saturation") + } + t.Logf("ColorSaturation range: %.0f - %.0f", + options.ColorSaturation.Min, options.ColorSaturation.Max) + } + + // Bosch camera supports contrast (0-255) + if options.Contrast != nil { + if options.Contrast.Min > options.Contrast.Max { + t.Error("Expected Min <= Max for contrast") + } + t.Logf("Contrast range: %.0f - %.0f", + options.Contrast.Min, options.Contrast.Max) + } + }) + + t.Run("GetMoveOptions", func(t *testing.T) { + moveOptions, err := client.GetMoveOptions(ctx, videoSourceToken) + if err != nil { + t.Fatalf("GetMoveOptions failed: %v", err) + } + + if moveOptions == nil { + t.Fatal("Expected move options, got nil") + } + + // Log available move options + hasAbsolute := moveOptions.Absolute != nil + hasRelative := moveOptions.Relative != nil + hasContinuous := moveOptions.Continuous != nil + + t.Logf("Move Options: Absolute=%v, Relative=%v, Continuous=%v", + hasAbsolute, hasRelative, hasContinuous) + }) +} diff --git a/testing/mock_server.go b/testing/mock_server.go new file mode 100644 index 0000000..4af59f7 --- /dev/null +++ b/testing/mock_server.go @@ -0,0 +1,207 @@ +package onviftesting + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" +) + +// CapturedExchange represents a single SOAP request/response pair +type CapturedExchange struct { + Timestamp string `json:"timestamp"` + Operation int `json:"operation"` + OperationName string `json:"operation_name,omitempty"` + Endpoint string `json:"endpoint"` + RequestBody string `json:"request_body"` + ResponseBody string `json:"response_body"` + StatusCode int `json:"status_code"` + Error string `json:"error,omitempty"` +} + +// CameraCapture holds all captured exchanges for a camera +type CameraCapture struct { + CameraName string + Exchanges []CapturedExchange +} + +// LoadCaptureFromArchive loads all captured exchanges from a tar.gz archive +func LoadCaptureFromArchive(archivePath string) (*CameraCapture, error) { + file, err := os.Open(archivePath) + if err != nil { + return nil, fmt.Errorf("failed to open archive: %w", err) + } + defer file.Close() + + gzr, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %w", err) + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + + capture := &CameraCapture{ + CameraName: filepath.Base(archivePath), + Exchanges: make([]CapturedExchange, 0), + } + + // Read all .json files from the archive + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read tar header: %w", err) + } + + // Only process JSON metadata files + if !strings.HasSuffix(header.Name, ".json") { + continue + } + + data, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", header.Name, err) + } + + var exchange CapturedExchange + if err := json.Unmarshal(data, &exchange); err != nil { + return nil, fmt.Errorf("failed to unmarshal %s: %w", header.Name, err) + } + + capture.Exchanges = append(capture.Exchanges, exchange) + } + + return capture, nil +} + +// MockSOAPServer creates a test HTTP server that replays captured SOAP responses +type MockSOAPServer struct { + Server *httptest.Server + Capture *CameraCapture +} + +// NewMockSOAPServer creates a new mock server from a capture archive +func NewMockSOAPServer(archivePath string) (*MockSOAPServer, error) { + capture, err := LoadCaptureFromArchive(archivePath) + if err != nil { + return nil, err + } + + mock := &MockSOAPServer{ + Capture: capture, + } + + // Create HTTP test server + mock.Server = httptest.NewServer(http.HandlerFunc(mock.handleRequest)) + + return mock, nil +} + +// handleRequest matches incoming requests to captured responses +func (m *MockSOAPServer) handleRequest(w http.ResponseWriter, r *http.Request) { + // Read request body + reqBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request", http.StatusBadRequest) + return + } + + // Extract operation name from request + operationName := extractOperationFromSOAP(string(reqBody)) + + // Find matching response by operation name + var exchange *CapturedExchange + + if operationName != "" { + // Try matching by operation_name field if available + for i := range m.Capture.Exchanges { + if m.Capture.Exchanges[i].OperationName == operationName { + exchange = &m.Capture.Exchanges[i] + break + } + } + + // If not found by operation_name, try matching by extracting from request body + if exchange == nil { + for i := range m.Capture.Exchanges { + capturedOp := extractOperationFromSOAP(m.Capture.Exchanges[i].RequestBody) + if capturedOp == operationName { + exchange = &m.Capture.Exchanges[i] + break + } + } + } + } + + if exchange == nil { + http.Error(w, fmt.Sprintf("No matching capture found for operation: %s", operationName), http.StatusNotFound) + return + } + + // Return the captured response + w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8") + w.WriteHeader(exchange.StatusCode) + w.Write([]byte(exchange.ResponseBody)) +} + +// Close shuts down the mock server +func (m *MockSOAPServer) Close() { + m.Server.Close() +} + +// URL returns the mock server's URL +func (m *MockSOAPServer) URL() string { + return m.Server.URL +} + +// extractOperationFromSOAP extracts the SOAP operation name from request body +func extractOperationFromSOAP(soapBody string) string { + // Find the Body element + bodyStart := strings.Index(soapBody, " of the Body opening tag + bodyOpenEnd := strings.Index(soapBody[bodyStart:], ">") + if bodyOpenEnd == -1 { + return "" + } + bodyContentStart := bodyStart + bodyOpenEnd + 1 + + // Skip whitespace + for bodyContentStart < len(soapBody) && soapBody[bodyContentStart] <= ' ' { + bodyContentStart++ + } + + if bodyContentStart >= len(soapBody) || soapBody[bodyContentStart] != '<' { + return "" + } + + // Extract the tag name + tagStart := bodyContentStart + 1 + tagEnd := tagStart + for tagEnd < len(soapBody) && soapBody[tagEnd] != ' ' && soapBody[tagEnd] != '>' && soapBody[tagEnd] != '/' { + tagEnd++ + } + + if tagEnd > tagStart { + tagName := soapBody[tagStart:tagEnd] + // Remove namespace prefix if present + if colonIdx := strings.Index(tagName, ":"); colonIdx != -1 { + return tagName[colonIdx+1:] + } + return tagName + } + + return "" +} diff --git a/types.go b/types.go index 235e161..4ce4555 100644 --- a/types.go +++ b/types.go @@ -24,25 +24,25 @@ type Capabilities struct { // AnalyticsCapabilities represents analytics service capabilities type AnalyticsCapabilities struct { - XAddr string - RuleSupport bool + XAddr string + RuleSupport bool AnalyticsModuleSupport bool } // DeviceCapabilities represents device service capabilities type DeviceCapabilities struct { - XAddr string - Network *NetworkCapabilities - System *SystemCapabilities - IO *IOCapabilities + XAddr string + Network *NetworkCapabilities + System *SystemCapabilities + IO *IOCapabilities Security *SecurityCapabilities } // EventCapabilities represents event service capabilities type EventCapabilities struct { - XAddr string - WSSubscriptionPolicySupport bool - WSPullPointSupport bool + XAddr string + WSSubscriptionPolicySupport bool + WSPullPointSupport bool WSPausableSubscriptionSupport bool } @@ -53,7 +53,7 @@ type ImagingCapabilities struct { // MediaCapabilities represents media service capabilities type MediaCapabilities struct { - XAddr string + XAddr string StreamingCapabilities *StreamingCapabilities } @@ -64,51 +64,51 @@ type PTZCapabilities struct { // NetworkCapabilities represents network capabilities type NetworkCapabilities struct { - IPFilter bool - ZeroConfiguration bool - IPVersion6 bool - DynDNS bool - Extension *NetworkCapabilitiesExtension + IPFilter bool + ZeroConfiguration bool + IPVersion6 bool + DynDNS bool + Extension *NetworkCapabilitiesExtension } // SystemCapabilities represents system capabilities type SystemCapabilities struct { - DiscoveryResolve bool - DiscoveryBye bool - RemoteDiscovery bool - SystemBackup bool - SystemLogging bool - FirmwareUpgrade bool - SupportedVersions []string - Extension *SystemCapabilitiesExtension + DiscoveryResolve bool + DiscoveryBye bool + RemoteDiscovery bool + SystemBackup bool + SystemLogging bool + FirmwareUpgrade bool + SupportedVersions []string + Extension *SystemCapabilitiesExtension } // IOCapabilities represents I/O capabilities type IOCapabilities struct { - InputConnectors int - RelayOutputs int - Extension *IOCapabilitiesExtension + InputConnectors int + RelayOutputs int + Extension *IOCapabilitiesExtension } // SecurityCapabilities represents security capabilities type SecurityCapabilities struct { - TLS11 bool - TLS12 bool + TLS11 bool + TLS12 bool OnboardKeyGeneration bool AccessPolicyConfig bool - X509Token bool - SAMLToken bool - KerberosToken bool - RELToken bool - Extension *SecurityCapabilitiesExtension + X509Token bool + SAMLToken bool + KerberosToken bool + RELToken bool + Extension *SecurityCapabilitiesExtension } // StreamingCapabilities represents streaming capabilities type StreamingCapabilities struct { - RTPMulticast bool - RTP_TCP bool - RTP_RTSP_TCP bool - Extension *StreamingCapabilitiesExtension + RTPMulticast bool + RTP_TCP bool + RTP_RTSP_TCP bool + Extension *StreamingCapabilitiesExtension } // Extension types @@ -121,15 +121,15 @@ type StreamingCapabilitiesExtension struct{} // Profile represents a media profile type Profile struct { - Token string - Name string - VideoSourceConfiguration *VideoSourceConfiguration - AudioSourceConfiguration *AudioSourceConfiguration - VideoEncoderConfiguration *VideoEncoderConfiguration - AudioEncoderConfiguration *AudioEncoderConfiguration - PTZConfiguration *PTZConfiguration - MetadataConfiguration *MetadataConfiguration - Extension *ProfileExtension + Token string + Name string + VideoSourceConfiguration *VideoSourceConfiguration + AudioSourceConfiguration *AudioSourceConfiguration + VideoEncoderConfiguration *VideoEncoderConfiguration + AudioEncoderConfiguration *AudioEncoderConfiguration + PTZConfiguration *PTZConfiguration + MetadataConfiguration *MetadataConfiguration + Extension *ProfileExtension } // VideoSourceConfiguration represents video source configuration @@ -151,17 +151,17 @@ type AudioSourceConfiguration struct { // VideoEncoderConfiguration represents video encoder configuration type VideoEncoderConfiguration struct { - Token string - Name string - UseCount int - Encoding string // JPEG, MPEG4, H264 - Resolution *VideoResolution - Quality float64 - RateControl *VideoRateControl - MPEG4 *MPEG4Configuration - H264 *H264Configuration - Multicast *MulticastConfiguration - SessionTimeout time.Duration + Token string + Name string + UseCount int + Encoding string // JPEG, MPEG4, H264 + Resolution *VideoResolution + Quality float64 + RateControl *VideoRateControl + MPEG4 *MPEG4Configuration + H264 *H264Configuration + Multicast *MulticastConfiguration + SessionTimeout time.Duration } // AudioEncoderConfiguration represents audio encoder configuration @@ -178,20 +178,20 @@ type AudioEncoderConfiguration struct { // PTZConfiguration represents PTZ configuration type PTZConfiguration struct { - Token string - Name string - UseCount int - NodeToken string - DefaultAbsolutePantTiltPositionSpace string - DefaultAbsoluteZoomPositionSpace string + Token string + Name string + UseCount int + NodeToken string + DefaultAbsolutePantTiltPositionSpace string + DefaultAbsoluteZoomPositionSpace string DefaultRelativePanTiltTranslationSpace string DefaultRelativeZoomTranslationSpace string DefaultContinuousPanTiltVelocitySpace string DefaultContinuousZoomVelocitySpace string - DefaultPTZSpeed *PTZSpeed - DefaultPTZTimeout time.Duration - PanTiltLimits *PanTiltLimits - ZoomLimits *ZoomLimits + DefaultPTZSpeed *PTZSpeed + DefaultPTZTimeout time.Duration + PanTiltLimits *PanTiltLimits + ZoomLimits *ZoomLimits } // MetadataConfiguration represents metadata configuration @@ -214,14 +214,14 @@ type VideoResolution struct { // VideoRateControl represents video rate control type VideoRateControl struct { - FrameRateLimit int - EncodingInterval int - BitrateLimit int + FrameRateLimit int + EncodingInterval int + BitrateLimit int } // MPEG4Configuration represents MPEG4 configuration type MPEG4Configuration struct { - GovLength int + GovLength int MPEG4Profile string } @@ -241,8 +241,10 @@ type MulticastConfiguration struct { // IPAddress represents an IP address type IPAddress struct { - Type string // IPv4 or IPv6 - Address string + Type string // IPv4 or IPv6 + Address string + IPv4Address string + IPv6Address string } // IntRectangle represents a rectangle with integer coordinates @@ -291,7 +293,7 @@ type Space2DDescription struct { // Space1DDescription represents 1D space description type Space1DDescription struct { - URI string + URI string XRange *FloatRange } @@ -365,8 +367,8 @@ type PTZMoveStatus struct { // PTZPreset represents a PTZ preset type PTZPreset struct { - Token string - Name string + Token string + Name string PTZPosition *PTZVector } @@ -393,17 +395,17 @@ type BacklightCompensation struct { // Exposure represents exposure settings type Exposure struct { - Mode string // AUTO, MANUAL - Priority string // LowNoise, FrameRate + Mode string // AUTO, MANUAL + Priority string // LowNoise, FrameRate MinExposureTime float64 MaxExposureTime float64 - MinGain float64 - MaxGain float64 - MinIris float64 - MaxIris float64 - ExposureTime float64 - Gain float64 - Iris float64 + MinGain float64 + MaxGain float64 + MinIris float64 + MaxIris float64 + ExposureTime float64 + Gain float64 + Iris float64 } // FocusConfiguration represents focus configuration @@ -429,3 +431,208 @@ type WhiteBalance struct { // ImagingSettingsExtension represents imaging settings extension type ImagingSettingsExtension struct{} + +// HostnameInformation represents hostname configuration +type HostnameInformation struct { + FromDHCP bool + Name string +} + +// DNSInformation represents DNS configuration +type DNSInformation struct { + FromDHCP bool + SearchDomain []string + DNSFromDHCP []IPAddress + DNSManual []IPAddress +} + +// NTPInformation represents NTP configuration +type NTPInformation struct { + FromDHCP bool + NTPFromDHCP []NetworkHost + NTPManual []NetworkHost +} + +// NetworkHost represents a network host +type NetworkHost struct { + Type string // IPv4, IPv6, DNS + IPv4Address string + IPv6Address string + DNSname string +} + +// NetworkInterface represents a network interface +type NetworkInterface struct { + Token string + Enabled bool + Info NetworkInterfaceInfo + IPv4 *IPv4NetworkInterface + IPv6 *IPv6NetworkInterface +} + +// NetworkInterfaceInfo represents network interface info +type NetworkInterfaceInfo struct { + Name string + HwAddress string + MTU int +} + +// IPv4NetworkInterface represents IPv4 configuration +type IPv4NetworkInterface struct { + Enabled bool + Config IPv4Configuration +} + +// IPv6NetworkInterface represents IPv6 configuration +type IPv6NetworkInterface struct { + Enabled bool + Config IPv6Configuration +} + +// IPv4Configuration represents IPv4 configuration +type IPv4Configuration struct { + Manual []PrefixedIPv4Address + DHCP bool +} + +// IPv6Configuration represents IPv6 configuration +type IPv6Configuration struct { + Manual []PrefixedIPv6Address + DHCP bool +} + +// PrefixedIPv4Address represents an IPv4 address with prefix +type PrefixedIPv4Address struct { + Address string + PrefixLength int +} + +// PrefixedIPv6Address represents an IPv6 address with prefix +type PrefixedIPv6Address struct { + Address string + PrefixLength int +} + +// Scope represents a device scope +type Scope struct { + ScopeDef string + ScopeItem string +} + +// User represents a user account +type User struct { + Username string + Password string + UserLevel string // Administrator, Operator, User +} + +// VideoSource represents a video source +type VideoSource struct { + Token string + Framerate float64 + Resolution *VideoResolution + Imaging *ImagingSettings +} + +// AudioSource represents an audio source +type AudioSource struct { + Token string + Channels int +} + +// AudioOutput represents an audio output +type AudioOutput struct { + Token string +} + +// ImagingOptions represents available imaging options +type ImagingOptions struct { + BacklightCompensation *BacklightCompensationOptions + Brightness *FloatRange + ColorSaturation *FloatRange + Contrast *FloatRange + Exposure *ExposureOptions + Focus *FocusOptions + IrCutFilterModes []string + Sharpness *FloatRange + WideDynamicRange *WideDynamicRangeOptions + WhiteBalance *WhiteBalanceOptions +} + +// BacklightCompensationOptions represents backlight compensation options +type BacklightCompensationOptions struct { + Mode []string + Level *FloatRange +} + +// ExposureOptions represents exposure options +type ExposureOptions struct { + Mode []string + Priority []string + MinExposureTime *FloatRange + MaxExposureTime *FloatRange + MinGain *FloatRange + MaxGain *FloatRange + MinIris *FloatRange + MaxIris *FloatRange + ExposureTime *FloatRange + Gain *FloatRange + Iris *FloatRange +} + +// FocusOptions represents focus options +type FocusOptions struct { + AutoFocusModes []string + DefaultSpeed *FloatRange + NearLimit *FloatRange + FarLimit *FloatRange +} + +// WideDynamicRangeOptions represents WDR options +type WideDynamicRangeOptions struct { + Mode []string + Level *FloatRange +} + +// WhiteBalanceOptions represents white balance options +type WhiteBalanceOptions struct { + Mode []string + YrGain *FloatRange + YbGain *FloatRange +} + +// MoveOptions represents imaging move options +type MoveOptions struct { + Absolute *AbsoluteFocusOptions + Relative *RelativeFocusOptions + Continuous *ContinuousFocusOptions +} + +// AbsoluteFocusOptions represents absolute focus options +type AbsoluteFocusOptions struct { + Position FloatRange + Speed FloatRange +} + +// RelativeFocusOptions represents relative focus options +type RelativeFocusOptions struct { + Distance FloatRange + Speed FloatRange +} + +// ContinuousFocusOptions represents continuous focus options +type ContinuousFocusOptions struct { + Speed FloatRange +} + +// ImagingStatus represents imaging status +type ImagingStatus struct { + FocusStatus *FocusStatus +} + +// FocusStatus represents focus status +type FocusStatus struct { + Position float64 + MoveStatus string + Error string +}