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:
ProtoTess
2025-11-11 02:10:04 +00:00
parent 3340094f4f
commit 3bf078ed3f
27 changed files with 5701 additions and 147 deletions
+120
View File
@@ -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
View File
@@ -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
+706
View File
@@ -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
View File
@@ -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
+82
View File
@@ -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.
+116
View File
@@ -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
+380
View File
@@ -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
+426 -4
View File
@@ -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
}
+255
View File
@@ -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!")
}
+152
View File
@@ -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))
}
+162
View File
@@ -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))
}
+255
View File
@@ -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")
}
+39
View File
@@ -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()
}
}
+100
View File
@@ -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)
}
+444
View File
@@ -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)
}
}
+93
View File
@@ -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
View File
@@ -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
}
+305 -15
View File
@@ -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
}
+280 -10
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -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)
}
}
+298
View File
@@ -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
View File
@@ -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)
})
}
+207
View File
@@ -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 ""
}
+292 -85
View File
@@ -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
}