Add camera test framework and initial tests for Bosch FLEXIDOME indoor 5100i IR
- Introduced a new directory `testdata/captures/` containing captured XML archives and README documentation for the camera test framework. - Added a mock server implementation to replay captured SOAP responses for testing. - Created automated tests for Bosch FLEXIDOME indoor 5100i IR using captured responses, validating device information, system date and time, capabilities, and profiles. - Implemented enhanced device features tests, covering hostname, DNS, NTP, network interfaces, scopes, and user management. - Added support for enhanced media and imaging features, including video and audio sources, and imaging options. - Updated types to include new configurations and options for network, imaging, and device capabilities.
This commit is contained in:
@@ -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
|
||||
+13
@@ -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
|
||||
|
||||
@@ -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**
|
||||
+140
@@ -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: `<manufacturer>_<model>_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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 `<vendor>_<model>_test.go`
|
||||
2. Update test function names
|
||||
3. Update expected values
|
||||
4. Run tests to verify
|
||||
5. Document in CAMERA_TESTS.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
|
||||
<Media>
|
||||
<XAddr>http://127.0.0.1/onvif/services</XAddr>
|
||||
</Media>
|
||||
```
|
||||
|
||||
### 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
|
||||
<SOAP-ENV:Fault>
|
||||
<SOAP-ENV:Code>
|
||||
<SOAP-ENV:Subcode>
|
||||
<SOAP-ENV:Value>ter:IncompleteConfiguration</SOAP-ENV:Value>
|
||||
</SOAP-ENV:Subcode>
|
||||
</SOAP-ENV:Code>
|
||||
<SOAP-ENV:Reason>
|
||||
<SOAP-ENV:Text>Configuration not complete</SOAP-ENV:Text>
|
||||
</SOAP-ENV:Reason>
|
||||
</SOAP-ENV:Fault>
|
||||
```
|
||||
|
||||
### 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 `<Fault>` elements
|
||||
- Compare namespace prefixes (tds, trt, tt, etc.)
|
||||
- Look for XAddr values in capabilities response
|
||||
- Verify authentication headers in request XML
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
|
||||
xmlns:tds="http://www.onvif.org/ver10/device/wsdl">
|
||||
<soap:Body>
|
||||
<tds:GetDeviceInformation/>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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!")
|
||||
}
|
||||
+278
-7
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+47
-26
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+298
@@ -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 `<Body>` 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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
+367
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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, "<Body")
|
||||
if bodyStart == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the closing > 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 ""
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user